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/download-build-artifacts.sh b/.github/scripts/download-build-artifacts.sh index 91109d98..669871fc 100755 --- a/.github/scripts/download-build-artifacts.sh +++ b/.github/scripts/download-build-artifacts.sh @@ -58,55 +58,7 @@ artifact_present() { 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 + bun .github/scripts/merge-checksum-manifest.mjs "$existing" "$incoming" } merge_downloaded_artifact() { 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/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..82332440 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: @@ -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..09b0aaa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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,8 +149,8 @@ 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/dev/bun.sh tools/release/sync-release-pr.mjs + tools/dev/bun.sh tools/release/sync-release-pr.mjs --check tools/release/release.py check if git diff --quiet; then @@ -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 @@ -339,21 +314,6 @@ jobs: 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" - - name: Download WASIX runtime build artifacts if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: @@ -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" + local artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/release/release.py ci-artifacts --product "$product" --family sdk-package) .github/scripts/download-build-artifacts.sh \ 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/release/release.py ci-products --family sdk-package --products-json "$PRODUCTS_JSON") - name: Download liboliphaunt release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} @@ -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" + local product="$1" + local kind="$2" + local destination="$3" + local artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/release/release.py ci-artifacts --product "$product" --kind "$kind" --family release-assets) .github/scripts/download-build-artifacts.sh \ 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: | + artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/release/release.py ci-artifacts --product oliphaunt-node-direct --kind node-direct-addon --family npm-package) .github/scripts/download-build-artifacts.sh \ 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' }} 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/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..f8cbf801 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,8 +1007,8 @@ 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 diff --git a/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md b/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md new file mode 100644 index 00000000..a625a9a9 --- /dev/null +++ b/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md @@ -0,0 +1,1455 @@ +# 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] 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. +- [ ] 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. +- [ ] Re-run Linux CI-like and release/local-registry lanes after each tooling + migration batch. + +### Current Fresh Evidence + +- 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: 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 entrypoint, 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 both full-path 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 entrypoint 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 entrypoints 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/release/build-sdk-ci-artifacts.sh`, + `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. +- [ ] 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/release/build-sdk-ci-artifacts.sh 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.sh` 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 release orchestration/package validators, + product metadata adapters, the WASIX Cargo artifact packager, local registry + publishing, release policy checks, and the extension model generator. New + Python files must either be intentionally allowlisted or ported to Bun. The + per-Python-script migration decisions remain open. +- 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.py plan --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, which now contains 33 tracked files. + 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`; the intentional + Python inventory contained 32 tracked files at that point. +- 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. +- 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. +- 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` now backs `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. diff --git a/docs/internal/IMPLEMENTATION_CHECKLIST.md b/docs/internal/IMPLEMENTATION_CHECKLIST.md index da618f9a..fd99087c 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` @@ -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. @@ -252,7 +252,7 @@ or CI/build output proves the contract. the package boundary. Evidence: `tools/release/build-sdk-ci-artifacts.sh` 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 @@ -392,35 +392,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, @@ -511,7 +511,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 +526,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. @@ -541,10 +541,11 @@ Run before claiming this architecture complete: - [x] `bash -n tools/release/build-sdk-ci-artifacts.sh src/sdks/swift/tools/check-sdk.sh` - [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,14 +571,14 @@ 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. @@ -608,44 +609,44 @@ Run before claiming this architecture complete: 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,7 +688,7 @@ 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` @@ -984,7 +985,7 @@ 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 @@ -992,7 +993,7 @@ Run before claiming this architecture complete: 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 + check`, `tools/release/sync-example-lockfiles.mjs --check`, `cargo metadata --locked --format-version 1 --no-deps`, `tools/release/release.py check-registries --products-json "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD`, and @@ -1000,7 +1001,7 @@ Run before claiming this architecture complete: - [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 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..6ab94f0e 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 diff --git a/docs/maintainers/examples-ci-release-validation.md b/docs/maintainers/examples-ci-release-validation.md new file mode 100644 index 00000000..c73a5564 --- /dev/null +++ b/docs/maintainers/examples-ci-release-validation.md @@ -0,0 +1,237 @@ +# 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 + +- [ ] Verify CI lanes build and upload 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. +- [ ] 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. +- [ ] Document local runner limitations instead of pretending macOS, Windows, iOS, or + Android lanes were validated on Linux. + +## P1: SDK Consistency + +- [ ] Compare native runtime/tool/extension/ICU resolution across Rust, JS, React + Native, Swift, and Kotlin. +- [ ] 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. +- [ ] 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 + +- [ ] Run dead-code scans for Rust, TypeScript, shell, and release scripts. +- [ ] Remove generated or stale example build outputs if they are tracked accidentally. +- [ ] Identify Python release scripts that can be moved to Bun without losing the + ecosystem fit or making release behavior harder to validate. +- [ ] Identify Rust xtask code that is not performance-sensitive or domain-critical and + can be moved to Bun without compiling unnecessary crates. +- [ ] Keep build/runtime-critical Rust and platform shell where they remain idiomatic. + +## Current Evidence + +- 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. +- 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`, + `check_consumer_shape.py`, and `check_artifact_targets.py`. 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 + `release.py ci-artifacts --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. +- 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..6e5a6116 100644 --- a/docs/maintainers/release-setup.md +++ b/docs/maintainers/release-setup.md @@ -64,11 +64,12 @@ 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/release/release.py check`. The publish job still needs the repository-scoped `GITHUB_TOKEN` for GitHub release asset uploads, artifact attestations, release-please release creation, @@ -107,11 +108,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 +335,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` @@ -423,7 +424,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. diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 3211c17d..f8252587 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -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 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/examples/README.md b/examples/README.md new file mode 100644 index 00000000..36dda8b2 --- /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 +python3 tools/release/local_registry_publish.py 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 +python3 tools/release/package_liboliphaunt_wasix_cargo_artifacts.py \ + --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 +python3 tools/release/local_registry_publish.py 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..35c3a854 --- /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": "catalog:", + "vite": "^6.0.3" + } +} diff --git a/examples/electron-wasix/src-wasix/Cargo.lock b/examples/electron-wasix/src-wasix/Cargo.lock new file mode 100644 index 00000000..b2440035 --- /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 = "f7c773796df578853baca2f0dcfb610dc78c103f17fbd260f053c5945a5d0ba1" +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 = "9611d8528c54f4a6981217d6acaddaba0b26cbc20841b8698cb14332fd1b8a64" +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 = "43067bd9d8aa2499d867443a39dcba33195f83c525193a730b6e9b7d66570f88" +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 = "8856bae97b2d60f323f5847db4223fe768a0ee34ebb785b795b11482bd1a9b86" +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 = "a813fb560bf766f17233f41ae60abd7463dd6a13b019792b614550c64be77e29" +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 = "3a767b3afef41b9d6692c74870df7739aeb208bf3078a92a116afb4558872b4d" +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 = "5129bc72a7419128b828189dc54a3a5a82eafc1754b08e8b0316528fcdbfea3b" +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 = "00ababb85de5d0fde8235e1f833726944cb4b1ff948de487166759e9d9784390" +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 = "f0efc748599c21e28a1900dc055847dbdb65f79948159fb1333229713a4b1bf5" +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 = "608a00fadaa05b4e1d714024d1ef77d6ce536f1f547cc1dc37ed686bdf1f2340" +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..dc687cb1 --- /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": "catalog:", + "vite": "^6.0.3" + } +} 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..d513d048 --- /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": "catalog:", + "vite": "^6.0.3" + } +} diff --git a/examples/tauri-wasix/src-tauri/Cargo.lock b/examples/tauri-wasix/src-tauri/Cargo.lock new file mode 100644 index 00000000..739ffce6 --- /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 = "f7c773796df578853baca2f0dcfb610dc78c103f17fbd260f053c5945a5d0ba1" +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 = "9611d8528c54f4a6981217d6acaddaba0b26cbc20841b8698cb14332fd1b8a64" +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 = "43067bd9d8aa2499d867443a39dcba33195f83c525193a730b6e9b7d66570f88" +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 = "8856bae97b2d60f323f5847db4223fe768a0ee34ebb785b795b11482bd1a9b86" +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 = "a813fb560bf766f17233f41ae60abd7463dd6a13b019792b614550c64be77e29" +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 = "3a767b3afef41b9d6692c74870df7739aeb208bf3078a92a116afb4558872b4d" +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 = "5129bc72a7419128b828189dc54a3a5a82eafc1754b08e8b0316528fcdbfea3b" +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 = "00ababb85de5d0fde8235e1f833726944cb4b1ff948de487166759e9d9784390" +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 = "f0efc748599c21e28a1900dc055847dbdb65f79948159fb1333229713a4b1bf5" +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 = "608a00fadaa05b4e1d714024d1ef77d6ce536f1f547cc1dc37ed686bdf1f2340" +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.4.3", + "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..b5a621be --- /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": "catalog:", + "vite": "^6.0.3" + } +} diff --git a/examples/tauri/src-tauri/Cargo.lock b/examples/tauri/src-tauri/Cargo.lock new file mode 100644 index 00000000..44eaf6e5 --- /dev/null +++ b/examples/tauri/src-tauri/Cargo.lock @@ -0,0 +1,4676 @@ +# 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 = "339fb30e364733e12d691126243e8cf6e17472cf7f0625e69ba0b2d7ed296e4e" +dependencies = [ + "liboliphaunt-native-linux-x64-gnu-part-000", + "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 = "a19bbcad796b3aaee8a3ba3c0f4f46d7a148aa5e7958ca57498a4837f0c06d4a" + +[[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 = "bf38854611fdfe97264113f7746b4a7cd61e7fb8b2346e4436e5bca1fa4ba8da" +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 = "d03f050c7e2307a0b41a082369ab69f2da478d65f5cfd26ec30bf56816333c82" +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 = "834e7c11f46fb5b5f87cefca8106ce533eba734ace5818e21d011be92fbacdaf" + +[[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..ca2920b0 --- /dev/null +++ b/examples/tools/check-examples.mjs @@ -0,0 +1,220 @@ +#!/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 --filter "\./\$app_dir" install --no-frozen-lockfile`, +); +requireText( + "examples/tools/run-electron-driver-smoke.sh", + String.raw`pnpm --filter "\./\$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/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"); +for (const example of ["tauri", "tauri-wasix", "electron", "electron-wasix"]) { + requireFile(`examples/${example}/package.json`); + 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$`); +} +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/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..a1345250 --- /dev/null +++ b/examples/tools/run-electron-driver-smoke.sh @@ -0,0 +1,73 @@ +#!/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" + examples/tools/with-local-registries.sh pnpm --dir "$app_dir" exec node - "$package_name" "$expected_version" <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); + +const [packageName, expectedVersion] = process.argv.slice(2); +const packageJson = require.resolve(`${packageName}/package.json`); +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="$root/node_modules/electron/dist/electron" +if [ ! -x "$electron" ]; then + fail "missing Electron executable at $electron; run pnpm install" +fi + +examples/tools/with-local-registries.sh pnpm --filter "./$app_dir" install --no-frozen-lockfile +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" + assert_npm_package "@oliphaunt/tools-linux-x64-gnu" "0.1.0" + assert_npm_package "@oliphaunt/extension-hstore" "0.1.0" +fi +examples/tools/with-local-registries.sh pnpm --dir "$app_dir" build + +run_smoke=( + env + "OLIPHAUNT_E2E_ELECTRON=$electron" + "OLIPHAUNT_E2E_ELECTRON_APP=$root/$app_dir" + 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..88691494 --- /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 --filter "./$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..0d557261 --- /dev/null +++ b/examples/tools/with-local-registries.sh @@ -0,0 +1,38 @@ +#!/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/release/local_registry_publish.py 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 + +exec "$@" diff --git a/moon.yml b/moon.yml index 31dd3d0d..ec34c3d3 100644 --- a/moon.yml +++ b/moon.yml @@ -239,7 +239,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/**/*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28c6bd60..dcae7227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,100 @@ importers: .: {} + examples/electron: + dependencies: + '@oliphaunt/extension-hstore': + specifier: 0.1.0 + version: 0.1.0 + '@oliphaunt/extension-pg-trgm': + specifier: 0.1.0 + version: 0.1.0 + '@oliphaunt/extension-unaccent': + specifier: 0.1.0 + version: 0.1.0 + '@oliphaunt/ts': + specifier: 0.1.0 + version: 0.1.0 + kysely: + specifier: ^0.29.2 + version: 0.29.2 + pg: + specifier: ^8.16.3 + version: 8.22.0 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.4 + '@types/pg': + specifier: ^8.15.6 + version: 8.20.0 + electron: + specifier: ^39.2.5 + version: 39.8.10 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + examples/electron-wasix: + dependencies: + kysely: + specifier: ^0.29.2 + version: 0.29.2 + pg: + specifier: ^8.16.3 + version: 8.22.0 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.4 + '@types/pg': + specifier: ^8.15.6 + version: 8.20.0 + electron: + specifier: ^39.2.5 + version: 39.8.10 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + examples/tauri: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.0 + devDependencies: + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + examples/tauri-wasix: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.0 + devDependencies: + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla: dependencies: '@tauri-apps/api': @@ -133,6 +227,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 +309,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: @@ -782,6 +896,10 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1689,6 +1807,73 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oliphaunt/extension-hstore-linux-x64-gnu@0.1.0': + resolution: {integrity: sha512-SFLBAQOITw1cq7ipyAejj7Br5V879vV6eoRsku5eq48N8FMTT5gnFVhHkIcGZ5zGXW2hDF0Se6kl3IiiX96BsQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-0@0.1.0': + resolution: {integrity: sha512-L2n/7d3Xt5PgrmFbuZKYdTiG8BbexieiOQgAEhrzEwKqTY9xj1C1rodcARn/Y5uFnZya32oZ4v8UMnt1ePJSAQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-1@0.1.0': + resolution: {integrity: sha512-kc+6WXQFgIDLNCKRnazNdviwr9M48i8duMQ+urHK2Xg0nuj8xp3R5klVS5KlB0cw82Vem+0EBg9LnNxWRD08ow==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-hstore@0.1.0': + resolution: {integrity: sha512-Rbj1wtX0XY6oXorBAWwWVx22Tm2mLEegC3JPs0fJ5XmZ7QDIa4eTDoqv3kr4wbX2li1YbcTUkl03aKevKRNdbg==} + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu@0.1.0': + resolution: {integrity: sha512-J6ZiD0aWHBmuT64R2b1zeHz30753Jxojh/yRDKzqg2Iy+9ZIY6N2Fc12pWKw9tUhJfiNTjrq5yZSROYgXoMWcA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-0@0.1.0': + resolution: {integrity: sha512-V4h14dpRbkIAS6MpoNzHaHKyVvmDO6HfAsrTLraZ5mPs1zA9xWgBpOKzlWixP5J5kcqRKP/87NTFf90Jyx2e7g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-1@0.1.0': + resolution: {integrity: sha512-fDAsqWZSKab3RaEeTpHOse2oAuNT6BXgDX24rtLOxVMJXu/32u2zjXOaHIXw4vNzf2uOzWhV8qevPyCVhAiIuQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-pg-trgm@0.1.0': + resolution: {integrity: sha512-/OcL2Rxm0jEOyQf5LCx/3NDGq8XBbKZPPwy4lslulL5w8oYcrbiWkaq7/3ydLmbA5BdfGUQnpMP1P3Jz7JoFhQ==} + + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-0@0.1.0': + resolution: {integrity: sha512-4tD8F+LrjS0XkpBc9FTLDf70U9roHypvksalDvItNXwaZeELcs+LYPMXw7TNhD60hO6XepmBOO07tP8j6JaNuQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-1@0.1.0': + resolution: {integrity: sha512-w390+r99Mo9Umxx3tx4a++glV8D+BW47Fl6Zz3yQ2Mbepc4/ZjZZKL943fHmcc7E38m8SUby2BW6SjmLy8vIeQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-unaccent-linux-x64-gnu@0.1.0': + resolution: {integrity: sha512-HJ51Z2CzHxiynqC6GD6BDWXMp4VsC2Dq2CSN13pMYRPhV1q4eqsw7pUD30nXNn+hbyH43nJ+s9HWB3XOcTJgUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oliphaunt/extension-unaccent@0.1.0': + resolution: {integrity: sha512-UtfqGnTj6HvTkXqTHFtad17mhwi7bxUFzc8bnYF+Ou5or5gwvsUpdwnFu1fKOh8iRBetWs2U3iIDhEYJ6bBVxw==} + + '@oliphaunt/ts@0.1.0': + resolution: {integrity: sha512-VrhXdLX7bmFWUt0TLUm1uj/Glc387UfsLWA1j0jjmbzoL+dv/IvumjDLnjbJLC9wbAG0p2+9YScPND62xrXqnQ==} + engines: {node: '>=22.13 <25'} + '@orama/orama@3.1.18': resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} @@ -2315,12 +2500,20 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -2508,6 +2701,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2532,6 +2728,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2547,6 +2746,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2562,6 +2764,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2576,6 +2781,9 @@ packages: '@types/react@19.2.16': resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2588,6 +2796,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.59.4': resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3017,6 +3228,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -3047,6 +3261,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3054,6 +3271,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3141,6 +3366,9 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -3294,6 +3522,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3304,6 +3536,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3331,6 +3567,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3354,6 +3593,11 @@ packages: electron-to-chromium@1.5.361: resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + electron@39.8.10: + resolution: {integrity: sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3365,6 +3609,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.22.1: resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} engines: {node: '>=10.13.0'} @@ -3377,6 +3624,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -3415,6 +3666,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -3847,6 +4101,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3873,6 +4132,9 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3956,6 +4218,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4105,6 +4371,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -4126,13 +4396,16 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -4149,6 +4422,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4248,10 +4525,17 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4535,6 +4819,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -4544,6 +4831,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsr@0.14.3: resolution: {integrity: sha512-PGxnDepx7vwJoZQe2SHbyBiFfpGwsOKmX4kn/wZZqfMafV7fjXqTxSaX6lp9QHYkSTLKkER+P/wmrZY3gVJNzg==} hasBin: true @@ -4559,6 +4849,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} + lan-network@0.2.1: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true @@ -4675,6 +4969,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4720,6 +5018,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4984,6 +5286,14 @@ packages: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -5130,6 +5440,10 @@ packages: resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} engines: {node: '>=0.12.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-package-arg@11.0.3: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5217,6 +5531,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5267,6 +5585,43 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5305,6 +5660,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5341,6 +5712,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5360,6 +5734,10 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5600,6 +5978,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5624,10 +6005,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5669,6 +6057,9 @@ packages: resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} engines: {node: '>=6'} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5690,6 +6081,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -5815,6 +6210,13 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -5922,6 +6324,10 @@ packages: styleq@0.1.3: resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -6023,6 +6429,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -6134,6 +6544,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -6181,7 +6595,6 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@5.0.1: @@ -6395,6 +6808,10 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6415,6 +6832,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6960,6 +7380,20 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -7866,6 +8300,56 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oliphaunt/extension-hstore-linux-x64-gnu@0.1.0': + optionalDependencies: + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-0': 0.1.0 + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-1': 0.1.0 + optional: true + + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-0@0.1.0': + optional: true + + '@oliphaunt/extension-hstore-linux-x64-gnu-payload-1@0.1.0': + optional: true + + '@oliphaunt/extension-hstore@0.1.0': + optionalDependencies: + '@oliphaunt/extension-hstore-linux-x64-gnu': 0.1.0 + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu@0.1.0': + optionalDependencies: + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-0': 0.1.0 + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-1': 0.1.0 + optional: true + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-0@0.1.0': + optional: true + + '@oliphaunt/extension-pg-trgm-linux-x64-gnu-payload-1@0.1.0': + optional: true + + '@oliphaunt/extension-pg-trgm@0.1.0': + optionalDependencies: + '@oliphaunt/extension-pg-trgm-linux-x64-gnu': 0.1.0 + + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-0@0.1.0': + optional: true + + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-1@0.1.0': + optional: true + + '@oliphaunt/extension-unaccent-linux-x64-gnu@0.1.0': + optionalDependencies: + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-0': 0.1.0 + '@oliphaunt/extension-unaccent-linux-x64-gnu-payload-1': 0.1.0 + optional: true + + '@oliphaunt/extension-unaccent@0.1.0': + optionalDependencies: + '@oliphaunt/extension-unaccent-linux-x64-gnu': 0.1.0 + + '@oliphaunt/ts@0.1.0': {} + '@orama/orama@3.1.18': {} '@radix-ui/number@1.1.1': {} @@ -8657,12 +9141,18 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -8801,6 +9291,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 24.12.4 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8826,6 +9323,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8840,6 +9339,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 24.12.4 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -8856,6 +9359,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.12.4 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: '@types/react': 19.2.15 @@ -8877,6 +9386,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 24.12.4 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8887,6 +9400,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.12.4 + optional: true + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9384,6 +9902,9 @@ snapshots: transitivePeerDependencies: - supports-color + boolean@3.2.0: + optional: true + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -9421,10 +9942,24 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} bytes@3.1.2: {} + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9516,6 +10051,10 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@2.1.1: {} @@ -9658,6 +10197,10 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -9666,6 +10209,8 @@ snapshots: dependencies: clone: 1.0.4 + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -9688,6 +10233,9 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -9710,12 +10258,24 @@ snapshots: electron-to-chromium@1.5.361: {} + electron@39.8.10: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.19 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + emoji-regex@8.0.0: {} encodeurl@1.0.2: {} encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 @@ -9725,6 +10285,8 @@ snapshots: entities@6.0.1: {} + env-paths@2.2.1: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -9832,6 +10394,9 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-error@4.1.1: + optional: true + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -10489,6 +11054,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -10517,6 +11092,10 @@ snapshots: transitivePeerDependencies: - encoding + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -10596,6 +11175,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fsevents@2.3.3: optional: true @@ -10735,6 +11320,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -10768,6 +11357,16 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.1 + serialize-error: 7.0.1 + optional: true + globals@14.0.0: {} globals@16.5.0: {} @@ -10779,6 +11378,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} has-bigints@1.1.0: {} @@ -10947,6 +11560,8 @@ snapshots: html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10955,6 +11570,11 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -11229,12 +11849,19 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@1.0.2: dependencies: minimist: 1.2.8 json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsr@0.14.3: dependencies: node-stream-zip: 1.15.0 @@ -11253,6 +11880,8 @@ snapshots: kleur@3.0.3: {} + kysely@0.29.2: {} + lan-network@0.2.1: {} leven@3.1.0: {} @@ -11342,6 +11971,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.5.0: {} @@ -11389,6 +12020,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -12025,6 +12661,10 @@ snapshots: mimic-fn@1.2.0: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.5: @@ -12145,6 +12785,8 @@ snapshots: node-stream-zip@1.15.0: {} + normalize-url@6.1.0: {} + npm-package-arg@11.0.3: dependencies: hosted-git-info: 7.0.2 @@ -12257,6 +12899,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-cancelable@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -12306,6 +12950,43 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -12338,6 +13019,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} pretty-format@29.7.0: @@ -12376,6 +13067,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -12395,6 +13091,8 @@ snapshots: dependencies: inherits: 2.0.4 + quick-lru@5.1.1: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -12816,6 +13514,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-alpn@1.2.1: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -12840,11 +13540,25 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 signal-exit: 3.0.7 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -12919,6 +13633,9 @@ snapshots: semiver@1.1.0: {} + semver-compare@1.0.0: + optional: true + semver@6.3.1: {} semver@7.8.1: {} @@ -12959,6 +13676,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -13127,6 +13849,11 @@ snapshots: split-on-first@1.1.0: {} + split2@4.2.0: {} + + sprintf-js@1.1.3: + optional: true + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -13240,6 +13967,12 @@ snapshots: styleq@0.1.3: {} + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -13329,6 +14062,9 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: + optional: true + type-fest@0.21.3: {} type-fest@0.7.1: {} @@ -13457,6 +14193,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} + unpipe@1.0.0: {} unrs-resolver@1.12.2: @@ -13725,6 +14463,8 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13743,6 +14483,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.2(zod@3.25.76): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a03208e..eb499fc9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,12 +3,17 @@ 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/*" - "src/sdks/react-native" - "src/sdks/react-native/examples/expo" - "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla" + - "examples/tauri" + - "examples/tauri-wasix" + - "examples/electron" + - "examples/electron-wasix" catalog: "@vitest/coverage-v8": ^4.1.8 @@ -27,6 +32,7 @@ verifyDepsBeforeRun: false allowBuilds: core-js: false + electron: true esbuild: true msgpackr-extract: true sharp: true 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..fca68186 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 = "f7c773796df578853baca2f0dcfb610dc78c103f17fbd260f053c5945a5d0ba1" +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 = "9611d8528c54f4a6981217d6acaddaba0b26cbc20841b8698cb14332fd1b8a64" +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 = "43067bd9d8aa2499d867443a39dcba33195f83c525193a730b6e9b7d66570f88" +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 = "8856bae97b2d60f323f5847db4223fe768a0ee34ebb785b795b11482bd1a9b86" +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 = "a813fb560bf766f17233f41ae60abd7463dd6a13b019792b614550c64be77e29" +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 = "3a767b3afef41b9d6692c74870df7739aeb208bf3078a92a116afb4558872b4d" +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 = "5129bc72a7419128b828189dc54a3a5a82eafc1754b08e8b0316528fcdbfea3b" +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 = "00ababb85de5d0fde8235e1f833726944cb4b1ff948de487166759e9d9784390" +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 = "f0efc748599c21e28a1900dc055847dbdb65f79948159fb1333229713a4b1bf5" +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 = "608a00fadaa05b4e1d714024d1ef77d6ce536f1f547cc1dc37ed686bdf1f2340" 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.4.3", "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..2d5c8c4c 100644 --- a/src/bindings/wasix-rust/moon.yml +++ b/src/bindings/wasix-rust/moon.yml @@ -100,6 +100,7 @@ tasks: - "/src/runtimes/liboliphaunt/wasix/crates/**/*" - "/src/bindings/wasix-rust/tools/check-package.sh" - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/package_oliphaunt_wasix_sdk_crate.mjs" outputs: - "/target/sdk-artifacts/oliphaunt-wasix-rust/**/*" options: @@ -108,7 +109,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 +121,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 +144,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/tools/extension-artifact-packager.mjs b/src/extensions/artifacts/native/tools/extension-artifact-packager.mjs index 27a02374..ad03224d 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,20 @@ async function writeArtifactDirectory(artifactRoot, args) { await fs.writeFile(path.join(artifactRoot, 'manifest.properties'), manifest); } +function stripNativeReleaseBinaries(artifactRoot) { + const result = spawnSync( + path.join(root, 'tools/dev/bun.sh'), + ['tools/release/strip_native_release_binaries.mjs', artifactRoot], + { 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 +849,7 @@ async function createArtifact(argv) { await fs.rm(output, { recursive: true, force: true }); } await writeArtifactDirectory(output, args); + stripNativeReleaseBinaries(output); console.log(`path=${output}`); console.log(`sqlName=${args.sqlName}`); console.log('format=directory'); @@ -848,6 +864,7 @@ async function createArtifact(argv) { await fs.mkdir(artifactRoot, { recursive: true }); try { await writeArtifactDirectory(artifactRoot, args); + stripNativeReleaseBinaries(artifactRoot); 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/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..c67f4ac0 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 @@ -514,7 +514,7 @@ } ], "schema": "oliphaunt-extension-evidence-v1", - "sourceDigest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d", + "sourceDigest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4", "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/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..c696c0cb 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:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -56,7 +56,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -92,7 +92,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -128,7 +128,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -164,7 +164,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -200,7 +200,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -236,7 +236,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -272,7 +272,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -308,7 +308,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -344,7 +344,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -380,7 +380,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -416,7 +416,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -452,7 +452,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -488,7 +488,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -524,7 +524,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -560,7 +560,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -596,7 +596,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -632,7 +632,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -668,7 +668,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -704,7 +704,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -740,7 +740,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -776,7 +776,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -812,7 +812,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -848,7 +848,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -884,7 +884,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -920,7 +920,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -956,7 +956,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -992,7 +992,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1028,7 +1028,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1064,7 +1064,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1100,7 +1100,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1136,7 +1136,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1172,7 +1172,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1208,7 +1208,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1244,7 +1244,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1280,7 +1280,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1316,7 +1316,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1352,7 +1352,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1388,7 +1388,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4" } ], "platform-targets": [ @@ -1420,7 +1420,7 @@ "path": "src/extensions/evidence/runs" } ], - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d", + "source-digest": "sha256:00b3f5a8122d09c441c0c8b70253f32401cb7b82b95afa5fc43eff8ab142c8d4", "source-digest-inputs": [ "src/postgres/versions/18/source.toml", "src/extensions/catalog/extensions.promoted.toml", diff --git a/src/extensions/tools/check-extension-model.py b/src/extensions/tools/check-extension-model.py index dacfa218..cf991ebc 100755 --- a/src/extensions/tools/check-extension-model.py +++ b/src/extensions/tools/check-extension-model.py @@ -970,6 +970,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"], @@ -997,6 +999,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" 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/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/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..e6d744dd 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 \ 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/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..a0578585 100644 --- a/src/runtimes/liboliphaunt/native/moon.yml +++ b/src/runtimes/liboliphaunt/native/moon.yml @@ -169,10 +169,12 @@ 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/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/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..aa809b8d 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 +++ b/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 @@ -1 +1 @@ -d208dde15f9d8aec1a34249292342a72148664fd0093b3573082950440a936d5 +9cbe9b35eaa955c2f205314933cf7c5aeaa6ce0638089378a261326e15851f22 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..a54678ef 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`. 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..63531676 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools/README.md @@ -0,0 +1,5 @@ +# 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. 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..aaedf308 100644 --- a/src/runtimes/node-direct/moon.yml +++ b/src/runtimes/node-direct/moon.yml @@ -38,8 +38,13 @@ tasks: - "liboliphaunt-native:check" inputs: - "/src/runtimes/node-direct/**/*" - - "/tools/release/artifact_targets.py" - "/tools/release/check_artifact_targets.py" + - "/tools/release/check-node-direct-release-assets.mjs" + - "/tools/release/product_metadata.py" + - "/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 +80,13 @@ 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/product_metadata.py" + - "/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..8daa3893 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 @@ -168,6 +168,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 +194,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 +225,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" ;; @@ -269,17 +265,9 @@ JS 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 -tzf "$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/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..28992e07 100644 --- a/src/sdks/js/src/generated/extensions.ts +++ b/src/sdks/js/src/generated/extensions.ts @@ -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..59c70423 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,6 +378,12 @@ 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" \ @@ -375,6 +394,40 @@ require_source_text "$root/tools/release/release.py" "node_direct_optional_npm_t "Node direct release dry-run must validate staged optional npm tarballs from builder jobs" 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..8e5ef5b4 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,6 +622,58 @@ internal object OliphauntAndroidRuntimeAssets { ) } } + requireExtensionInstallFiles(runtimePackage, requestedExtensions, runtimeFiles) + } + + 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 = validatePortableIds(values, label = "extension id") @@ -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/Oliphaunt.kt b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt index c373f278..3923003b 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,12 @@ internal fun validateStartupGucs(gucs: List) { } } -internal fun OliphauntConfig.postgresStartupArgs(): List = runtimeFootprint.postgresStartupArgs() + +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( 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..126be063 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 @@ -929,6 +929,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/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..b840bd00 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 } 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..28992e07 100644 --- a/src/sdks/react-native/src/generated/extensions.ts +++ b/src/sdks/react-native/src/generated/extensions.ts @@ -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/src/lib.rs b/src/sdks/rust/crates/oliphaunt-build/src/lib.rs index 6b91db34..be092c8f 100644 --- a/src/sdks/rust/crates/oliphaunt-build/src/lib.rs +++ b/src/sdks/rust/crates/oliphaunt-build/src/lib.rs @@ -227,6 +227,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", @@ -245,6 +253,14 @@ fn select_artifacts( "portable", "selected WASIX portable runtime", )?); + selected.push(require_artifact( + artifacts, + "oliphaunt-wasix-tools", + Some(&metadata.runtime_version), + ArtifactKind::WasixTools, + "portable", + "selected WASIX tools", + )?); selected.push(require_artifact( artifacts, "liboliphaunt-wasix", @@ -253,6 +269,14 @@ fn select_artifacts( target, "selected WASIX AOT runtime", )?); + 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!( @@ -570,6 +594,8 @@ impl ArtifactManifest { self.label() ))); } + self.validate_product_kind()?; + self.validate_payload()?; Ok(()) } @@ -579,14 +605,176 @@ 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, + &[ + "runtime/bin/postgres", + "runtime/bin/initdb", + "runtime/bin/pg_ctl", + ], + )?; + self.reject_files( + &relatives, + &[ + "runtime/bin/pg_dump", + "runtime/bin/psql", + "runtime/bin/pg_dump.exe", + "runtime/bin/psql.exe", + ], + )?; + } + ArtifactKind::NativeTools => { + self.require_files(&relatives, &["runtime/bin/pg_dump", "runtime/bin/psql"])?; + self.reject_files( + &relatives, + &[ + "runtime/bin/postgres", + "runtime/bin/initdb", + "runtime/bin/pg_ctl", + "runtime/bin/postgres.exe", + "runtime/bin/initdb.exe", + "runtime/bin/pg_ctl.exe", + ], + )?; + } + 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: &[&str]) -> Result<()> { + for relative in required { + if !relatives.contains(relative) && !windows_tool_variant_present(relatives, 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: &[&str]) -> Result<()> { + for relative in rejected { + if relatives.contains(relative) { + return Err(Error::new(format!( + "{} {} artifact must not contain payload {relative:?}", + self.label(), + self.kind.as_str() + ))); + } + } + Ok(()) + } +} + +fn windows_tool_variant_present(relatives: &BTreeSet<&str>, relative: &str) -> bool { + if !relative.starts_with("runtime/bin/") || relative.ends_with(".exe") { + return false; + } + let windows_relative = format!("{relative}.exe"); + relatives.contains(windows_relative.as_str()) } #[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 +784,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 +943,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 +967,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 +995,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 +1039,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 +1085,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 +1099,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 +1127,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 +1161,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 +1194,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 +1228,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 +1246,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 +1292,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 +1320,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 +1334,131 @@ runtime-version = "0.1.0" ); } + #[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_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 +1483,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/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/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..a2cfcee5 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 python3 tools/release/release.py prepare-rust-release-source 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/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/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..696ee212 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" ] } }, @@ -48,12 +48,12 @@ "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": [ @@ -770,8 +770,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 +783,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 +879,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..74eea3e6 100755 --- a/tools/dev/bootstrap-tools.sh +++ b/tools/dev/bootstrap-tools.sh @@ -132,13 +132,11 @@ install_cargo_binstall() { curl -L --fail --retry 3 --output "$archive" "$url" 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/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/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..c44379ef 100755 --- a/tools/policy/check-docs.sh +++ b/tools/policy/check-docs.sh @@ -134,6 +134,22 @@ 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 git grep -n \ -e 'f0rr0/oliphaunt-oxide' \ -e 'github.com/f0rr0/oliphaunt-oxide' \ diff --git a/tools/policy/check-final-source-architecture.mjs b/tools/policy/check-final-source-architecture.mjs new file mode 100755 index 00000000..47ca6513 --- /dev/null +++ b/tools/policy/check-final-source-architecture.mjs @@ -0,0 +1,750 @@ +#!/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/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) || 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..c07585af 100755 --- a/tools/policy/check-moon-product-graph.mjs +++ b/tools/policy/check-moon-product-graph.mjs @@ -774,17 +774,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'); @@ -1163,6 +1164,10 @@ assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'tools/rel 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..455af426 100755 --- a/tools/policy/check-policy-tools.sh +++ b/tools/policy/check-policy-tools.sh @@ -30,11 +30,11 @@ 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' | LC_ALL=C sort) python_files=() while IFS= read -r script; do diff --git a/tools/policy/check-python-entrypoints.mjs b/tools/policy/check-python-entrypoints.mjs new file mode 100644 index 00000000..b493b9c6 --- /dev/null +++ b/tools/policy/check-python-entrypoints.mjs @@ -0,0 +1,152 @@ +#!/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-local-registry-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 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 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 entrypoint 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 entrypoint inventory verified (${trackedPython.length} tracked files).`); +} diff --git a/tools/policy/check-release-policy.py b/tools/policy/check-release-policy.py index 094dbef0..a569aa79 100644 --- a/tools/policy/check-release-policy.py +++ b/tools/policy/check-release-policy.py @@ -12,12 +12,8 @@ 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 = { @@ -39,6 +35,182 @@ def fail(message: str) -> None: raise SystemExit(message) +def bun_json(args: list[str]) -> object: + try: + output = subprocess.check_output( + ["tools/dev/bun.sh", *args], + cwd=ROOT, + stderr=subprocess.STDOUT, + text=True, + ) + except subprocess.CalledProcessError as error: + raise RuntimeError(error.output.strip()) from error + return json.loads(output) + + +def string_set(value: object, label: str) -> set[str]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"{label} must be a JSON string list") + return set(value) + + +def optional_string_set(value: object, label: str) -> set[str] | None: + if value is None: + return None + return string_set(value, label) + + +def json_flag(value: set[str] | None) -> str: + if value is None: + return "null" + return json.dumps(sorted(value), separators=(",", ":")) + + +class CiPlanClient: + def __init__(self) -> None: + config = bun_json(["tools/graph/ci_plan.mjs", "config"]) + if not isinstance(config, dict): + fail("CI planner config query must return an object") + self.BASE_JOBS = string_set(config.get("baseJobs"), "baseJobs") + self.BUILDER_JOBS = string_set(config.get("builderJobs"), "builderJobs") + targets = config.get("ciJobTargets") + if not isinstance(targets, dict): + fail("ciJobTargets must be an object") + self.CI_JOB_TARGETS = { + str(job): sorted(string_set(job_targets, f"ciJobTargets.{job}")) + for job, job_targets in targets.items() + } + + def query(self, *args: str) -> object: + return bun_json(["tools/graph/ci_plan.mjs", *args]) + + def plan_jobs_for_affected(self, direct_projects: set[str], tasks: set[str]) -> set[str]: + return string_set( + self.query( + "jobs-for-affected", + "--direct-projects-json", + json_flag(direct_projects), + "--tasks-json", + json_flag(tasks), + ), + "jobs-for-affected", + ) + + def native_target_subset_for_jobs(self, jobs: set[str], tasks: set[str]) -> set[str] | None: + return optional_string_set( + self.query( + "native-target-subset", + "--jobs-json", + json_flag(jobs), + "--tasks-json", + json_flag(tasks), + ), + "native-target-subset", + ) + + def selected_extension_products_for_plan( + self, + direct_projects: set[str], + tasks: set[str], + jobs: set[str], + ) -> set[str] | None: + return optional_string_set( + self.query( + "selected-extension-products", + "--direct-projects-json", + json_flag(direct_projects), + "--tasks-json", + json_flag(tasks), + "--jobs-json", + json_flag(jobs), + ), + "selected-extension-products", + ) + + def plan_for_full_run( + self, + *, + wasm_target: str = "all", + native_target: str = "all", + mobile_target: str = "all", + ) -> tuple[set[str], set[str], set[str], str, set[str] | None]: + value = self.query( + "plan-full", + "--wasm-target", + wasm_target, + "--native-target", + native_target, + "--mobile-target", + mobile_target, + ) + if not isinstance(value, dict): + fail("plan-full must return an object") + reason = value.get("reason") + if not isinstance(reason, str): + fail("plan-full reason must be a string") + return ( + string_set(value.get("jobs"), "plan-full.jobs"), + string_set(value.get("projects"), "plan-full.projects"), + string_set(value.get("tasks"), "plan-full.tasks"), + reason, + optional_string_set(value.get("selectedTargets"), "plan-full.selectedTargets"), + ) + + def mobile_extension_package_native_targets( + self, + jobs: set[str], + selected_targets: set[str] | None, + ) -> list[str]: + value = self.query( + "mobile-extension-package-native-targets", + "--jobs-json", + json_flag(jobs), + "--selected-targets-json", + json_flag(selected_targets), + ) + return sorted(string_set(value, "mobile-extension-package-native-targets")) + + def extension_artifacts_native_matrix( + self, + native_target: str, + selected_targets: set[str] | None, + selected_products: set[str] | None = None, + ) -> dict: + value = self.query( + "matrix", + "extension-artifacts-native", + "--native-target", + native_target, + "--selected-targets-json", + json_flag(selected_targets), + "--selected-products-json", + json_flag(selected_products), + ) + if not isinstance(value, dict): + fail("extension-artifacts-native matrix must be an object") + return value + + def extension_artifacts_wasix_matrix( + self, + wasm_target: str, + selected_products: set[str] | None = None, + ) -> dict: + value = self.query( + "matrix", + "extension-artifacts-wasix", + "--wasm-target", + wasm_target, + "--selected-products-json", + json_flag(selected_products), + ) + if not isinstance(value, dict): + fail("extension-artifacts-wasix matrix must be an object") + return value + + +ci_plan = CiPlanClient() + + def read_text(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") @@ -69,6 +241,38 @@ def read_toml(path: pathlib.Path) -> dict: return tomllib.load(handle) +def release_graph() -> dict: + value = bun_json(["tools/release/release_graph_query.mjs", "graph"]) + if not isinstance(value, dict): + fail("release graph query did not return an object") + return value + + +def release_product_projects() -> dict[str, str]: + value = bun_json(["tools/release/release_graph_query.mjs", "product-projects"]) + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in value.items() + ): + fail("release graph product-project query did not return a string map") + return value + + +def release_plans_for_single_paths(paths: list[str]) -> dict[str, dict]: + value = bun_json( + [ + "tools/release/release_graph_query.mjs", + "plans-for-paths", + "--paths-json", + json.dumps(paths, separators=(",", ":")), + ] + ) + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, dict) for key, item in value.items() + ): + fail("release graph plans-for-paths query did not return a plan map") + return value + + def extension_product_id(sql_name: str) -> str: return "oliphaunt-extension-" + sql_name.replace("_", "-").lower() @@ -260,6 +464,7 @@ def check_release_metadata(graph: dict) -> None: ) projects = moon_projects() + product_projects = release_product_projects() for product, config in products.items(): release_path = ROOT / config["path"] / "release.toml" raw = read_toml(release_path) @@ -272,7 +477,7 @@ def check_release_metadata(graph: dict) -> None: 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_id = product_projects[product] project = projects.get(project_id) if project is None: fail(f"{product} has no owning Moon project") @@ -334,20 +539,21 @@ def check_release_planning(graph: dict) -> None: } | 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(), } + plans = release_plans_for_single_paths(sorted({*contains_cases, *exact_cases})) + for path, expected in contains_cases.items(): + plan = plans[path] + actual = set(plan.get("releaseProducts", [])) + if not expected <= actual: + fail(f"{path} release plan expected at least {sorted(expected)}, got {sorted(actual)}") + for path, expected in exact_cases.items(): - plan = release_plan.build_plan(graph, [path]) + plan = plans[path] actual = set(plan.get("releaseProducts", [])) if actual != expected: fail(f"{path} release plan expected exactly {sorted(expected)}, got {sorted(actual)}") @@ -360,10 +566,10 @@ def check_ci_policy() -> None: 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.mjs", "moon([\"query\", \"tasks\"])", "CI planner must read Moon task tags") + assert_contains("tools/graph/ci_plan.mjs", "ci-", "CI planner must document ci-* task tags") assert_contains( - "tools/graph/ci_plan.py", + "tools/graph/ci_plan.mjs", "extension_package_products_csv", "CI planner must emit selected exact-extension products for artifact package builders", ) @@ -373,7 +579,7 @@ def check_ci_policy() -> None: "CI extension package builders must consume selected exact-extension products from the affected plan", ) assert_contains( - "tools/release/build-extension-ci-artifacts.py", + "tools/release/build-extension-ci-artifacts.mjs", "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", "exact-extension package builder must support selected product subsets", ) @@ -563,8 +769,8 @@ def check_ci_policy() -> None: "React Native Android mobile build reports must include static-extension link evidence", ), ( - "tools/release/check_staged_artifacts.py", - "check_android_prebuilt_extension_linkage", + "tools/release/check-staged-artifacts.mjs", + "checkAndroidPrebuiltExtensionLinkage", "staged mobile artifact checks must validate Android static-extension link evidence", ), ): @@ -616,7 +822,7 @@ def check_ci_policy() -> None: 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") + release_tool_patterns = ("tools/release/release.py", "tools/release/artifact_target_matrix.mjs") missing_moon_setup = sorted( job for job, block in release_workflow_blocks.items() @@ -630,13 +836,13 @@ def check_ci_policy() -> None: fail(f"missing consumer shape fixture: {CONSUMER_SHAPE_PRODUCTS_FIXTURE}") assert_contains( "tools/release/release.py", - "check_release_pr_coverage.py", + "check_release_pr_coverage.mjs", "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", + "tools/release/upload_github_release_assets.mjs", ): assert_not_contains( path, @@ -649,12 +855,12 @@ def check_ci_policy() -> None: "GitHub release asset replacement must stay a manual repair, not a release CLI switch", ) assert_not_contains( - "tools/release/upload_github_release_assets.py", + "tools/release/upload_github_release_assets.mjs", "--clobber", "GitHub release asset upload must not overwrite existing assets", ) assert_contains( - "tools/release/upload_github_release_assets.py", + "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", ) @@ -722,13 +928,11 @@ def check_release_workflow_policy() -> None: "--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", + "PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }}", + "tools/release/release.py ci-products --family sdk-package --products-json \"$PRODUCTS_JSON\"", + "tools/release/release.py ci-artifacts --product \"$product\" --family sdk-package", + "tools/release/release.py ci-artifacts --product \"$product\" --kind \"$kind\" --family release-assets", + "tools/release/release.py ci-artifacts --product oliphaunt-node-direct --kind node-direct-addon --family npm-package", "pnpm install --frozen-lockfile", "target/oliphaunt-broker/release-assets", "target/oliphaunt-node-direct/release-assets", @@ -737,6 +941,16 @@ def check_release_workflow_policy() -> None: ): if snippet not in publish_block: fail(f"Release workflow dry-run handoff is missing {snippet!r}") + for legacy_env in ( + "PRODUCT_OLIPHAUNT_RUST", + "PRODUCT_OLIPHAUNT_SWIFT", + "PRODUCT_OLIPHAUNT_KOTLIN", + "PRODUCT_OLIPHAUNT_REACT_NATIVE", + "PRODUCT_OLIPHAUNT_JS", + "PRODUCT_OLIPHAUNT_WASIX_RUST", + ): + if legacy_env in publish_block: + fail(f"Release workflow must not hard-code SDK product selection with {legacy_env}") if "target/release-assets/native" in publish_block: fail("Release workflow must download native helper artifacts into product-owned release asset roots") @@ -752,9 +966,11 @@ def check_release_workflow_policy() -> None: # 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"): + for required in ("CI", '"$RELEASE_HEAD_SHA"', "--run-id", "--job Builds"): if required not in call_text: fail(f"Release artifact download must require {required}: {call_text[:240]}") + if "--artifact" not in call_text and "artifact_args" not in call_text: + fail(f"Release artifact download must require explicit artifact arguments: {call_text[:240]}") build_artifact_script = read_text(".github/scripts/download-build-artifacts.sh") for snippet in ( @@ -809,7 +1025,7 @@ def check_release_workflow_policy() -> None: ) for snippet in ( "validate_wasix_release_assets", - "artifact_targets.expected_assets(product, version, surface=\"github-release\")", + "product_metadata.expected_assets(product, version, surface=\"github-release\")", "parse_local_checksum_manifest", "target/oliphaunt-wasix/release-assets", "validate_wasix_release_asset_contents", @@ -833,6 +1049,30 @@ def check_release_workflow_policy() -> None: if snippet not in release_script: fail(f"release dry-runs and package publishes must cover registry-native checks: missing {snippet!r}") + crate_package_script = read_text("tools/policy/check-crate-package.sh") + crate_package_helper = read_text("tools/policy/list-publishable-cargo-packages.mjs") + for snippet in ( + "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 snippet not in crate_package_script: + fail( + "crate package policy must package oliphaunt-wasix through the " + f"release-shaped local helper instead of crates.io resolution: missing {snippet!r}" + ) + for snippet in ( + "'cargo', ['metadata', '--no-deps', '--format-version', '1']", + "Array.isArray(cargoPackage.publish) && cargoPackage.publish.length === 0", + "cargoPackage.name === 'oliphaunt-wasix'", + ): + if snippet not in crate_package_helper: + fail( + "crate package policy must derive default publishable crates from cargo metadata " + f"with oliphaunt-wasix handled by the release-shaped helper: missing {snippet!r}" + ) + release_head_script = read_text(".github/scripts/resolve-release-head.sh") for snippet in ( "INPUT_RELEASE_COMMIT", @@ -928,7 +1168,7 @@ def check_release_workflow_policy() -> None: ) assert_contains( "tools/release/release.py", - "tools/release/verify_github_release_attestations.py", + "tools/release/verify_github_release_attestations.mjs", "release.py verify-release must verify GitHub artifact attestations", ) for snippet in ( @@ -939,7 +1179,7 @@ def check_release_workflow_policy() -> None: "--deny-self-hosted-runners", ): assert_contains( - "tools/release/verify_github_release_attestations.py", + "tools/release/verify_github_release_attestations.mjs", snippet, "Release attestation verification must pin signer workflow, source ref, and runner trust", ) @@ -1248,7 +1488,7 @@ def check_ci_builder_planning() -> None: full_targets = extension_native_targets(extension_jobs, extension_tasks) expected_full_targets = { target.target - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="liboliphaunt-native", kind="native-runtime", published_only=True, @@ -1283,7 +1523,7 @@ def check_ci_builder_planning() -> None: def main() -> int: - graph = release_plan.load_graph() + graph = release_graph() policy = graph.get("policy") if not isinstance(policy, dict): fail("release metadata must define policy") diff --git a/tools/policy/check-repo-structure.sh b/tools/policy/check-repo-structure.sh index 8a33d722..93444429 100755 --- a/tools/policy/check-repo-structure.sh +++ b/tools/policy/check-repo-structure.sh @@ -208,26 +208,29 @@ require_file tools/release/release.py 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 +239,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 +374,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 +417,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 +514,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 +540,32 @@ 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.sh 'bun .github/scripts/merge-checksum-manifest.mjs "$existing" "$incoming"' 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 +594,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 +628,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..81006cc2 --- /dev/null +++ b/tools/policy/check-rust-helper-crates.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { readFileSync } 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]"); +} + +let list = false; +for (const arg of args) { + if (arg === "--list") { + list = 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); +} + +if (list) { + console.log(`Rust helper crate inventory verified (${trackedRustHelpers.length} tracked crates):`); + for (const path of trackedRustHelpers) { + const entry = allowlistedEntries.find((candidate) => candidate.path === path); + if (entry === undefined) { + fail(`internal error: ${path} missing from parsed allowlist`); + } + console.log(` ${path} 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-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..0c641b7b 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" \ @@ -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..0b681732 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" \ @@ -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,16 @@ 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/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 }' \ @@ -1195,6 +1287,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 +1307,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-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..9b07ce4a 100755 --- a/tools/policy/check-tooling-stack.sh +++ b/tools/policy/check-tooling-stack.sh @@ -37,8 +37,26 @@ 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/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/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/dev/bun.sh require_file tools/dev/deno.sh require_file tools/dev/install-actionlint.sh @@ -162,6 +180,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 +199,24 @@ 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" 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 +227,142 @@ 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-sdk-manifest.mjs +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 broker_cargo_caller in \ + tools/release/release.py \ + tools/release/local_registry_publish.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 'python3 tools/release/release.py prepare-rust-release-source' src/sdks/rust/tools/check-sdk.sh || + fail "Rust SDK check must prepare generated publish source through the release CLI" +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 +398,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" @@ -275,6 +428,23 @@ grep -Fq 'missing package-shape output' tools/release/build-sdk-ci-artifacts.sh if grep -Fq 'OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check"' tools/release/build-sdk-ci-artifacts.sh; then fail "SDK artifact builder must not rerun package-shape inside the artifact staging script" fi +grep -Fq 'bun tools/release/cargo-crate-filename.mjs "$manifest"' tools/release/build-sdk-ci-artifacts.sh || + fail "SDK artifact builder must use the Bun helper for Cargo crate filenames" +if grep -Fq 'python3 - "$manifest"' tools/release/build-sdk-ci-artifacts.sh; 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.sh; 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 || @@ -333,6 +503,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 || 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..230f6b93 --- /dev/null +++ b/tools/policy/check-wasix-release-dependency-invariants.mjs @@ -0,0 +1,139 @@ +#!/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 INTERNAL_ASSETS_MANIFEST = + 'src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml'; +const INTERNAL_TOOLS_MANIFEST = + 'src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml'; +const INTERNAL_AOT_MANIFESTS_DIR = 'src/runtimes/liboliphaunt/wasix/crates/aot'; +const INTERNAL_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 internalManifestPaths = [INTERNAL_ASSETS_MANIFEST, INTERNAL_TOOLS_MANIFEST]; +for (const manifestsDir of [INTERNAL_AOT_MANIFESTS_DIR, INTERNAL_TOOLS_AOT_MANIFESTS_DIR]) { + for (const entry of (await readdir(manifestsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort()) { + internalManifestPaths.push(join(manifestsDir, entry, 'Cargo.toml')); + } +} + +for (const manifestPath of internalManifestPaths) { + 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`); + } + 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/list-helper-reference-candidates.mjs b/tools/policy/list-helper-reference-candidates.mjs new file mode 100644 index 00000000..a4844bae --- /dev/null +++ b/tools/policy/list-helper-reference-candidates.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { basename } from "node:path"; + +const args = process.argv.slice(2); + +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] [--json] + +Lists tracked shell, Python, and JavaScript helper entrypoints with few textual +references. The output is advisory: each candidate still needs manual review +before removal because some entrypoints are intentionally invoked by humans or +external tools.`); +} + +let maxRefs = 1; +let json = 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 === "--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) => !path.includes("/node_modules/")) + .filter((path) => !path.startsWith("target/")) + .sort(); +} + +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 externalReferenceCount(path, pattern) { + return grepFixed(pattern).filter((line) => !line.startsWith(`${path}:`)).length; +} + +const candidates = trackedHelpers() + .map((path) => { + const pathReferences = externalReferenceCount(path, path); + const basenameReferences = externalReferenceCount(path, basename(path)); + return { + path, + basename: basename(path), + pathReferences, + basenameReferences, + }; + }) + .filter((candidate) => candidate.pathReferences <= maxRefs && candidate.basenameReferences <= maxRefs) + .sort((left, right) => { + const byPathReferences = left.pathReferences - right.pathReferences; + if (byPathReferences !== 0) { + return byPathReferences; + } + const byBasenameReferences = left.basenameReferences - right.basenameReferences; + if (byBasenameReferences !== 0) { + return byBasenameReferences; + } + return left.path.localeCompare(right.path); + }); + +if (json) { + console.log(JSON.stringify({ maxRefs, candidates }, null, 2)); +} else { + console.log(`Low-reference helper candidates (maxRefs=${maxRefs}):`); + if (candidates.length === 0) { + console.log(" none"); + } + for (const candidate of candidates) { + console.log( + ` ${candidate.path} pathRefs=${candidate.pathReferences} basenameRefs=${candidate.basenameReferences}`, + ); + } +} 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..e54f4cf6 --- /dev/null +++ b/tools/policy/list-source-reference-candidates.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +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(); +} + +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(); +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..a9c168d0 --- /dev/null +++ b/tools/policy/python-entrypoints.allowlist @@ -0,0 +1,12 @@ +# 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/policy/check-release-policy.py release-policy defer-release-graph-port guards CI and release policy against product metadata and the Bun CI planner during release-graph migration +tools/release/check_artifact_targets.py release-metadata defer-release-graph-port validates release target coverage across workflow producers, product metadata, and package artifact handlers +tools/release/check_consumer_shape.py release-consumer-shape defer-release-graph-port validates cross-SDK package/runtime/install shape from generated release fixtures and source invariants +tools/release/check_release_metadata.py release-metadata defer-release-graph-port validates release metadata and publish-step wiring against the Python release graph while it remains canonical +tools/release/local_registry_publish.py local-registry defer-local-registry-port publishes local Cargo, npm, Maven, and Swift registries from current release artifacts for e2e example validation +tools/release/package_liboliphaunt_wasix_cargo_artifacts.py wasix-cargo-artifacts defer-wasix-packager-port generates split WASIX runtime, tools, ICU, and extension Cargo artifact crates with size-limit enforcement +tools/release/product_metadata.py release-metadata defer-release-graph-port owns the canonical product metadata API consumed by Python release tools and shell callers +tools/release/release.py release-orchestrator defer-release-graph-port owns protected release planning, validation, registry checks, publish dry-runs, and publish dispatch 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..89fa0c25 --- /dev/null +++ b/tools/release/build-extension-ci-artifacts.mjs @@ -0,0 +1,798 @@ +#!/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, + exactExtensionProducts, + extensionArtifactTargets, +} from "./release-artifact-targets.mjs"; +import { loadGraph } from "./release-graph.mjs"; + +const PREFIX = "build-extension-ci-artifacts.mjs"; +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"; + +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 graphProducts() { + return loadGraph(PREFIX).products; +} + +function productConfig(product) { + const config = graphProducts()[product]; + if (!config) { + fail(`unknown release product ${product}`); + } + return config; +} + +function packagePath(product) { + return releaseMetadataRelativePath( + nonEmptyString(productConfig(product).path, `${product}.path`), + `${product}.path`, + ); +} + +function extensionProducts() { + return exactExtensionProducts(PREFIX); +} + +function extensionSqlName(product) { + const value = productConfig(product).extension_sql_name; + if (typeof value !== "string" || !value) { + fail(`${product} release metadata must declare extension_sql_name`); + } + return value; +} + +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"); + 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"); + 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(", ")}`); + } + } +} + +function extensionMetadata(product) { + const config = productConfig(product); + if (config.kind !== "exact-extension-artifact") { + fail(`${product} is not an exact-extension artifact product`); + } + const topLevelSqlName = config.extension_sql_name; + if (typeof topLevelSqlName !== "string" || !topLevelSqlName) { + fail(`${product} release metadata must declare extension_sql_name`); + } + const metadata = config.extension; + if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") { + fail(`${product} release metadata must declare [extension]`); + } + const sqlName = nonEmptyString(metadata.sql_name, `${product}.extension.sql_name`); + if (sqlName !== topLevelSqlName) { + fail(`${product}.extension.sql_name ${JSON.stringify(sqlName)} must match extension_sql_name ${JSON.stringify(topLevelSqlName)}`); + } + const extensionClass = nonEmptyString(metadata.class, `${product}.extension.class`); + if (!(extensionClass in EXTENSION_VERSIONING_BY_CLASS)) { + fail(`${product}.extension.class must be one of ${Object.keys(EXTENSION_VERSIONING_BY_CLASS).sort(compareText).join(", ")}`); + } + const versioning = nonEmptyString(metadata.versioning, `${product}.extension.versioning`); + const expectedVersioning = EXTENSION_VERSIONING_BY_CLASS[extensionClass]; + if (versioning !== expectedVersioning) { + fail(`${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(`${product}.extension must declare [extension.source]`); + } + const sourcePath = releaseMetadataRelativePath(nonEmptyString(source.path, `${product}.extension.source.path`), `${product}.extension.source.path`); + const packageRoot = packagePath(product); + if (extensionClass === "contrib" && sourcePath !== POSTGRES18_SOURCE_PATH) { + fail(`${product}.extension.source.path must be ${JSON.stringify(POSTGRES18_SOURCE_PATH)} for contrib extensions`); + } + if (extensionClass === "external" && sourcePath !== `${packageRoot}/source.toml`) { + fail(`${product}.extension.source.path must be ${packageRoot}/source.toml for external extensions`); + } + if (extensionClass === "first-party" && !(sourcePath === packageRoot || sourcePath.startsWith(`${packageRoot}/`))) { + fail(`${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(`${product}.extension must declare [extension.compatibility]`); + } + const postgresMajor = nonEmptyString(compatibility.postgres_major, `${product}.extension.compatibility.postgres_major`); + if (postgresMajor !== "18") { + fail(`${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`), + `${product}.extension.compatibility.extension_runtime_contract`, + ); + if (contractPath !== EXTENSION_RUNTIME_CONTRACT_PATH) { + fail(`${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`); + const wasixProduct = nonEmptyString(compatibility.wasix_runtime_product, `${product}.extension.compatibility.wasix_runtime_product`); + if (nativeProduct !== "liboliphaunt-native") { + fail(`${product}.extension.compatibility.native_runtime_product must be 'liboliphaunt-native'`); + } + if (wasixProduct !== "liboliphaunt-wasix") { + fail(`${product}.extension.compatibility.wasix_runtime_product must be 'liboliphaunt-wasix'`); + } + const nativeVersion = nonEmptyString(compatibility.native_runtime_version, `${product}.extension.compatibility.native_runtime_version`); + const wasixVersion = nonEmptyString(compatibility.wasix_runtime_version, `${product}.extension.compatibility.wasix_runtime_version`); + const expectedNativeVersion = currentProductVersionSync(nativeProduct); + const expectedWasixVersion = currentProductVersionSync(wasixProduct); + if (nativeVersion !== expectedNativeVersion) { + fail(`${product}.extension.compatibility.native_runtime_version must be ${JSON.stringify(expectedNativeVersion)}, got ${JSON.stringify(nativeVersion)}`); + } + if (wasixVersion !== expectedWasixVersion) { + fail(`${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, + }, + }; +} + +function extensionSourceIdentity(product) { + const metadata = extensionMetadata(product); + 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(`${metadata.sourcePath} must declare [postgresql] for contrib extension products`); + } + return { + kind: "postgres-contrib", + name: "postgresql", + version: nonEmptyString(postgresql.version, `${metadata.sourcePath}.postgresql.version`), + url: nonEmptyString(postgresql.url, `${metadata.sourcePath}.postgresql.url`), + sha256: nonEmptyString(postgresql.sha256, `${metadata.sourcePath}.postgresql.sha256`), + }; + } + if (metadata.class === "external") { + return { + kind: "external", + name: nonEmptyString(source.name, `${metadata.sourcePath}.name`), + url: nonEmptyString(source.url, `${metadata.sourcePath}.url`), + branch: nonEmptyString(source.branch, `${metadata.sourcePath}.branch`), + commit: nonEmptyString(source.commit, `${metadata.sourcePath}.commit`), + }; + } + if (metadata.class === "first-party") { + return { + kind: "repo", + name: metadata.sqlName, + path: metadata.sourcePath, + version: currentProductVersionSync(product), + }; + } + fail(`${product}.extension.class has unsupported source identity class ${JSON.stringify(metadata.class)}`); +} + +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); + 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); + const releaseData = { + schema: "oliphaunt-extension-release-manifest-v1", + product, + version, + sqlName, + extensionClass: releaseMetadata.class, + versioning: releaseMetadata.versioning, + sourceIdentity: extensionSourceIdentity(product), + 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; +} + +const versionCache = new Map(); + +function currentProductVersionSync(product) { + if (!versionCache.has(product)) { + const versionFile = productConfig(product).version_files?.[0]; + if (typeof versionFile !== "string" || !versionFile) { + fail(`${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") { + 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; + } + const match = inPackage ? /^version\s*=\s*"([^"]+)"/u.exec(line) : null; + if (match) { + version = match[1]; + break; + } + } + } 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 [key, ...rest] = line.split("="); + if (key.trim() === "VERSION_NAME") { + version = rest.join("=").trim(); + break; + } + } + } else if (name === "VERSION" || name === "LIBOLIPHAUNT_VERSION") { + version = text.trim(); + } else { + fail(`${product}.version_files has unsupported version file type: ${versionFile}`); + } + if (!version) { + fail(`${versionFile} does not define a release version for ${product}`); + } + versionCache.set(product, version); + } + return versionCache.get(product); +} + +function nonEmptyString(value, context) { + if (typeof value === "string" && value.length > 0) { + return value; + } + fail(`${context} must be a non-empty string`); +} + +function releaseMetadataRelativePath(value, context) { + const candidate = path.normalize(value).split(path.sep).join("/"); + if (path.isAbsolute(value) || candidate.split("/").includes("..")) { + fail(`${context} must be a repository-relative path: ${JSON.stringify(value)}`); + } + if (!existsSync(path.join(ROOT, candidate))) { + fail(`${context} path does not exist: ${candidate}`); + } + return candidate; +} + +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.sh b/tools/release/build-sdk-ci-artifacts.sh index 1924bad0..91c1b54d 100755 --- a/tools/release/build-sdk-ci-artifacts.sh +++ b/tools/release/build-sdk-ci-artifacts.sh @@ -28,15 +28,7 @@ require_dir() { 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 + bun tools/release/cargo-crate-filename.mjs "$manifest" } cargo_package_dir() { @@ -47,26 +39,6 @@ cargo_package_dir() { 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" @@ -123,7 +95,7 @@ mkdir -p "$artifact_root" "$work_root" case "$product" in oliphaunt-rust) require cargo - require python3 + require bun package_listing="$root/target/liboliphaunt-sdk-check/rust-cargo-package-list.txt" require_file "$package_listing" for package in oliphaunt oliphaunt-build; do @@ -148,12 +120,13 @@ case "$product" in ;; oliphaunt-swift) require swift + require bun 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 \ + tools/dev/bun.sh tools/release/render_swiftpm_release_package.mjs \ --asset-dir "$OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR" \ --output "$artifact_root/Package.swift.release" \ --generated-tree "$work_root/swiftpm-release-tree" @@ -204,9 +177,10 @@ case "$product" in ;; oliphaunt-wasix-rust) require cargo - require python3 + require bun package_listing="$root/target/oliphaunt-wasix-rust/package/oliphaunt-wasix.package-files.txt" require_file "$package_listing" + bun tools/release/package_oliphaunt_wasix_sdk_crate.mjs --output-dir "$artifact_root" cp "$package_listing" "$artifact_root/cargo-package-files.txt" ;; *) @@ -216,5 +190,5 @@ 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" +tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --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/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-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-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-staged-artifacts.mjs b/tools/release/check-staged-artifacts.mjs new file mode 100644 index 00000000..0e382d37 --- /dev/null +++ b/tools/release/check-staged-artifacts.mjs @@ -0,0 +1,1659 @@ +#!/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, +} 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"]); +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"; + +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); +} + +const versionCache = new Map(); + +function currentProductVersionSync(product) { + if (!versionCache.has(product)) { + const versionFile = productConfig(product).version_files?.[0]; + if (typeof versionFile !== "string" || !versionFile) { + fail(`${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") { + 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; + } + const match = inPackage ? /^version\s*=\s*"([^"]+)"/u.exec(line) : null; + if (match) { + version = match[1]; + break; + } + } + } 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 [key, ...rest] = line.split("="); + if (key.trim() === "VERSION_NAME") { + version = rest.join("=").trim(); + break; + } + } + } else if (name === "VERSION" || name === "LIBOLIPHAUNT_VERSION") { + version = text.trim(); + } else { + fail(`${product}.version_files has unsupported version file type: ${versionFile}`); + } + if (!version) { + fail(`${versionFile} does not define a release version for ${product}`); + } + versionCache.set(product, version); + } + return versionCache.get(product); +} + +function nonEmptyString(value, context) { + if (typeof value === "string" && value.length > 0) { + return value; + } + fail(`${context} must be a non-empty string`); +} + +function releaseMetadataRelativePath(value, context) { + const candidate = path.normalize(value).split(path.sep).join("/"); + if (path.isAbsolute(value) || candidate.split("/").includes("..")) { + fail(`${context} must be a repository-relative path: ${JSON.stringify(value)}`); + } + if (!existsSync(path.join(ROOT, candidate))) { + fail(`${context} path does not exist: ${candidate}`); + } + return candidate; +} + +function packagePath(product) { + return releaseMetadataRelativePath(nonEmptyString(productConfig(product).path, `${product}.path`), `${product}.path`); +} + +function extensionMetadata(product) { + const config = productConfig(product); + if (config.kind !== "exact-extension-artifact") { + fail(`${product} is not an exact-extension artifact product`); + } + const topLevelSqlName = nonEmptyString(config.extension_sql_name, `${product}.extension_sql_name`); + const metadata = config.extension; + if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") { + fail(`${product} release metadata must declare [extension]`); + } + const sqlName = nonEmptyString(metadata.sql_name, `${product}.extension.sql_name`); + if (sqlName !== topLevelSqlName) { + fail(`${product}.extension.sql_name ${JSON.stringify(sqlName)} must match extension_sql_name ${JSON.stringify(topLevelSqlName)}`); + } + const extensionClass = nonEmptyString(metadata.class, `${product}.extension.class`); + if (!(extensionClass in EXTENSION_VERSIONING_BY_CLASS)) { + fail(`${product}.extension.class must be one of ${Object.keys(EXTENSION_VERSIONING_BY_CLASS).sort(compareText).join(", ")}`); + } + const versioning = nonEmptyString(metadata.versioning, `${product}.extension.versioning`); + const expectedVersioning = EXTENSION_VERSIONING_BY_CLASS[extensionClass]; + if (versioning !== expectedVersioning) { + fail(`${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(`${product}.extension must declare [extension.source]`); + } + const sourcePath = releaseMetadataRelativePath(nonEmptyString(source.path, `${product}.extension.source.path`), `${product}.extension.source.path`); + const packageRoot = packagePath(product); + if (extensionClass === "contrib" && sourcePath !== POSTGRES18_SOURCE_PATH) { + fail(`${product}.extension.source.path must be ${JSON.stringify(POSTGRES18_SOURCE_PATH)} for contrib extensions`); + } + if (extensionClass === "external" && sourcePath !== `${packageRoot}/source.toml`) { + fail(`${product}.extension.source.path must be ${packageRoot}/source.toml for external extensions`); + } + if (extensionClass === "first-party" && !(sourcePath === packageRoot || sourcePath.startsWith(`${packageRoot}/`))) { + fail(`${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(`${product}.extension must declare [extension.compatibility]`); + } + const postgresMajor = nonEmptyString(compatibility.postgres_major, `${product}.extension.compatibility.postgres_major`); + if (postgresMajor !== "18") { + fail(`${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`), + `${product}.extension.compatibility.extension_runtime_contract`, + ); + if (contractPath !== EXTENSION_RUNTIME_CONTRACT_PATH) { + fail(`${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`); + const wasixProduct = nonEmptyString(compatibility.wasix_runtime_product, `${product}.extension.compatibility.wasix_runtime_product`); + if (nativeProduct !== "liboliphaunt-native") { + fail(`${product}.extension.compatibility.native_runtime_product must be 'liboliphaunt-native'`); + } + if (wasixProduct !== "liboliphaunt-wasix") { + fail(`${product}.extension.compatibility.wasix_runtime_product must be 'liboliphaunt-wasix'`); + } + const nativeVersion = nonEmptyString(compatibility.native_runtime_version, `${product}.extension.compatibility.native_runtime_version`); + const wasixVersion = nonEmptyString(compatibility.wasix_runtime_version, `${product}.extension.compatibility.wasix_runtime_version`); + const expectedNativeVersion = currentProductVersionSync(nativeProduct); + const expectedWasixVersion = currentProductVersionSync(wasixProduct); + if (nativeVersion !== expectedNativeVersion) { + fail(`${product}.extension.compatibility.native_runtime_version must be ${JSON.stringify(expectedNativeVersion)}, got ${JSON.stringify(nativeVersion)}`); + } + if (wasixVersion !== expectedWasixVersion) { + fail(`${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, + }, + }; +} + +function extensionSourceIdentity(product) { + const metadata = extensionMetadata(product); + 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(`${metadata.sourcePath} must declare [postgresql] for contrib extension products`); + } + return { + kind: "postgres-contrib", + name: "postgresql", + version: nonEmptyString(postgresql.version, `${metadata.sourcePath}.postgresql.version`), + url: nonEmptyString(postgresql.url, `${metadata.sourcePath}.postgresql.url`), + sha256: nonEmptyString(postgresql.sha256, `${metadata.sourcePath}.postgresql.sha256`), + }; + } + if (metadata.class === "external") { + return { + kind: "external", + name: nonEmptyString(source.name, `${metadata.sourcePath}.name`), + url: nonEmptyString(source.url, `${metadata.sourcePath}.url`), + branch: nonEmptyString(source.branch, `${metadata.sourcePath}.branch`), + commit: nonEmptyString(source.commit, `${metadata.sourcePath}.commit`), + }; + } + if (metadata.class === "first-party") { + return { + kind: "repo", + name: metadata.sqlName, + path: metadata.sourcePath, + version: currentProductVersionSync(product), + }; + } + fail(`${product}.extension.class has unsupported source identity class ${JSON.stringify(metadata.class)}`); +} + +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); + 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))) { + 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.py b/tools/release/check_artifact_targets.py index 0b1df445..f69c1596 100644 --- a/tools/release/check_artifact_targets.py +++ b/tools/release/check_artifact_targets.py @@ -3,21 +3,17 @@ from __future__ import annotations +import json +import subprocess 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: @@ -40,6 +36,36 @@ def read_toml(path: Path) -> dict: return data +def bun_json(args: list[str]) -> object: + output = subprocess.check_output(["tools/dev/bun.sh", *args], cwd=ROOT, text=True) + return json.loads(output) + + +def artifact_target_matrix(matrix: str) -> dict[str, list[dict[str, str]]]: + value = bun_json(["tools/release/artifact_target_matrix.mjs", matrix]) + if not isinstance(value, dict) or not isinstance(value.get("include"), list): + fail(f"{matrix} matrix query did not return a matrix object") + return value + + +def ci_plan_full_run(*, wasm_target: str = "all", native_target: str = "all", mobile_target: str = "all") -> dict: + value = bun_json( + [ + "tools/graph/ci_plan.mjs", + "plan-full", + "--wasm-target", + wasm_target, + "--native-target", + native_target, + "--mobile-target", + mobile_target, + ] + ) + if not isinstance(value, dict): + fail("CI planner full-run query did not return an object") + return value + + def ts_template(asset: str) -> str: return asset.replace("{version}", "${version}") @@ -55,12 +81,12 @@ def reject_text(path: str, text: str, message: str) -> None: def validate_target_shape() -> None: - targets = artifact_targets.artifact_targets() + targets = product_metadata.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()) + for raw in product_metadata.raw_artifact_target_tables(product_metadata.load_graph()) if isinstance(raw, dict) and isinstance(raw.get("id"), str) } @@ -69,7 +95,11 @@ def validate_target_shape() -> None: 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: + if ( + target.published + and "github-release" not in target.surfaces + and target.kind not in {"native-tools"} + ): fail(f"{target.id} is published but is not a GitHub release asset") if not target.published: if raw_target.get("tier") != "planned": @@ -101,11 +131,12 @@ def validate_target_shape() -> None: ) 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 + if "github-release" in target.surfaces: + 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: @@ -173,7 +204,7 @@ def validate_extension_artifact_targets() -> None: expected_native_targets = { target.target - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="liboliphaunt-native", kind="native-runtime", published_only=True, @@ -182,7 +213,7 @@ def validate_extension_artifact_targets() -> None: } expected_wasix_targets = { wasm_extension_target_id(target.target) - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="liboliphaunt-wasix", published_only=True, ) @@ -194,7 +225,7 @@ def validate_extension_artifact_targets() -> None: 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) + rows = product_metadata.extension_artifact_targets(product=product) published_native_targets = { target.target for target in rows if target.family == "native" and target.published } @@ -256,18 +287,18 @@ def validate_github_asset_helpers() -> None: "macOS liboliphaunt target packager must write into the release asset directory", ) require_text( - "tools/release/check_github_release_assets.py", - "artifact_targets.expected_assets", + "tools/release/check_github_release_assets.mjs", + "expectedAssets", "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", + "tools/release/check-liboliphaunt-release-assets.mjs", + "allArtifactTargets", "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", + "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", ) require_text( @@ -325,20 +356,8 @@ def validate_ci_release_artifacts() -> None: ".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", @@ -373,10 +392,10 @@ def validate_ci_release_artifacts() -> None: "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": ( + "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.py --require-mobile ios --require-mobile-prebuilt-extensions": ( + "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", @@ -408,6 +427,35 @@ def validate_ci_release_artifacts() -> None: for snippet, message in required_ci_snippets.items(): if snippet not in ci: fail(message) + for artifact in product_metadata.ci_sdk_package_artifact_names(): + if artifact not in ci: + fail(f"CI must upload SDK package artifact {artifact}") + for product in product_metadata.sdk_package_products(): + if f"target/sdk-artifacts/{product}" not in ci: + fail(f"CI must use the shared SDK artifact staging layout for {product}") + require_text( + ".github/workflows/release.yml", + 'tools/release/release.py ci-artifacts --product "$product" --family sdk-package', + "release workflow must derive SDK package artifact names from release metadata", + ) + require_text( + ".github/workflows/release.yml", + 'tools/release/release.py ci-products --family sdk-package --products-json "$PRODUCTS_JSON"', + "release workflow must derive selected SDK package products from release metadata", + ) + for legacy_env in ( + "PRODUCT_OLIPHAUNT_RUST", + "PRODUCT_OLIPHAUNT_SWIFT", + "PRODUCT_OLIPHAUNT_KOTLIN", + "PRODUCT_OLIPHAUNT_REACT_NATIVE", + "PRODUCT_OLIPHAUNT_JS", + "PRODUCT_OLIPHAUNT_WASIX_RUST", + ): + reject_text( + ".github/workflows/release.yml", + legacy_env, + f"release workflow must not hard-code SDK product selection with {legacy_env}", + ) require_text( "src/runtimes/broker/moon.yml", 'tags: ["release", "artifact", "ci-broker-runtime"]', @@ -443,14 +491,7 @@ def validate_ci_release_artifacts() -> None: '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", - ): + for project_id in product_metadata.sdk_package_products(): moon_file = ( "src/bindings/wasix-rust/moon.yml" if project_id == "oliphaunt-wasix-rust" @@ -466,25 +507,25 @@ def validate_ci_release_artifacts() -> None: 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") + focused_wasix_jobs = set(ci_plan_full_run(wasm_target="linux-x64-gnu").get("jobs", [])) 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": (', + "tools/graph/ci_plan.mjs", + "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', + "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", ) require_text( - "tools/graph/ci_plan.py", - 'extension_artifacts_wasix_matrix("all", selected_extension_products)', + "tools/graph/ci_plan.mjs", + 'extensionArtifactsWasixMatrix("all", selectedExtensionProducts', "WASIX extension artifacts are portable and must use the portable selector, not the AOT target selector", ) wasix_release_needs = ( @@ -530,12 +571,12 @@ def validate_ci_release_artifacts() -> None: 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:', + "tools/graph/ci_plan.mjs", + 'jobs.has("swift-sdk-package")', "CI affected planner must make Swift SDK package builds imply liboliphaunt target asset producers", ) require_text( - "tools/graph/ci_plan.py", + "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", ) @@ -566,7 +607,7 @@ def validate_ci_release_artifacts() -> None: ) require_text( "tools/release/build-sdk-ci-artifacts.sh", - 'check_staged_artifacts.py --require-sdk-product "$product"', + 'check-staged-artifacts.mjs --require-sdk-product "$product"', "SDK package builders must validate staged package artifacts for runtime/extension payload leaks", ) reject_text( @@ -581,7 +622,7 @@ def validate_ci_release_artifacts() -> None: ) require_text( "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", - "check_staged_artifacts.py \"${validation_args[@]}\"", + "check-staged-artifacts.mjs \"${validation_args[@]}\"", "mobile exact-extension package assembly must validate the staged package manifests and checksums it selected", ) require_text( @@ -600,17 +641,17 @@ def validate_ci_release_artifacts() -> None: "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)", + "tools/release/check-staged-artifacts.mjs", + "validateReleaseArchivePayload(assetPath)", "staged exact-extension artifact checks must reject placeholder files that are not readable release archives", ) require_text( - "tools/graph/ci_plan.py", + "tools/graph/ci_plan.mjs", '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", + "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", ) @@ -634,14 +675,7 @@ def validate_ci_release_artifacts() -> None: "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", - ): + for product_id in product_metadata.sdk_package_products(): require_text( "tools/release/release.py", f'validate_staged_sdk_package("{product_id}")', @@ -705,8 +739,8 @@ def validate_ci_release_artifacts() -> None: "release CLI must verify staged exact-extension checksum manifests exactly", ) require_text( - "tools/release/build-extension-ci-artifacts.py", - "native_asset_name(product, version", + "tools/release/build-extension-ci-artifacts.mjs", + "nativeAssetName(product, version", "exact-extension package artifacts must be named by extension product version", ) require_text( @@ -725,42 +759,42 @@ def validate_ci_release_artifacts() -> None: "WASIX exact-extension artifact producers must support product-scoped builds", ) require_text( - "tools/release/build-extension-ci-artifacts.py", - "native_assets_from_target_indexes", + "tools/release/build-extension-ci-artifacts.mjs", + "nativeAssetsFromTargetIndexes", "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")', + "tools/release/build-extension-ci-artifacts.mjs", + 'publishedTargetIds("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")', + "tools/release/build-extension-ci-artifacts.mjs", + 'publishedTargetIds("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:", + "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", ) require_text( - "tools/release/build-extension-ci-artifacts.py", - "index_contains_sql_name(product_index, sql_name)", + "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", ) require_text( - "tools/release/build-extension-ci-artifacts.py", + "tools/release/build-extension-ci-artifacts.mjs", "-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", + "tools/release/check_github_release_assets.mjs", + "verifyReleaseAssets", "GitHub release verification must derive exact-extension asset expectations from staged extension package manifests", ) require_text( - "tools/release/verify_github_release_attestations.py", + "tools/release/verify_github_release_attestations.mjs", "exact-extension-artifact", "Release attestation verification must include exact-extension artifact products", ) @@ -816,8 +850,8 @@ def validate_ci_release_artifacts() -> None: ) require_text( ".github/workflows/release.yml", - "oliphaunt-broker-release-assets", - "release workflow must name the broker CI artifacts it consumes", + "tools/release/release.py ci-artifacts --product \"$product\" --kind \"$kind\" --family release-assets", + "release workflow must derive native helper release artifact names from target metadata", ) require_text( ".github/workflows/release.yml", @@ -831,8 +865,8 @@ def validate_ci_release_artifacts() -> None: ) require_text( ".github/workflows/release.yml", - "oliphaunt-node-direct-release-assets", - "release workflow must name the Node direct CI artifacts it consumes", + "tools/release/release.py ci-artifacts --product oliphaunt-node-direct --kind node-direct-addon --family npm-package", + "release workflow must derive Node direct npm package artifact names from target metadata", ) require_text( ".github/workflows/release.yml", @@ -871,12 +905,12 @@ def validate_ci_release_artifacts() -> None: ) require_text( "tools/release/release.py", - "package_liboliphaunt_cargo_artifacts.py", + "package-liboliphaunt-cargo-artifacts.mjs", "liboliphaunt native Cargo artifact packages must be generated from staged native release assets", ) require_text( "tools/release/release.py", - "package_broker_cargo_artifacts.py", + "package_broker_cargo_artifacts.mjs", "broker Cargo artifact packages must be generated from staged broker release assets", ) require_text( @@ -904,10 +938,15 @@ def validate_ci_release_artifacts() -> None: "DEFAULT_PART_COUNT", "WASIX Cargo artifact packager must not generate reserved part crates", ) - reject_text( + require_text( "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", - "part_package_name", - "WASIX Cargo artifact packager must not generate part crate names", + "wasix_extension_aot_part_package_name", + "WASIX Cargo artifact packager may only generate named part crates for oversized extension AOT artifacts", + ) + require_text( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", + "EXTENSION_AOT_SPLIT_THRESHOLD_BYTES", + "WASIX Cargo artifact packager must keep extension AOT part splitting behind an explicit size threshold", ) require_text( "tools/release/release.py", @@ -926,9 +965,14 @@ def validate_ci_release_artifacts() -> None: ) require_text( "tools/release/release.py", - '"package/runtime/bin/initdb"', + "required_runtime_member_paths", "liboliphaunt npm artifact packages must include the selected platform runtime tree", ) + require_text( + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "optimizeNativePayload(", + "liboliphaunt Cargo artifact packages must prune and validate native runtime payloads before splitting", + ) reject_text( ".github/workflows/release.yml", "target/release-assets/native", @@ -965,7 +1009,7 @@ def validate_ci_release_artifacts() -> None: "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", + "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", @@ -1057,7 +1101,7 @@ def validate_ci_release_artifacts() -> None: 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") + planner = read_text("tools/graph/ci_plan.mjs") for output_name in ( "liboliphaunt_native_desktop_runtime_matrix", "liboliphaunt_native_android_runtime_matrix", @@ -1065,15 +1109,15 @@ def validate_target_matrices() -> None: ): 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", + for output_name, helper in ( + ("liboliphaunt_native_desktop_runtime_matrix", "liboliphauntNativeDesktopRuntimeMatrix"), + ("liboliphaunt_native_android_runtime_matrix", "liboliphauntNativeAndroidRuntimeMatrix"), + ("liboliphaunt_native_ios_runtime_matrix", "liboliphauntNativeIosRuntimeMatrix"), ): require_text( - "tools/graph/ci_plan.py", - f"artifact_target_matrix.{helper}", - f"CI affected planner must derive {helper} from release metadata artifact targets", + "tools/graph/ci_plan.mjs", + helper, + f"CI affected planner must derive {output_name} 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") @@ -1101,14 +1145,24 @@ def validate_target_matrices() -> None: ) require_text( "src/extensions/artifacts/packages/moon.yml", - "tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix", + "tools/release/build-extension-ci-artifacts.mjs --all --require-native --require-wasix", "CI exact-extension package producer must use the shared product artifact builder", ) + require_text( + "src/extensions/artifacts/packages/moon.yml", + "/target/extensions/wasix/aot-artifacts/**/*", + "CI exact-extension package producer must consume WASIX extension AOT artifacts", + ) 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 package-extension-aot --target-triple "$target"', + "WASIX AOT target build must package extension AOT artifacts for extension Cargo crates", + ) require_text( "src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh", "cargo run -p xtask -- assets check-aot --target-triple \"$target\"", @@ -1118,14 +1172,14 @@ def validate_target_matrices() -> None: 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") + if "../release/artifact_target_matrix.mjs" not in planner: + fail("shared affected planner must query the release artifact target matrix helper") - liboliphaunt_matrix = artifact_target_matrix.liboliphaunt_native_runtime_matrix() + liboliphaunt_matrix = artifact_target_matrix("liboliphaunt-native-runtime") liboliphaunt_targets = {item["target"] for item in liboliphaunt_matrix["include"]} expected_liboliphaunt_targets = { target.target - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="liboliphaunt-native", kind="native-runtime", published_only=True, @@ -1137,7 +1191,7 @@ def validate_target_matrices() -> None: f"{sorted(liboliphaunt_targets)} vs {sorted(expected_liboliphaunt_targets)}" ) - extension_native_matrix = artifact_target_matrix.extension_artifacts_native_matrix() + extension_native_matrix = artifact_target_matrix("extension-artifacts-native") extension_native_pairs = { (product, item["target"]) for item in extension_native_matrix["include"] @@ -1146,7 +1200,7 @@ def validate_target_matrices() -> None: } expected_extension_native_pairs = { (target.product, target.target) - for target in extension_artifact_targets.artifact_targets(family="native", published_only=True) + for target in product_metadata.extension_artifact_targets(family="native", published_only=True) } if extension_native_pairs != expected_extension_native_pairs: fail( @@ -1154,11 +1208,11 @@ def validate_target_matrices() -> None: f"{sorted(extension_native_pairs)} vs {sorted(expected_extension_native_pairs)}" ) - broker_matrix = artifact_target_matrix.broker_runtime_matrix() + broker_matrix = artifact_target_matrix("broker-runtime") broker_targets = {item["target"] for item in broker_matrix["include"]} expected_broker_targets = { target.target - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="oliphaunt-broker", kind="broker-helper", published_only=True, @@ -1170,11 +1224,11 @@ def validate_target_matrices() -> None: f"{sorted(broker_targets)} vs {sorted(expected_broker_targets)}" ) - node_direct_matrix = artifact_target_matrix.node_direct_runtime_matrix() + node_direct_matrix = artifact_target_matrix("node-direct-runtime") 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( + for target in product_metadata.artifact_targets( product="oliphaunt-node-direct", kind="node-direct-addon", published_only=True, @@ -1186,7 +1240,7 @@ def validate_target_matrices() -> None: f"{sorted(node_direct_targets)} vs {sorted(expected_node_direct_targets)}" ) - extension_wasix_matrix = artifact_target_matrix.extension_artifacts_wasix_matrix() + extension_wasix_matrix = artifact_target_matrix("extension-artifacts-wasix") extension_wasix_pairs = { (product, item["target"]) for item in extension_wasix_matrix["include"] @@ -1195,7 +1249,7 @@ def validate_target_matrices() -> None: } expected_extension_wasix_pairs = { (target.product, target.target) - for target in extension_artifact_targets.artifact_targets(family="wasix", published_only=True) + for target in product_metadata.extension_artifact_targets(family="wasix", published_only=True) } if extension_wasix_pairs != expected_extension_wasix_pairs: fail( @@ -1205,7 +1259,7 @@ def validate_target_matrices() -> None: def validate_typescript_runtime_targets() -> None: - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="liboliphaunt-native", kind="native-runtime", surface="typescript-native-direct", @@ -1233,7 +1287,7 @@ def validate_typescript_runtime_targets() -> 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( + for target in product_metadata.artifact_targets( product="oliphaunt-broker", kind="broker-helper", surface="typescript-broker", @@ -1256,7 +1310,7 @@ def validate_typescript_runtime_targets() -> 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( + for target in product_metadata.artifact_targets( product="oliphaunt-node-direct", kind="node-direct-addon", surface="npm-optional", @@ -1296,7 +1350,7 @@ def validate_rust_broker_targets() -> None: "OLIPHAUNT_BROKER_ASSET_DIR", "Rust broker resolver must support package-shaped broker artifact fixtures", ) - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product="oliphaunt-broker", kind="broker-helper", surface="rust-broker", @@ -1319,9 +1373,13 @@ def validate_expected_product_assets() -> None: expected = { "liboliphaunt-native": { "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", @@ -1358,7 +1416,7 @@ def validate_expected_product_assets() -> None: for product, assets in expected.items(): actual = { target.asset - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product=product, surface="github-release", published_only=True, 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..390ebf3f 100755 --- a/tools/release/check_consumer_shape.py +++ b/tools/release/check_consumer_shape.py @@ -17,9 +17,7 @@ from pathlib import Path from typing import NoReturn -import artifact_targets import product_metadata -import extension_artifact_targets ROOT = Path(__file__).resolve().parents[2] @@ -27,6 +25,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) @@ -239,13 +258,7 @@ def product_registry_packages(product: str) -> list[str]: 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]: @@ -256,6 +269,117 @@ def product_publish_targets(product: str) -> list[str]: return [str(target) for target in targets] +def npm_registry_packages(product: str, kind: str, surface: str) -> set[str]: + packages = set() + for target in product_metadata.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 = product_metadata.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="rust-native-direct", + published_only=True, + ) + tools_targets = product_metadata.artifact_targets( + product="liboliphaunt-native", + kind="native-tools", + surface="typescript-native-direct", + published_only=True, + ) + android_targets = product_metadata.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 = product_metadata.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, @@ -331,21 +455,7 @@ def check_liboliphaunt(findings: list[Finding]) -> None: 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 +466,63 @@ 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") + release_cli = read_text("tools/release/release.py") + local_registry_publisher = read_text("tools/release/local_registry_publish.py") + 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 "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-{lib_version}-*' in local_registry_publisher + and "DEFAULT_CURRENT_ARTIFACT_ROOT" in local_registry_publisher + and "copy_release_asset_set" in local_registry_publisher + and "native_split_release_assets_ready" in local_registry_publisher + and "native_npm_release_assets_ready" in local_registry_publisher + and "native_split_release_asset_missing_message" in local_registry_publisher + and "native_npm_release_asset_missing_message" in local_registry_publisher + and "stage_release_asset_npm_packages(roots, registry_root, dry_run, result, strict)" in local_registry_publisher + and "cargo_dependency_name_matches_host_target" 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 "is_default_cargo_tmp_crate_artifact" in local_registry_publisher + and "ignored malformed Cargo scratch artifact" in local_registry_publisher + 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-cargo-artifacts.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( @@ -407,10 +574,10 @@ def check_liboliphaunt(findings: list[Finding]) -> None: severity="P0", ) for required in [ - "package_liboliphaunt_cargo_artifacts.py", + "package-liboliphaunt-cargo-artifacts.mjs", "publish_liboliphaunt_cargo_artifacts", "liboliphaunt_cargo_artifact_crates", - "package_liboliphaunt_cargo_artifacts.cargo_package_name", + "liboliphaunt_cargo_package_name", ]: require( findings, @@ -433,6 +600,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 +608,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 +616,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 +631,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(): @@ -739,16 +909,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, @@ -760,7 +921,7 @@ def check_broker(findings: list[Finding]) -> None: severity="P0", ) version = product_metadata.read_current_version(product) - for target in artifact_targets.artifact_targets( + for target in product_metadata.artifact_targets( product=product, kind="broker-helper", surface="rust-broker", @@ -870,38 +1031,58 @@ def check_node_direct(findings: list[Finding]) -> None: severity="P0", ) + node_targets = product_metadata.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", @@ -957,7 +1138,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 +1146,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 +1156,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: @@ -1043,6 +1234,27 @@ 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_workflow = read_text(".github/workflows/release.yml") @@ -1063,7 +1275,7 @@ def check_kotlin(findings: list[Finding]) -> None: severity="P0", ) for required in [ - "build_maven_artifact_manifest.py", + "build_maven_artifact_manifest.mjs", "publish_liboliphaunt_runtime_maven", "publish_selected_extension_maven", ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", @@ -1208,6 +1420,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 +1468,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 = product_metadata.typescript_optional_runtime_package_versions() optional_dependencies = package.get("optionalDependencies", {}) require( findings, @@ -1365,10 +1592,66 @@ def check_wasm(findings: list[Finding]) -> None: f"oliphaunt-wasix Cargo.toml default={features.get('default')!r}", severity="P0", ) + expected_tools_feature = ( + product_metadata.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", + ) + 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 = product_metadata.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 +1659,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 = ( + product_metadata.wasix_public_aot_cargo_dependencies() + ) + expected_tools_aot_dependencies = ( + product_metadata.wasix_public_tools_aot_cargo_dependencies() + ) missing_aot_dependencies = [] for cfg, crate in expected_aot_dependencies.items(): target = target_tables.get(cfg) @@ -1392,12 +1700,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 +1743,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", ) @@ -1481,16 +1799,94 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: ) 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" + asset_package.get("name") == "liboliphaunt-wasix-portable" and asset_package.get("version") == product_metadata.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") == product_metadata.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,24 +1898,21 @@ 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 product_metadata.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_dependency_invariant_source = read_text("tools/policy/check-wasix-release-dependency-invariants.mjs") workflow_source = read_text(".github/workflows/release.yml") require( findings, @@ -1532,6 +1925,73 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: ["tools/release/release.py", ".github/workflows/release.yml"], severity="P0", ) + require( + findings, + product, + "wasix-portable-runtime-tool-contract", + product_metadata.wasix_core_runtime_archive_files() + == ("oliphaunt/bin/initdb", "oliphaunt/bin/postgres") + and product_metadata.wasix_tools_payload_files() + == ("bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm") + and product_metadata.wasix_forbidden_runtime_archive_tool_files() + == ("oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql") + and product_metadata.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 '"oliphaunt/bin/initdb",' in wasix_packager_source + and '"oliphaunt/bin/postgres",' in wasix_packager_source + and '"oliphaunt/bin/pg_ctl",' in wasix_packager_source + and '"oliphaunt/bin/pg_dump",' in wasix_packager_source + and '"oliphaunt/bin/psql",' 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.py", + ], + severity="P0", + ) + require( + findings, + product, + "wasix-tools-dependency-invariant", + "INTERNAL_TOOLS_MANIFEST" in wasix_dependency_invariant_source + and "INTERNAL_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.py") + 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 raise RuntimeError(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.py", + severity="P0", + ) + require( + findings, + product, + "wasix-local-registry-requires-target-artifacts", + "strict=strict" in local_registry_publisher + and "is missing local registry inputs for host target artifact dependencies" in local_registry_publisher + and "cargo_dependency_name_matches_host_target" in local_registry_publisher + and "prune_missing_feature_dependencies" 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.py", + severity="P0", + ) require( findings, product, @@ -1539,14 +1999,15 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: "CRATES_IO_MAX_BYTES" in wasix_packager_source and "validate_crate_size" in wasix_packager_source and "DEFAULT_PART_COUNT" not in wasix_packager_source - and "part_package_name" not in wasix_packager_source + and "wasix_extension_aot_part_package_name" 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 and fail above the crates.io size limit instead of splitting into part crates.", + "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.py", severity="P0", ) version = product_metadata.read_current_version(product) - expected_assets = set(artifact_targets.expected_assets(product, version, surface="github-release")) + expected_assets = set(product_metadata.expected_assets(product, version, surface="github-release")) require( findings, product, @@ -1572,7 +2033,7 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: 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 product_metadata.published_android_maven_targets(product) } version_path = f"{package_path}/VERSION" version = read_text(version_path).strip() @@ -1591,16 +2052,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.", + "Exact-extension release metadata must publish exact GitHub artifacts and explicit Android Maven packages by SQL extension name.", f"{package_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 = product_metadata.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( @@ -1620,6 +2080,35 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: f"{package_path}/release.toml: native={sorted(native_targets)!r} wasix={sorted(wasix_targets)!r}", severity="P0", ) + wasix_package = product_metadata.wasix_extension_package_name(product) + wasix_aot_packages = { + product_metadata.wasix_extension_aot_package_name(product, target) + for target in product_metadata.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 product_metadata.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"{package_path}/release.toml registry={sorted(product_registry_packages(product))!r} wasix={wasix_package!r} wasix_aot={sorted(wasix_aot_packages)!r}", + severity="P0", + ) require( findings, product, 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..f75c4dac 100755 --- a/tools/release/check_release_metadata.py +++ b/tools/release/check_release_metadata.py @@ -6,16 +6,37 @@ import json import re import sys +import tempfile import tomllib from pathlib import Path from typing import NoReturn -import artifact_targets -import extension_artifact_targets import product_metadata +import release 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: @@ -94,7 +115,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 product_metadata.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 +158,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 +166,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") @@ -217,18 +249,80 @@ def validate_graph_files(graph: dict) -> None: 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) + 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(product_metadata.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") + fail(f"{product} must publish exact-extension GitHub assets and 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") + 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 product_metadata.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 product_metadata.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 product_metadata.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 = product_metadata.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 product_metadata.wasix_expected_extension_aot_targets(): + package = product_metadata.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(graph: dict) -> None: + workflow = read_text(".github/workflows/release.yml") + release_source = read_text("tools/release/release.py") + 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 'run(["tools/release/check_publish_environment.mjs", *products_args])' not in release_source: + fail("release.py publish dry-run must validate publish credentials through the Bun helper") + saw_extension = False + for product, config in product_metadata.graph_products(graph).items(): + declared = set(product_metadata.string_list(config, "publish_targets", product)) + supported = release.supported_publish_targets(product) + if declared != supported: + fail( + f"{product}.publish_targets must match release.py publish handler coverage: " + f"declared={sorted(declared)}, supported={sorted(supported)}" + ) + step_coverage = release.publish_step_target_coverage(product) + if release.is_extension_product(product): + saw_extension = True + continue + for step in step_coverage: + if f'product == "{product}" and step == "{step}"' not in release_source: + fail(f"release.py 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 f'is_extension_product(product) and step == "{step}"' not in release_source: + fail(f"release.py must dispatch extension publish step {step}") + 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: @@ -273,6 +367,81 @@ def validate_release_setup_docs() -> None: fail("release setup guide must contain exactly one Sonatype token setup reference") +def validate_local_registry_publisher() -> None: + import local_registry_publish + + publisher = read_text("tools/release/local_registry_publish.py") + if "explicit_roots = list(artifact_roots)" not in publisher or "roots = explicit_roots or [" not in publisher: + fail("local registry publisher must treat explicit --artifact-root values as the selected artifact set") + if "roots.extend(extra_roots)" in publisher: + fail("local registry publisher must not append explicit artifact roots to stale default build roots") + if "include_icu=False" in publisher: + fail("local registry npm publishing must include the declared @oliphaunt/icu sidecar package") + if f'oliphaunt-tools-{{lib_version}}-*' 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 raise RuntimeError(message)" not in publisher + ): + fail("strict local Cargo publishing must reject legacy unsplit WASIX artifact crates") + if 'ROOT / "target" / "oliphaunt-wasix" / "cargo-artifacts",' in publisher or ( + 'ROOT / "target" / "oliphaunt-wasix" / "release-assets",' in publisher + ): + fail("local registry publisher defaults must not silently scan stale canonical WASIX build outputs") + if "def clear_local_cargo_home_cache" 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 ( + "def stage_release_asset_cargo_packages" 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.py" not in publisher + or "host_cargo_release_target()" not in publisher + or "stage_release_asset_cargo_packages(roots, registry_root, dry_run, result, strict)" not in publisher + or "strict=strict" not in publisher + or "prune_missing_feature_dependencies" not in publisher + ): + fail("local registry Cargo publishing must generate runtime/tool artifact crates from staged release assets") + artifacts = local_registry_publish.local_publish_artifacts() + 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 ( + "local_publish_aggregate_artifacts()" not in publisher + or "ci_aggregate_release_asset_artifact_name(\"liboliphaunt-native\")" not in publisher + or "ci_aggregate_release_asset_artifact_name(\"liboliphaunt-wasix\")" not in publisher + or "ci_wasix_runtime_artifact_names()" not in publisher + or "ci_wasix_extension_artifact_names()" not in publisher + or "ci_extension_package_artifact_names()" not in publisher + ): + fail("local registry publish preset must derive aggregate runtime and extension artifact names from release metadata") + if "ci_wasix_aot_runtime_artifact_names()" not in publisher: + fail("local registry publish preset must derive WASIX AOT artifact names from artifact target metadata") + 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_publish.discover_extension_manifests([first.parent, second.parent]) + if manifests != [first / "extension-artifacts.json"]: + 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 +450,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 +460,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 +485,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 +503,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 +527,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 +549,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( @@ -401,7 +580,7 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: ) require_text( "tools/release/build-sdk-ci-artifacts.sh", - "render_swiftpm_release_package.py", + "render_swiftpm_release_package.mjs", "Swift SDK package artifact builder must render the staged public SwiftPM release manifest", ) require_text( @@ -420,21 +599,21 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: "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", ) @@ -488,6 +667,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", @@ -551,9 +765,99 @@ 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.py", + 'product_metadata.registry_package_names("oliphaunt-kotlin", "maven")', + "Kotlin Maven release idempotency probes must derive package coordinates from release metadata", + ) + 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", + ) + 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 +925,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 +1039,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 +1102,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 = product_metadata.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 +1120,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 +1276,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( @@ -961,6 +1319,41 @@ def validate_typescript( "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", @@ -1021,15 +1414,31 @@ def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None 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 = ( + product_metadata.wasix_public_aot_cargo_dependencies() + ) + expected_tools_aot_dependencies = ( + product_metadata.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 +1446,118 @@ 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 = ( + product_metadata.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.py") + if ( + product_metadata.wasix_core_runtime_archive_files() + != ("oliphaunt/bin/initdb", "oliphaunt/bin/postgres") + or product_metadata.wasix_tools_payload_files() + != ("bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm") + or product_metadata.wasix_forbidden_runtime_archive_tool_files() + != ("oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql") + or product_metadata.wasix_tools_aot_artifacts() + != {"tool:pg_dump", "tool:psql"} + or "split_runtime_tools_payload" not in wasix_packager_source + or "split_aot_tools_payload" not in wasix_packager_source + or "text = re.sub(r'(?m)^publish = false\\n?', \"\", text)" not in wasix_packager_source + ): + fail("WASIX Cargo artifact packager must split pg_dump/psql into publishable tools crates while keeping only postgres/initdb in root runtime crates") + wasix_dependency_invariant_source = read_text("tools/policy/check-wasix-release-dependency-invariants.mjs") + if ( + "INTERNAL_TOOLS_MANIFEST" not in wasix_dependency_invariant_source + or "INTERNAL_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") + 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 "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 + ): + 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") + 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") + 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(")") @@ -1061,16 +1582,12 @@ def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None 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")) 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 product_metadata.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", {}) @@ -1101,7 +1618,9 @@ def main() -> int: graph = load_graph() validate_graph_files(graph) validate_exact_extension_registry_shape(graph) + validate_publish_target_coverage(graph) validate_release_setup_docs() + validate_local_registry_publisher() versions = { product: product_metadata.read_current_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..2a4699fc --- /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/release/release.py', [ + 'plan', + '--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.py b/tools/release/local_registry_publish.py new file mode 100755 index 00000000..b6dd9986 --- /dev/null +++ b/tools/release/local_registry_publish.py @@ -0,0 +1,3055 @@ +#!/usr/bin/env python3 +"""Stage Oliphaunt release artifacts into local package registries. + +The script intentionally consumes the same artifact shape produced by CI: + +* npm package tarballs under ``target/sdk-artifacts`` or a downloaded artifact + directory are published to a local Verdaccio. +* Rust ``.crate`` files are indexed into a local Cargo git registry whose + downloads point at local files. +* Maven repository trees are copied into a local filesystem Maven repository. +* SwiftPM artifacts are staged for inspection; the Swift product currently + releases through a source tag rather than a registry publish. +""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import json +import os +import platform as host_platform +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import tomllib +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_RUN_ID = "28049923289" +DEFAULT_REPO = "f0rr0/oliphaunt" +DEFAULT_REGISTRY_ROOT = ROOT / "target" / "local-registries" +DEFAULT_CURRENT_ARTIFACT_ROOT = ROOT / "target" / "local-registry-current" +DEFAULT_ARTIFACT_ROOT = ROOT / "target" / "local-registry-artifacts" +NPM_PACKAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024 +CRATES_IO_INDEX = "https://github.com/rust-lang/crates.io-index" +CARGO_PACKAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024 +CARGO_EXTENSION_PART_BYTES = 7 * 1024 * 1024 +CARGO_EXTENSION_SPLIT_THRESHOLD_BYTES = 9 * 1024 * 1024 +LEGACY_WASIX_ARTIFACT_CRATES = { + "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", +} +NON_PUBLISHABLE_LOCAL_CARGO_CRATE_PREFIXES = ( + "oliphaunt-perf-", +) + +def local_publish_aggregate_artifacts() -> list[str]: + return [ + product_metadata.ci_aggregate_release_asset_artifact_name("liboliphaunt-native"), + product_metadata.ci_aggregate_release_asset_artifact_name("liboliphaunt-wasix"), + *product_metadata.ci_wasix_runtime_artifact_names(), + *product_metadata.ci_wasix_extension_artifact_names(), + *product_metadata.ci_extension_package_artifact_names(), + ] + + +def local_publish_artifacts() -> list[str]: + artifacts = [ + *local_publish_aggregate_artifacts(), + *product_metadata.ci_release_asset_artifact_names("liboliphaunt-native", "native-runtime"), + *product_metadata.ci_wasix_aot_runtime_artifact_names(), + *product_metadata.ci_release_asset_artifact_names("oliphaunt-broker", "broker-helper"), + *product_metadata.ci_release_asset_artifact_names("oliphaunt-node-direct", "node-direct-addon"), + *product_metadata.ci_npm_package_artifact_names("oliphaunt-node-direct", "node-direct-addon"), + *product_metadata.ci_sdk_package_artifact_names(), + ] + duplicates = sorted({artifact for artifact in artifacts if artifacts.count(artifact) > 1}) + if duplicates: + raise RuntimeError("duplicate local publish artifact names: " + ", ".join(duplicates)) + return artifacts + + +def rel(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + +def run( + args: list[str], + *, + cwd: Path = ROOT, + check: bool = True, + capture: bool = False, + env: dict[str, str] | None = None, + timeout: float | None = None, +) -> subprocess.CompletedProcess[str]: + kwargs: dict[str, Any] = { + "cwd": cwd, + "check": check, + "text": True, + "env": env, + "timeout": timeout, + } + if capture: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE + return subprocess.run(args, **kwargs) + + +def require_command(name: str) -> str: + resolved = shutil.which(name) + if not resolved: + raise RuntimeError(f"missing required command: {name}") + return resolved + + +@dataclass +class SurfaceResult: + surface: str + published: list[str] = field(default_factory=list) + staged: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + + def add_skip(self, message: str) -> None: + self.skipped.append(message) + + +def discover_roots(artifact_roots: Iterable[Path]) -> list[Path]: + explicit_roots = list(artifact_roots) + roots = explicit_roots or [ + DEFAULT_CURRENT_ARTIFACT_ROOT, + DEFAULT_ARTIFACT_ROOT, + ROOT / "target" / "sdk-artifacts", + ROOT / "target" / "package" / "tmp-crate", + ROOT / "target" / "package" / "tmp-registry", + ROOT / "target" / "local-registry-generated" / "broker-cargo", + ROOT / "target" / "oliphaunt-broker" / "cargo-artifacts", + ROOT / "target" / "extension-artifacts", + ] + seen: set[Path] = set() + result: list[Path] = [] + for root in roots: + resolved = root.resolve() + if resolved in seen or not resolved.exists(): + continue + seen.add(resolved) + result.append(resolved) + return result + + +def list_ci_artifacts(repo: str, run_id: str) -> list[dict[str, Any]]: + require_command("gh") + completed = run( + [ + "gh", + "api", + f"repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100", + "--paginate", + ], + capture=True, + ) + data = json.loads(completed.stdout) + if isinstance(data, list): + artifacts: list[dict[str, Any]] = [] + for page in data: + artifacts.extend(page.get("artifacts", [])) + return artifacts + return data.get("artifacts", []) + + +def download_artifacts(args: argparse.Namespace) -> None: + artifacts = list(args.artifact) + if args.preset == "local-publish": + artifacts.extend(local_publish_artifacts()) + artifacts = sorted(set(artifacts)) + if not artifacts: + print("No artifacts selected; pass --artifact or --preset local-publish.", file=sys.stderr) + raise SystemExit(2) + + available = {artifact["name"]: artifact for artifact in list_ci_artifacts(args.repo, args.run_id)} + missing = [artifact for artifact in artifacts if artifact not in available] + if missing: + print(f"Run {args.run_id} is missing artifacts: {', '.join(missing)}", file=sys.stderr) + raise SystemExit(1) + if args.dry_run: + for artifact in artifacts: + row = available[artifact] + print(f"{artifact}\t{row.get('size_in_bytes', 0)}") + return + + args.destination.mkdir(parents=True, exist_ok=True) + for artifact in artifacts: + artifact_dir = args.destination / artifact + if artifact_dir.exists() and any(artifact_dir.iterdir()) and not args.force: + print(f"Skipping existing {rel(artifact_dir)}") + continue + shutil.rmtree(artifact_dir, ignore_errors=True) + artifact_dir.mkdir(parents=True, exist_ok=True) + print(f"Downloading {artifact} from {args.repo} run {args.run_id}") + run( + [ + "gh", + "run", + "download", + args.run_id, + "--repo", + args.repo, + "--name", + artifact, + "--dir", + str(artifact_dir), + ] + ) + + +def discover_files(roots: list[Path], suffixes: tuple[str, ...]) -> list[Path]: + files: list[Path] = [] + for root in roots: + if root.is_file() and root.name.endswith(suffixes): + files.append(root) + continue + if root.is_dir(): + files.extend(path for path in root.rglob("*") if path.is_file() and path.name.endswith(suffixes)) + return sorted(set(files)) + + +def file_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 copy_release_assets( + roots: list[Path], + destination: Path, + patterns: tuple[str, ...], +) -> list[Path]: + selected: dict[str, tuple[Path, Path]] = {} + destination_resolved = destination.resolve() + for root in roots: + if not root.is_dir(): + continue + root_candidates: list[Path] = [] + for pattern in patterns: + for path in root.rglob(pattern): + if not path.is_file(): + continue + try: + path.resolve().relative_to(destination_resolved) + continue + except ValueError: + pass + root_candidates.append(path) + for path in sorted(root_candidates): + existing = selected.get(path.name) + if existing is None: + selected[path.name] = (path, root) + continue + existing_path, existing_root = existing + if existing_root.resolve() != root.resolve(): + continue + if file_sha256(existing_path) != file_sha256(path): + raise RuntimeError( + f"conflicting release asset {path.name} within {rel(root)}: " + f"{rel(existing_path)} and {rel(path)} differ" + ) + if not selected: + return [] + + shutil.rmtree(destination, ignore_errors=True) + destination.mkdir(parents=True, exist_ok=True) + copied: list[Path] = [] + for source, _root in sorted(selected.values(), key=lambda item: item[0].name): + target = destination / source.name + shutil.copy2(source, target) + copied.append(target) + return copied + + +def release_asset_candidate(root: Path, name: str, destination: Path) -> Path | None: + destination_resolved = destination.resolve() + if root.is_file() and root.name == name: + return root + if not root.is_dir(): + return None + + candidates: list[Path] = [] + for path in root.rglob(name): + if not path.is_file(): + continue + try: + path.resolve().relative_to(destination_resolved) + continue + except ValueError: + pass + candidates.append(path) + if not candidates: + return None + + selected = sorted(candidates)[0] + for candidate in candidates[1:]: + if file_sha256(candidate) != file_sha256(selected): + raise RuntimeError( + f"conflicting release asset {name} within {rel(root)}: " + f"{rel(selected)} and {rel(candidate)} differ" + ) + return selected + + +def copy_release_asset_set( + roots: list[Path], + destination: Path, + names: tuple[str, ...], +) -> list[Path]: + for root in roots: + selected: list[Path] = [] + for name in names: + candidate = release_asset_candidate(root, name, destination) + if candidate is None: + break + selected.append(candidate) + if len(selected) != len(names): + continue + + shutil.rmtree(destination, ignore_errors=True) + destination.mkdir(parents=True, exist_ok=True) + copied: list[Path] = [] + for source in selected: + target = destination / source.name + shutil.copy2(source, target) + copied.append(target) + return copied + return [] + + +def release_asset_dir_has_files(asset_dir: Path, patterns: tuple[str, ...]) -> bool: + if not asset_dir.is_dir(): + return False + return any(path.is_file() for pattern in patterns for path in asset_dir.glob(pattern)) + + +def release_asset_dir_has_exact_files(asset_dir: Path, names: tuple[str, ...]) -> bool: + return asset_dir.is_dir() and all((asset_dir / name).is_file() for name in names) + + +def missing_release_asset_names(asset_dir: Path, names: tuple[str, ...]) -> list[str]: + return [name for name in names if not (asset_dir / name).is_file()] + + +def release_asset_dir_selected(roots: list[Path], asset_dir: Path) -> bool: + resolved = asset_dir.resolve() + return any(root.resolve() == resolved for root in roots) + + +def native_release_asset_name(version: str, target: str, kind: str) -> str: + matches = [ + artifact.asset_name(version) + for artifact in product_metadata.artifact_targets( + product="liboliphaunt-native", + kind=kind, + published_only=True, + ) + if artifact.target == target + and ( + "rust-native-direct" in artifact.surfaces + or "typescript-native-direct" in artifact.surfaces + ) + ] + if len(matches) != 1: + raise RuntimeError( + f"expected exactly one published liboliphaunt-native {kind} asset for {target}, got {matches}" + ) + return matches[0] + + +def native_split_release_asset_names(version: str, target: str) -> tuple[str, str]: + return ( + native_release_asset_name(version, target, "native-runtime"), + native_release_asset_name(version, target, "native-tools"), + ) + + +def native_npm_release_asset_names(version: str, target: str) -> tuple[str, str, str]: + return ( + *native_split_release_asset_names(version, target), + f"liboliphaunt-{version}-icu-data.tar.gz", + ) + + +def native_split_release_assets_ready(asset_dir: Path, version: str, target: str) -> tuple[bool, list[str]]: + required = native_split_release_asset_names(version, target) + missing = missing_release_asset_names(asset_dir, required) + return release_asset_dir_has_exact_files(asset_dir, required), missing + + +def native_npm_release_assets_ready(asset_dir: Path, version: str, target: str) -> tuple[bool, list[str]]: + required = native_npm_release_asset_names(version, target) + missing = missing_release_asset_names(asset_dir, required) + return release_asset_dir_has_exact_files(asset_dir, required), missing + + +def native_split_release_asset_missing_message(asset_dir: Path, version: str, target: str, missing: list[str]) -> str: + required = ", ".join(native_split_release_asset_names(version, target)) + return ( + f"native split release asset staging for {target} requires runtime and tools assets " + f"({required}) under {rel(asset_dir)}; missing {', '.join(missing)}" + ) + + +def native_npm_release_asset_missing_message(asset_dir: Path, version: str, target: str, missing: list[str]) -> str: + required = ", ".join(native_npm_release_asset_names(version, target)) + return ( + f"native npm artifact staging for {target} requires runtime, tools, and ICU assets " + f"({required}) under {rel(asset_dir)}; missing {', '.join(missing)}" + ) + + +def host_npm_target() -> str | None: + machine = host_platform.machine().lower() + if sys.platform == "linux" and machine in {"x86_64", "amd64"}: + return "linux-x64-gnu" + if sys.platform == "linux" and machine in {"aarch64", "arm64"}: + return "linux-arm64-gnu" + if sys.platform == "darwin" and machine == "arm64": + return "macos-arm64" + if sys.platform == "win32" and machine in {"amd64", "x86_64"}: + return "windows-x64-msvc" + return None + + +def host_cargo_release_target() -> str | None: + machine = host_platform.machine().lower() + if sys.platform == "linux" and machine in {"x86_64", "amd64"}: + return "linux-x64-gnu" + if sys.platform == "linux" and machine in {"aarch64", "arm64"}: + return "linux-arm64-gnu" + if sys.platform == "darwin" and machine == "arm64": + return "macos-arm64" + if sys.platform == "win32" and machine in {"amd64", "x86_64"}: + return "windows-x64-msvc" + return None + + +def cargo_target_triple(target: str) -> str | None: + if target == "linux-x64-gnu": + return "x86_64-unknown-linux-gnu" + if target == "linux-arm64-gnu": + return "aarch64-unknown-linux-gnu" + if target == "macos-arm64": + return "aarch64-apple-darwin" + if target == "windows-x64-msvc": + return "x86_64-pc-windows-msvc" + return None + + +def npm_platform_constraints(target: str) -> dict[str, list[str]]: + 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 {} + + +def extension_npm_package(sql_name: str) -> str: + return f"@oliphaunt/extension-{sql_name.replace('_', '-')}" + + +def extension_npm_target_package(sql_name: str, target: str) -> str: + return f"{extension_npm_package(sql_name)}-{target}" + + +def extension_npm_payload_package(sql_name: str, target: str, index: int) -> str: + return f"{extension_npm_target_package(sql_name, target)}-payload-{index}" + + +def discover_extension_manifests(roots: list[Path]) -> list[Path]: + manifests: dict[tuple[str, ...], Path] = {} + seen_paths: set[Path] = set() + for root in roots: + if root.is_file() and root.name == "extension-artifacts.json": + candidates = [root] + elif root.is_dir(): + candidates = sorted(path for path in root.rglob("extension-artifacts.json") if path.is_file()) + else: + continue + for manifest in candidates: + resolved = manifest.resolve() + if resolved in seen_paths: + continue + seen_paths.add(resolved) + manifests.setdefault(extension_manifest_identity(manifest), manifest) + return list(manifests.values()) + + +def extension_manifest_identity(manifest: Path) -> tuple[str, ...]: + try: + data = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return ("path", str(manifest.resolve())) + product = data.get("product") + version = data.get("version") + sql_name = data.get("sqlName") + if all(isinstance(value, str) and value for value in [product, version, sql_name]): + return ("extension", str(product), str(version), str(sql_name)) + return ("path", str(manifest.resolve())) + + +def safe_package_path(package_name: str) -> str: + return package_name.replace("@", "").replace("/", "__") + + +def extension_release_manifest(extension_dir: Path, product: str, version: str) -> dict[str, Any]: + manifest_path = extension_dir / "release-assets" / f"{product}-{version}-manifest.json" + if not manifest_path.is_file(): + return {} + return json.loads(manifest_path.read_text(encoding="utf-8")) + + +def extension_runtime_asset( + extension_dir: Path, + manifest: dict[str, Any], + target: str, +) -> Path | None: + for asset in manifest.get("assets", []): + if ( + asset.get("family") == "native" + and asset.get("kind") == "runtime" + and asset.get("target") == target + and isinstance(asset.get("name"), str) + ): + path = extension_dir / "release-assets" / asset["name"] + if path.is_file(): + return path + return None + + +def extract_extension_runtime(asset: Path, runtime_dir: Path) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + with tarfile.open(asset, "r:gz") as archive: + for member in archive.getmembers(): + if not member.isfile() or not member.name.startswith("files/"): + continue + relative = Path(member.name.removeprefix("files/")) + if relative.is_absolute() or ".." in relative.parts: + raise RuntimeError(f"{rel(asset)} contains unsafe path {member.name!r}") + target = runtime_dir / relative + target.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + continue + with source, target.open("wb") as output: + shutil.copyfileobj(source, output) + + +def extension_module_directory(runtime_dir: Path) -> Path | None: + postgres_lib = runtime_dir / "lib" / "postgresql" + if not postgres_lib.is_dir(): + return None + for path in sorted(postgres_lib.iterdir()): + if path.is_file() and path.suffix.lower() in {".so", ".dylib", ".dll"}: + return postgres_lib + return None + + +def strip_extension_modules(runtime_dir: Path, target: str) -> None: + module_dir = extension_module_directory(runtime_dir) + if module_dir is None or not target.startswith("linux-"): + return + strip = shutil.which("strip") + if strip is None: + return + for path in sorted(module_dir.iterdir()): + if path.is_file() and path.suffix == ".so": + run([strip, "--strip-unneeded", str(path)], check=False) + + +def write_extension_readme(package_dir: Path, package_name: str, sql_name: str, target: str | None) -> None: + target_text = f" for `{target}`" if target else "" + package_dir.joinpath("README.md").write_text( + "\n".join( + [ + f"# {package_name}", + "", + f"Oliphaunt registry package for the `{sql_name}` PostgreSQL extension{target_text}.", + "", + "This package is consumed by `@oliphaunt/ts` when an application opens a database with", + f"`extensions: ['{sql_name}']`.", + "", + ] + ), + encoding="utf-8", + ) + + +def write_extension_meta_package( + package_dir: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, +) -> None: + package_name = extension_npm_package(sql_name) + target_package = extension_npm_target_package(sql_name, target) + package_dir.mkdir(parents=True, exist_ok=True) + write_extension_readme(package_dir, package_name, sql_name, None) + package_dir.joinpath("package.json").write_text( + json.dumps( + { + "name": package_name, + "version": version, + "description": f"Oliphaunt extension package for PostgreSQL {sql_name}.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "optionalDependencies": {target_package: version}, + "oliphaunt": { + "product": product, + "kind": "exact-extension", + "sqlName": sql_name, + "targetPackageNames": {target: target_package}, + }, + "publishConfig": {"access": "public", "provenance": False}, + "files": ["README.md"], + "exports": {"./package.json": "./package.json"}, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def write_extension_target_package( + package_dir: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, + liboliphaunt_version: str, + payload_package_names: list[str], +) -> None: + package_name = extension_npm_target_package(sql_name, target) + package_dir.mkdir(parents=True, exist_ok=True) + write_extension_readme(package_dir, package_name, sql_name, target) + + package_json = { + "name": package_name, + "version": version, + "description": f"{target} Oliphaunt extension package selector for PostgreSQL {sql_name}.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + **npm_platform_constraints(target), + "optional": True, + "optionalDependencies": {name: version for name in payload_package_names}, + "oliphaunt": { + "product": product, + "kind": "exact-extension-target", + "sqlName": sql_name, + "target": target, + "liboliphauntVersion": liboliphaunt_version, + "payloadPackageNames": payload_package_names, + }, + "publishConfig": {"access": "public", "provenance": False}, + "files": ["README.md"], + "exports": {"./package.json": "./package.json"}, + } + package_dir.joinpath("package.json").write_text( + json.dumps(package_json, indent=2) + "\n", + encoding="utf-8", + ) + + +def copy_runtime_entries(runtime_dir: Path, payload_runtime_dir: Path, entries: list[Path]) -> None: + for entry in entries: + relative = entry.relative_to(runtime_dir) + target = payload_runtime_dir / relative + if entry.is_dir(): + shutil.copytree(entry, target, dirs_exist_ok=True) + elif entry.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(entry, target) + + +def write_extension_payload_package( + package_dir: Path, + *, + package_name: str, + product: str, + version: str, + sql_name: str, + target: str, + liboliphaunt_version: str, +) -> None: + runtime_dir = package_dir / "runtime" + module_dir = extension_module_directory(runtime_dir) + write_extension_readme(package_dir, package_name, sql_name, target) + oliphaunt: dict[str, Any] = { + "product": product, + "kind": "exact-extension-payload", + "sqlName": sql_name, + "target": target, + "runtimeRelativePath": "runtime", + "liboliphauntVersion": liboliphaunt_version, + } + if module_dir is not None: + oliphaunt["moduleRelativePath"] = module_dir.relative_to(package_dir).as_posix() + package_json = { + "name": package_name, + "version": version, + "description": f"{target} Oliphaunt extension runtime payload for PostgreSQL {sql_name}.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + **npm_platform_constraints(target), + "optional": True, + "oliphaunt": oliphaunt, + "publishConfig": {"access": "public", "provenance": False}, + "files": ["runtime", "README.md"], + "exports": {"./package.json": "./package.json"}, + } + package_dir.joinpath("package.json").write_text( + json.dumps(package_json, indent=2) + "\n", + encoding="utf-8", + ) + + +def pack_extension_package(package_dir: Path, tarball_dir: Path) -> Path: + tarball_dir.mkdir(parents=True, exist_ok=True) + completed = run( + [ + "npm", + "pack", + str(package_dir), + "--pack-destination", + str(tarball_dir), + "--loglevel=error", + ], + capture=True, + ) + filename = completed.stdout.strip().splitlines()[-1] + return tarball_dir / filename + + +def npm_package_size_ok(tarball: Path, result: SurfaceResult) -> bool: + size = tarball.stat().st_size + if size <= NPM_PACKAGE_SIZE_LIMIT_BYTES: + return True + result.add_skip( + f"{rel(tarball)} is {size} bytes, exceeding the 10 MiB npm package limit", + ) + tarball.unlink(missing_ok=True) + return False + + +def stage_extension_payload_group( + *, + runtime_dir: Path, + entries: list[Path], + package_root: Path, + tarball_root: Path, + product: str, + version: str, + sql_name: str, + target: str, + liboliphaunt_version: str, + payload_index: int, + result: SurfaceResult, +) -> tuple[list[str], list[Path]]: + package_name = extension_npm_payload_package(sql_name, target, payload_index) + package_dir = package_root / safe_package_path(package_name) + shutil.rmtree(package_dir, ignore_errors=True) + payload_runtime_dir = package_dir / "runtime" + payload_runtime_dir.mkdir(parents=True, exist_ok=True) + copy_runtime_entries(runtime_dir, payload_runtime_dir, entries) + write_extension_payload_package( + package_dir, + package_name=package_name, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + ) + tarball = pack_extension_package(package_dir, tarball_root) + if tarball.stat().st_size <= NPM_PACKAGE_SIZE_LIMIT_BYTES: + return [package_name], [tarball] + + tarball.unlink(missing_ok=True) + shutil.rmtree(package_dir, ignore_errors=True) + if len(entries) == 1 and entries[0].is_dir(): + child_entries = sorted(entries[0].iterdir()) + if child_entries: + return stage_extension_payload_groups( + runtime_dir=runtime_dir, + groups=[[entry] for entry in child_entries], + package_root=package_root, + tarball_root=tarball_root, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + start_index=payload_index, + result=result, + ) + if len(entries) > 1: + return stage_extension_payload_groups( + runtime_dir=runtime_dir, + groups=[[entry] for entry in entries], + package_root=package_root, + tarball_root=tarball_root, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + start_index=payload_index, + result=result, + ) + + result.add_skip( + f"{package_name} cannot be split below the 10 MiB npm package limit; largest entry is {entries[0]}", + ) + return [], [] + + +def stage_extension_payload_groups( + *, + runtime_dir: Path, + groups: list[list[Path]], + package_root: Path, + tarball_root: Path, + product: str, + version: str, + sql_name: str, + target: str, + liboliphaunt_version: str, + start_index: int, + result: SurfaceResult, +) -> tuple[list[str], list[Path]]: + package_names: list[str] = [] + tarballs: list[Path] = [] + payload_index = start_index + for entries in groups: + names, paths = stage_extension_payload_group( + runtime_dir=runtime_dir, + entries=entries, + package_root=package_root, + tarball_root=tarball_root, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + payload_index=payload_index, + result=result, + ) + if not names: + continue + package_names.extend(names) + tarballs.extend(paths) + payload_index += len(names) + return package_names, tarballs + + +def stage_extension_payload_packages( + *, + runtime_dir: Path, + package_root: Path, + tarball_root: Path, + product: str, + version: str, + sql_name: str, + target: str, + liboliphaunt_version: str, + result: SurfaceResult, +) -> tuple[list[str], list[Path]]: + entries = sorted(runtime_dir.iterdir()) + return stage_extension_payload_groups( + runtime_dir=runtime_dir, + groups=[[entry] for entry in entries], + package_root=package_root, + tarball_root=tarball_root, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + start_index=0, + result=result, + ) + + +def stage_extension_npm_packages( + roots: list[Path], + staging_root: Path, + target: str | None, + dry_run: bool, + result: SurfaceResult, +) -> Path | None: + manifests = discover_extension_manifests(roots) + if not manifests: + result.add_skip("no extension-artifacts.json manifests found for npm extension packages") + return None + if target is None: + result.add_skip("current host does not map to a supported npm extension target") + return None + + if dry_run: + for manifest_path in manifests: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + sql_name = manifest.get("sqlName") + version = manifest.get("version") + if isinstance(sql_name, str) and isinstance(version, str): + result.staged.append( + f"dry-run npm extension packages {extension_npm_package(sql_name)}@{version} ({target})", + ) + return None + + shutil.rmtree(staging_root, ignore_errors=True) + package_root = staging_root / "packages" + tarball_root = staging_root / "tarballs" + work_root = staging_root / "work" + staged_any = False + for manifest_path in manifests: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + extension_dir = manifest_path.parent + product = manifest.get("product") + version = manifest.get("version") + sql_name = manifest.get("sqlName") + if not all(isinstance(value, str) and value for value in [product, version, sql_name]): + result.add_skip(f"{rel(manifest_path)} is missing product, version, or sqlName") + continue + release_manifest = extension_release_manifest(extension_dir, product, version) + asset = extension_runtime_asset(extension_dir, release_manifest or manifest, target) + if asset is None: + result.add_skip(f"{product}@{version} has no {target} native runtime asset") + continue + compatibility = release_manifest.get("compatibility", {}) + liboliphaunt_version = compatibility.get("nativeRuntimeVersion", version) + if not isinstance(liboliphaunt_version, str) or not liboliphaunt_version: + result.add_skip(f"{product}@{version} is missing native runtime compatibility") + continue + + meta_dir = package_root / safe_package_path(extension_npm_package(sql_name)) + target_dir = package_root / safe_package_path(extension_npm_target_package(sql_name, target)) + runtime_work_dir = work_root / safe_package_path(extension_npm_target_package(sql_name, target)) / "runtime" + extract_extension_runtime(asset, runtime_work_dir) + strip_extension_modules(runtime_work_dir, target) + payload_package_names, payload_tarballs = stage_extension_payload_packages( + runtime_dir=runtime_work_dir, + package_root=package_root, + tarball_root=tarball_root, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + result=result, + ) + if not payload_package_names: + continue + write_extension_meta_package( + meta_dir, + product=product, + version=version, + sql_name=sql_name, + target=target, + ) + write_extension_target_package( + target_dir, + product=product, + version=version, + sql_name=sql_name, + target=target, + liboliphaunt_version=liboliphaunt_version, + payload_package_names=payload_package_names, + ) + target_tarball = pack_extension_package(target_dir, tarball_root) + if not npm_package_size_ok(target_tarball, result): + for tarball in payload_tarballs: + tarball.unlink(missing_ok=True) + continue + meta_tarball = pack_extension_package(meta_dir, tarball_root) + if not npm_package_size_ok(meta_tarball, result): + target_tarball.unlink(missing_ok=True) + for tarball in payload_tarballs: + tarball.unlink(missing_ok=True) + continue + for tarball in payload_tarballs: + result.staged.append(rel(tarball)) + result.staged.append(rel(target_tarball)) + result.staged.append(rel(meta_tarball)) + staged_any = True + + return tarball_root if staged_any else None + + +def write_verdaccio_config(root: Path, port: int) -> tuple[Path, bool]: + root = root.resolve() + config = root / "config.yaml" + storage = root / "storage" + storage.mkdir(parents=True, exist_ok=True) + (root / "plugins").mkdir(parents=True, exist_ok=True) + text = "\n".join( + [ + f"storage: {storage}", + "max_body_size: 100mb", + "auth:", + " htpasswd:", + f" file: {root / '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}", + "", + ] + ) + previous = config.read_text(encoding="utf-8") if config.exists() else None + config.write_text(text, encoding="utf-8") + (root / "registry-url.txt").write_text(f"http://127.0.0.1:{port}\n", encoding="utf-8") + return config, previous != text + + +def npm_auth_is_valid(registry_url: str, npmrc: Path) -> bool: + completed = run( + [ + "npm", + "whoami", + "--registry", + registry_url, + "--userconfig", + str(npmrc), + "--loglevel=error", + ], + check=False, + capture=True, + timeout=10, + ) + return completed.returncode == 0 + + +def stop_recorded_verdaccio(root: Path) -> None: + pid_file = root / "verdaccio.pid" + if not pid_file.is_file(): + return + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except ValueError: + pid_file.unlink(missing_ok=True) + return + try: + os.kill(pid, 15) + except ProcessLookupError: + pid_file.unlink(missing_ok=True) + return + for _ in range(30): + try: + os.kill(pid, 0) + except ProcessLookupError: + pid_file.unlink(missing_ok=True) + return + time.sleep(0.1) + try: + os.kill(pid, 9) + except ProcessLookupError: + pass + pid_file.unlink(missing_ok=True) + + +def npm_ping(registry_url: str) -> bool: + if not shutil.which("npm"): + return False + try: + result = run( + [ + "npm", + "ping", + "--registry", + registry_url, + "--fetch-timeout=1000", + "--fetch-retries=0", + ], + check=False, + capture=True, + timeout=3, + ) + return result.returncode == 0 + except subprocess.TimeoutExpired: + return False + + +def ensure_verdaccio(root: Path, port: int, dry_run: bool) -> str: + registry_url = f"http://127.0.0.1:{port}" + config, changed = write_verdaccio_config(root, port) + if changed and not dry_run: + stop_recorded_verdaccio(root) + if npm_ping(registry_url): + return registry_url + if dry_run: + return registry_url + + if not shutil.which("pnpm"): + raise RuntimeError("pnpm is required to start Verdaccio") + log_path = root / "verdaccio.log" + log = log_path.open("a", encoding="utf-8") + process = subprocess.Popen( + [ + "pnpm", + "dlx", + "verdaccio@6", + "--config", + str(config), + "--listen", + registry_url, + ], + cwd=ROOT, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + (root / "verdaccio.pid").write_text(f"{process.pid}\n", encoding="utf-8") + for _ in range(60): + if npm_ping(registry_url): + return registry_url + if process.poll() is not None: + raise RuntimeError(f"Verdaccio exited early; see {rel(log_path)}") + time.sleep(1) + raise RuntimeError(f"Timed out waiting for Verdaccio; see {rel(log_path)}") + + +def ensure_verdaccio_npmrc(root: Path, registry_url: str, dry_run: bool) -> Path | None: + if dry_run: + return None + npmrc = root / "npmrc" + if npmrc.is_file(): + text = npmrc.read_text(encoding="utf-8") + if "always-auth" in text: + npmrc.write_text( + "\n".join(line for line in text.splitlines() if not line.startswith("always-auth=")) + "\n", + encoding="utf-8", + ) + if npm_auth_is_valid(registry_url, npmrc): + return npmrc + npmrc.unlink() + username = "oliphaunt-local" + password = "oliphaunt-local" + payload = json.dumps( + { + "name": username, + "password": password, + "email": "local-registry@oliphaunt.invalid", + "type": "user", + "roles": [], + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + } + ).encode("utf-8") + request = urllib.request.Request( + f"{registry_url}/-/user/org.couchdb.user:{username}", + data=payload, + method="PUT", + headers={"content-type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"failed to create local Verdaccio user: HTTP {error.code}: {body}") from error + token = data.get("token") + if not isinstance(token, str) or not token: + raise RuntimeError("Verdaccio did not return an auth token for the local user") + host = registry_url.removeprefix("http://").removeprefix("https://") + npmrc.write_text( + "\n".join( + [ + f"registry={registry_url}/", + f"//{host}/:_authToken={token}", + "", + ] + ), + encoding="utf-8", + ) + return npmrc + + +def npm_package_identity(tarball: Path) -> tuple[str, str] | None: + try: + with tarfile.open(tarball, "r:gz") as archive: + for member in archive.getmembers(): + if member.isfile() and member.name.endswith("/package.json"): + source = archive.extractfile(member) + if source is None: + continue + with source: + package_json = json.loads(source.read().decode("utf-8")) + name = package_json.get("name") + version = package_json.get("version") + if isinstance(name, str) and isinstance(version, str): + return name, version + except (tarfile.TarError, json.JSONDecodeError): + return None + return None + + +def npm_package_exists( + registry_url: str, + npmrc: Path | None, + name: str, + version: str, +) -> bool: + command = [ + "npm", + "view", + f"{name}@{version}", + "version", + "--registry", + registry_url, + "--fetch-retries=0", + "--loglevel=error", + ] + if npmrc is not None: + command.extend(["--userconfig", str(npmrc)]) + completed = run(command, check=False, capture=True, timeout=10) + return completed.returncode == 0 and completed.stdout.strip() == version + + +def npm_tarball_priority(path: Path, registry_root: Path) -> tuple[int, float, str]: + resolved = path.resolve() + priority = 20 + for root, value in [ + (ROOT / "target" / "release" / "npm-packages", 100), + (ROOT / "target" / "sdk-artifacts", 90), + (registry_root / "npm-extension-packages", 80), + (DEFAULT_CURRENT_ARTIFACT_ROOT, 60), + (DEFAULT_ARTIFACT_ROOT, 30), + ]: + try: + resolved.relative_to(root.resolve()) + except ValueError: + continue + priority = value + break + try: + modified = path.stat().st_mtime + except OSError: + modified = 0 + return priority, modified, str(path) + + +def select_npm_tarballs(tarballs: list[Path], registry_root: Path, result: SurfaceResult) -> list[Path]: + selected: dict[tuple[str, str], Path] = {} + unidentified: list[Path] = [] + for tarball in tarballs: + identity = npm_package_identity(tarball) + if identity is None: + unidentified.append(tarball) + continue + current = selected.get(identity) + if current is None: + selected[identity] = tarball + continue + if npm_tarball_priority(tarball, registry_root) > npm_tarball_priority(current, registry_root): + selected[identity] = tarball + result.staged.append( + f"preferred {rel(tarball)} over {rel(current)} for {identity[0]}@{identity[1]}" + ) + else: + result.staged.append( + f"preferred {rel(current)} over {rel(tarball)} for {identity[0]}@{identity[1]}" + ) + return sorted([*unidentified, *selected.values()]) + + +def stage_release_asset_npm_packages( + roots: list[Path], + registry_root: Path, + dry_run: bool, + result: SurfaceResult, + strict: bool, +) -> list[Path]: + if dry_run: + result.staged.append("dry-run generated liboliphaunt and broker npm artifact packages") + return [] + + sys.path.insert(0, str(ROOT / "tools" / "release")) + import release # type: ignore + + tarballs: list[Path] = [] + target = host_npm_target() + targets = {target} if target is not None else None + + lib_asset_dir = ROOT / "target" / "liboliphaunt" / "release-assets" + lib_version = release.current_product_version("liboliphaunt-native") + lib_patterns = (f"liboliphaunt-{lib_version}-*", f"oliphaunt-tools-{lib_version}-*") + copied_lib = ( + [] + if target is None + else copy_release_asset_set(roots, lib_asset_dir, native_npm_release_asset_names(lib_version, target)) + ) + if copied_lib or (release_asset_dir_selected(roots, lib_asset_dir) and release.liboliphaunt_release_assets_ready()): + if target is None: + result.add_skip("current host does not map to a supported native npm artifact target") + else: + ready, missing = native_npm_release_assets_ready(lib_asset_dir, lib_version, target) + if ready: + if copied_lib: + result.staged.append(f"staged {len(copied_lib)} liboliphaunt release asset(s)") + tarballs.extend( + path + for _package_name, path in release.liboliphaunt_npm_tarballs( + lib_version, + validate_assets=False, + targets=targets, + ) + ) + else: + message = native_npm_release_asset_missing_message( + lib_asset_dir, + lib_version, + target, + missing, + ) + result.add_skip(message) + if strict: + raise RuntimeError(message) + else: + result.add_skip("no liboliphaunt release assets found for native npm artifact packages") + + broker_asset_dir = ROOT / "target" / "oliphaunt-broker" / "release-assets" + copied_broker = copy_release_assets( + roots, + broker_asset_dir, + ("oliphaunt-broker-*.tar.gz", "oliphaunt-broker-*.zip"), + ) + if copied_broker or ( + release_asset_dir_selected(roots, broker_asset_dir) + and (any(broker_asset_dir.glob("oliphaunt-broker-*.tar.gz")) or any(broker_asset_dir.glob("oliphaunt-broker-*.zip"))) + ): + if copied_broker: + result.staged.append(f"staged {len(copied_broker)} broker release asset(s)") + version = release.current_product_version("oliphaunt-broker") + tarballs.extend( + path + for _package_name, path in release.broker_npm_tarballs( + version, + validate_assets=False, + targets=targets, + ) + ) + else: + result.add_skip("no broker release assets found for broker npm artifact packages") + + if tarballs: + result.staged.append(f"generated {len(tarballs)} release-asset npm package(s)") + return tarballs + + +def publish_npm(roots: list[Path], registry_root: Path, dry_run: bool, strict: bool, port: int) -> SurfaceResult: + result = SurfaceResult("npm") + generated_tarballs = stage_release_asset_npm_packages(roots, registry_root, dry_run, result, strict) + extension_target = host_npm_target() + extension_tarball_root = stage_extension_npm_packages( + roots, + registry_root / "npm-extension-packages", + extension_target, + dry_run, + result, + ) + if extension_tarball_root is not None: + roots = [*roots, extension_tarball_root] + tarballs = select_npm_tarballs([*discover_files(roots, (".tgz",)), *generated_tarballs], registry_root, result) + if not tarballs: + result.add_skip("no npm .tgz artifacts found") + if strict: + raise RuntimeError(result.skipped[-1]) + return result + + verdaccio_root = registry_root / "verdaccio" + registry_url = ensure_verdaccio(verdaccio_root, port, dry_run) + npmrc = ensure_verdaccio_npmrc(verdaccio_root, registry_url, dry_run) + result.staged.append(f"verdaccio={registry_url}") + for tarball in tarballs: + identity = npm_package_identity(tarball) + if dry_run: + label = rel(tarball) if identity is None else f"{identity[0]}@{identity[1]}" + result.published.append(f"dry-run npm publish {label}") + continue + if identity is not None and npm_package_exists(registry_url, npmrc, identity[0], identity[1]): + command = [ + "npm", + "unpublish", + f"{identity[0]}@{identity[1]}", + "--registry", + registry_url, + "--force", + "--loglevel=error", + ] + if npmrc is not None: + command.extend(["--userconfig", str(npmrc)]) + run(command) + result.staged.append(f"replaced {identity[0]}@{identity[1]}") + command = [ + "npm", + "publish", + str(tarball), + "--registry", + registry_url, + "--provenance=false", + "--ignore-scripts", + "--access", + "public", + "--loglevel=error", + ] + if npmrc is not None: + command.extend(["--userconfig", str(npmrc)]) + run(command) + result.published.append(rel(tarball)) + pnpm_store = registry_root / "pnpm-store" + shutil.rmtree(pnpm_store, ignore_errors=True) + result.staged.append(f"cleared local pnpm store {rel(pnpm_store)}") + return result + + +def read_cargo_package_name_version(manifest: Path) -> tuple[str, str]: + data = tomllib.loads(manifest.read_text(encoding="utf-8")) + package = data.get("package") + if not isinstance(package, dict): + raise RuntimeError(f"{rel(manifest)} is missing [package]") + name = package.get("name") + version = package.get("version") + if not isinstance(name, str) or not isinstance(version, str) or not name or not version: + raise RuntimeError(f"{rel(manifest)} must declare package name and version") + return name, version + + +def packaged_cargo_manifest_text(text: str) -> str: + text = text.replace( + "repository.workspace = true", + 'repository = "https://github.com/f0rr0/oliphaunt"', + ).replace( + "homepage.workspace = true", + 'homepage = "https://oliphaunt.dev"', + ) + text = re.sub(r', path = "[^"]+"', "", text) + if "\n[workspace]" not in text: + text = text.rstrip() + "\n\n[workspace]\n" + return text + + +def cargo_package_name_from_crate(crate_path: Path) -> str | None: + try: + with tarfile.open(crate_path, "r:gz") as archive: + manifests = [ + member + for member in archive.getmembers() + if member.isfile() and member.name.count("/") == 1 and member.name.endswith("/Cargo.toml") + ] + if not manifests: + return None + extracted = archive.extractfile(manifests[0]) + if extracted is None: + return None + data = tomllib.loads(extracted.read().decode("utf-8")) + except (tarfile.TarError, tomllib.TOMLDecodeError, UnicodeDecodeError, OSError): + return None + package = data.get("package") + if not isinstance(package, dict): + return None + name = package.get("name") + return name if isinstance(name, str) and name else None + + +def cargo_package_names_from_roots(roots: list[Path]) -> set[str]: + names: set[str] = set() + for crate_path in discover_files(roots, (".crate",)): + name = cargo_package_name_from_crate(crate_path) + if name is not None: + names.add(name) + return names + + +def cargo_dependency_name_matches_host_target(name: str) -> bool: + host_target = host_cargo_release_target() + if host_target is None: + return True + host_triple = cargo_target_triple(host_target) + host_markers = [host_target] + if host_triple is not None: + host_markers.append(host_triple) + return any( + name.endswith(f"-{marker}") + or f"-{marker}-" in name + or f"-aot-{marker}" in name + for marker in host_markers + ) + + +def prune_missing_local_artifact_target_dependencies( + manifest: Path, + available_package_names: set[str], + result: SurfaceResult, + *, + strict: bool, +) -> None: + text = manifest.read_text(encoding="utf-8") + lines = text.splitlines() + output: list[str] = [] + removed: list[tuple[str, list[str]]] = [] + index = 0 + while index < len(lines): + line = lines[index] + if not re.match(r"^\[target\..*\.dependencies\]$", line): + output.append(line) + index += 1 + continue + + block = [line] + index += 1 + while index < len(lines) and not re.match(r"^\[[^\]]+\]$", lines[index]): + block.append(lines[index]) + index += 1 + + dependency_names = [] + for block_line in block[1:]: + match = re.match(r"^([A-Za-z0-9_-]+)\s*=", block_line) + if match: + dependency_names.append(match.group(1)) + missing = sorted(name for name in dependency_names if name not in available_package_names) + if missing: + removed.append((line, missing)) + while output and output[-1] == "": + output.pop() + continue + if output and output[-1] != "": + output.append("") + output.extend(block) + + if not removed: + return + missing_packages = sorted({package for _header, missing in removed for package in missing}) + if strict: + host_missing_packages = sorted( + package for package in missing_packages if cargo_dependency_name_matches_host_target(package) + ) + if not host_missing_packages: + strict = False + else: + raise RuntimeError( + f"{rel(manifest)} is missing local registry inputs for host target artifact dependencies: " + + ", ".join(host_missing_packages) + ) + pruned_text = prune_missing_feature_dependencies( + "\n".join(output).rstrip() + "\n", + set(missing_packages), + ) + manifest.write_text(pruned_text, encoding="utf-8") + for header, missing in removed: + result.add_skip( + f"{rel(manifest)} pruned {header} because local registry inputs are missing {', '.join(missing)}" + ) + + +def prune_missing_feature_dependencies(text: str, missing_package_names: set[str]) -> str: + if not missing_package_names: + return text + lines = text.splitlines() + output: list[str] = [] + in_features = False + index = 0 + while index < len(lines): + line = lines[index] + if re.match(r"^\[features\]$", line): + in_features = True + output.append(line) + index += 1 + continue + if line.startswith("[") and not line.startswith("[["): + in_features = False + output.append(line) + index += 1 + continue + if not in_features: + output.append(line) + index += 1 + continue + + match = re.match(r"^([A-Za-z0-9_-]+)\s*=", line) + if match is None: + output.append(line) + index += 1 + continue + feature_name = match.group(1) + block = [line] + index += 1 + bracket_depth = line.count("[") - line.count("]") + while bracket_depth > 0 and index < len(lines): + block.append(lines[index]) + bracket_depth += lines[index].count("[") - lines[index].count("]") + index += 1 + feature_text = "[features]\n" + "\n".join(block) + "\n" + try: + values = tomllib.loads(feature_text)["features"][feature_name] + except (KeyError, tomllib.TOMLDecodeError): + output.extend(block) + continue + if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + output.extend(block) + continue + filtered = [ + value + for value in values + if not (value.startswith("dep:") and value.removeprefix("dep:") in missing_package_names) + ] + if filtered == values: + output.extend(block) + continue + output.append(f"{feature_name} = [{', '.join(json.dumps(value) for value in filtered)}]") + return "\n".join(output).rstrip() + "\n" + + +def cargo_metadata_package_from_manifest(manifest: Path) -> dict[str, Any]: + completed = run( + [ + "cargo", + "metadata", + "--manifest-path", + str(manifest), + "--format-version", + "1", + "--no-deps", + ], + check=False, + capture=True, + ) + if completed.returncode != 0: + raise RuntimeError( + f"cargo metadata failed for {rel(manifest)}: {completed.stderr.strip()}" + ) + packages = json.loads(completed.stdout).get("packages") + if not isinstance(packages, list) or len(packages) != 1: + raise RuntimeError(f"cargo metadata for {rel(manifest)} did not return exactly one package") + package = packages[0] + if not isinstance(package, dict): + raise RuntimeError(f"cargo metadata for {rel(manifest)} returned an invalid package") + return package + + +def manual_cargo_package_source(manifest: Path, output_dir: Path) -> Path: + name, version = read_cargo_package_name_version(manifest) + source_dir = manifest.parent + package_root = f"{name}-{version}" + stage_root = output_dir / "manual-package-stage" + stage_dir = stage_root / package_root + crate_path = output_dir / f"{package_root}.crate" + shutil.rmtree(stage_dir, ignore_errors=True) + stage_dir.parent.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree( + source_dir, + stage_dir, + ignore=shutil.ignore_patterns("target", ".git", ".DS_Store"), + ) + staged_manifest = stage_dir / "Cargo.toml" + staged_manifest.write_text( + packaged_cargo_manifest_text(staged_manifest.read_text(encoding="utf-8")), + encoding="utf-8", + ) + package = cargo_metadata_package_from_manifest(staged_manifest) + if package.get("name") != name or package.get("version") != version: + raise RuntimeError(f"{rel(staged_manifest)} produced unexpected cargo metadata") + if crate_path.exists(): + crate_path.unlink() + with crate_path.open("wb") as raw_output: + with gzip.GzipFile(fileobj=raw_output, mode="wb", mtime=0) as gzip_output: + with tarfile.open(fileobj=gzip_output, mode="w") as archive: + for path in sorted(item for item in stage_dir.rglob("*") if item.is_file()): + arcname = f"{package_root}/{path.relative_to(stage_dir).as_posix()}" + info = archive.gettarinfo(path, arcname) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + with path.open("rb") as handle: + archive.addfile(info, handle) + size = crate_path.stat().st_size + if size > CARGO_PACKAGE_SIZE_LIMIT_BYTES: + raise RuntimeError(f"{rel(crate_path)} is {size} bytes, above the crates.io 10 MiB package limit") + return crate_path + + +def stage_cargo_source_crates( + roots: list[Path], + registry_root: Path, + dry_run: bool, + result: SurfaceResult, + strict: bool, +) -> list[Path]: + output_dir = registry_root / "cargo-generated" / "source-crates" + if dry_run: + result.staged.append("dry-run generated local Cargo source crates") + return [] + shutil.rmtree(output_dir, ignore_errors=True) + output_dir.mkdir(parents=True, exist_ok=True) + + generated: list[Path] = [] + build_manifest = ROOT / "src/sdks/rust/crates/oliphaunt-build/Cargo.toml" + generated.append(manual_cargo_package_source(build_manifest, output_dir)) + + sys.path.insert(0, str(ROOT / "tools/release")) + import release # type: ignore + + oliphaunt_manifest = release.prepare_oliphaunt_release_source( + release.current_product_version("oliphaunt-rust") + ) + available_package_names = cargo_package_names_from_roots(roots) + native_source_root = ROOT / "target/liboliphaunt/cargo-package-sources" + native_runtime_public_manifests = native_runtime_artifact_manifests(native_source_root) + native_runtime_all_manifests = native_runtime_artifact_manifests( + native_source_root, + include_parts=True, + ) + for manifest in native_runtime_public_manifests: + name, _version = read_cargo_package_name_version(manifest) + available_package_names.add(name) + prune_missing_local_artifact_target_dependencies( + oliphaunt_manifest, + available_package_names, + result, + strict=strict, + ) + generated.append(manual_cargo_package_source(oliphaunt_manifest, output_dir)) + + wasix_manifest = release.prepare_oliphaunt_wasix_release_source( + release.current_product_version("oliphaunt-wasix-rust") + ) + prune_missing_local_artifact_target_dependencies( + wasix_manifest, + available_package_names, + result, + strict=strict, + ) + generated.append(manual_cargo_package_source(wasix_manifest, output_dir)) + + for manifest in native_runtime_all_manifests: + generated.append(manual_cargo_package_source(manifest, output_dir)) + + result.staged.extend(rel(path) for path in generated) + return generated + + +def native_runtime_artifact_manifests(source_root: Path, *, include_parts: bool = False) -> list[Path]: + if not source_root.is_dir(): + return [] + manifests = [ + *source_root.glob("liboliphaunt-native-*/Cargo.toml"), + source_root / "oliphaunt-tools" / "Cargo.toml", + *source_root.glob("oliphaunt-tools-*/Cargo.toml"), + ] + result: list[Path] = [] + seen: set[Path] = set() + for manifest in sorted(manifests): + if not manifest.is_file(): + continue + if manifest in seen: + continue + seen.add(manifest) + name, _version = read_cargo_package_name_version(manifest) + if "-part-" in name and not include_parts: + continue + result.append(manifest) + return result + + +def native_extension_cargo_package_name(product: str, target: str) -> str: + return f"{product}-{target}" + + +def native_extension_cargo_links_name(product: str, target: str) -> str: + stem = f"extension_{product.removeprefix('oliphaunt-extension-')}_{target}" + return "oliphaunt_artifact_" + stem.replace("-", "_") + + +def native_extension_cargo_part_package_name(product: str, target: str, index: int) -> str: + return f"{native_extension_cargo_package_name(product, target)}-part-{index:03d}" + + +def rust_crate_ident(crate_name: str) -> str: + return crate_name.replace("-", "_") + + +def toml_string(value: str) -> str: + return json.dumps(value) + + +def payload_files(source_root: Path) -> list[Path]: + return sorted(path for path in source_root.rglob("*") if path.is_file()) + + +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 write_native_extension_cargo_part_crate( + crate_dir: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, + index: int, +) -> None: + name = native_extension_cargo_part_package_name(product, target, index) + (crate_dir / "src").mkdir(parents=True, exist_ok=True) + (crate_dir / "Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{name}"', + f'version = "{version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Cargo payload part {index:03d} for the {sql_name} 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]", + "", + ] + ), + encoding="utf-8", + ) + (crate_dir / "README.md").write_text( + "\n".join( + [ + f"# {name}", + "", + f"Cargo payload part for the `{sql_name}` Oliphaunt native extension on `{target}`.", + "Applications do not depend on this crate directly.", + "", + ] + ), + encoding="utf-8", + ) + (crate_dir / "src" / "lib.rs").write_text( + "\n".join( + [ + f'pub const PRODUCT: &str = "{product}";', + 'pub const KIND: &str = "extension-part";', + f'pub const SQL_NAME: &str = "{sql_name}";', + f'pub const RELEASE_TARGET: &str = "{target}";', + f"pub const PART_INDEX: usize = {index};", + 'pub const PAYLOAD_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/payload");', + "", + ] + ), + encoding="utf-8", + ) + + +def build_native_extension_part_crates( + runtime_dir: Path, + source_root: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, + part_bytes: int = CARGO_EXTENSION_PART_BYTES, +) -> list[Path]: + part_dirs: list[Path] = [] + current_dir: Path | None = None + current_size = 0 + + def start_part() -> Path: + index = len(part_dirs) + part_dir = source_root / native_extension_cargo_part_package_name(product, target, index) + write_native_extension_cargo_part_crate( + part_dir, + product=product, + version=version, + sql_name=sql_name, + target=target, + index=index, + ) + part_dirs.append(part_dir) + return part_dir + + for source in payload_files(runtime_dir): + relative = source.relative_to(runtime_dir).as_posix() + size = source.stat().st_size + if size > part_bytes: + current_dir = None + current_size = 0 + with source.open("rb") as handle: + chunk_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{chunk_index:03d}", + data, + ) + chunk_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: + raise RuntimeError(f"{product}@{version} generated no native extension Cargo part crates") + return part_dirs + + +NATIVE_EXTENSION_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 = 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('\\', "/"); + 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('\\', "/"); + 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) +} +''' + + +def write_native_extension_split_aggregator_crate( + crate_dir: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, + triple: str, + part_dirs: list[Path], +) -> None: + name = native_extension_cargo_package_name(product, target) + links = native_extension_cargo_links_name(product, target) + shutil.rmtree(crate_dir / "payload", ignore_errors=True) + dependency_lines = [] + for index, part_dir in enumerate(part_dirs): + dependency_name = native_extension_cargo_part_package_name(product, target, index) + dependency_path = Path(os.path.relpath(part_dir, crate_dir)).as_posix() + dependency_lines.append( + f'{dependency_name} = {{ version = "={version}", path = "{dependency_path}" }}' + ) + part_roots = [ + f" {rust_crate_ident(native_extension_cargo_part_package_name(product, target, index))}::PAYLOAD_ROOT," + for index in range(len(part_dirs)) + ] + (crate_dir / "Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{name}"', + f'version = "{version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Cargo artifact crate for the {sql_name} 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"', + f'links = "{links}"', + 'build = "build.rs"', + 'include = ["Cargo.toml", "README.md", "build.rs", "src/**"]', + "", + "[lib]", + 'path = "src/lib.rs"', + "", + "[build-dependencies]", + 'sha2 = "0.10"', + *dependency_lines, + "", + "[workspace]", + "", + ] + ), + encoding="utf-8", + ) + build_rs = ( + NATIVE_EXTENSION_AGGREGATOR_BUILD_RS.replace( + "__SCHEMA__", toml_string("oliphaunt-artifact-manifest-v1") + ) + .replace("__PRODUCT__", toml_string(product)) + .replace("__TARGET__", toml_string(triple)) + .replace("__EXTENSION__", toml_string(sql_name)) + .replace("__PART_ROOTS__", "\n".join(part_roots)) + ) + (crate_dir / "build.rs").write_text(build_rs, encoding="utf-8") + + +def cargo_package(crate_dir: Path, target_dir: Path, *, no_verify: bool = False) -> Path: + name, version = read_cargo_package_name_version(crate_dir / "Cargo.toml") + command = [ + "cargo", + "package", + "--manifest-path", + str(crate_dir / "Cargo.toml"), + "--target-dir", + str(target_dir), + "--allow-dirty", + ] + if no_verify: + command.append("--no-verify") + run(command, env={**os.environ, "OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD": "1"}) + crate_path = target_dir / "package" / f"{name}-{version}.crate" + if not crate_path.is_file(): + raise RuntimeError(f"cargo package did not create {rel(crate_path)}") + return crate_path + + +def discard_cargo_package_artifact(crate_path: Path) -> None: + crate_path.unlink(missing_ok=True) + (crate_path.parent / "tmp-crate" / crate_path.name).unlink(missing_ok=True) + + +def write_native_extension_cargo_crate( + crate_dir: Path, + *, + product: str, + version: str, + sql_name: str, + target: str, + triple: str, + asset: Path, +) -> None: + name = native_extension_cargo_package_name(product, target) + links = native_extension_cargo_links_name(product, target) + runtime_dir = crate_dir / "payload" + extract_extension_runtime(asset, runtime_dir) + strip_extension_modules(runtime_dir, target) + if not any(runtime_dir.rglob("*")): + raise RuntimeError(f"{rel(asset)} did not contain extension runtime files") + (crate_dir / "src").mkdir(parents=True, exist_ok=True) + (crate_dir / "README.md").write_text( + "\n".join( + [ + f"# {name}", + "", + f"Cargo artifact crate for the `{sql_name}` Oliphaunt native extension on `{target}`.", + "", + ] + ), + encoding="utf-8", + ) + (crate_dir / "Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{name}"', + f'version = "{version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Cargo artifact crate for the {sql_name} 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"', + f'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]", + "", + ] + ), + encoding="utf-8", + ) + (crate_dir / "src/lib.rs").write_text( + "\n".join( + [ + f'pub const PRODUCT: &str = "{product}";', + 'pub const KIND: &str = "extension";', + f'pub const SQL_NAME: &str = "{sql_name}";', + f'pub const RELEASE_TARGET: &str = "{target}";', + f'pub const CARGO_TARGET: &str = "{triple}";', + "", + ] + ), + encoding="utf-8", + ) + (crate_dir / "build.rs").write_text( + f"""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.dumps(product)}; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const KIND: &str = "extension"; +const TARGET: &str = {json.dumps(triple)}; +const EXTENSION: &str = {json.dumps(sql_name)}; + +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"); + let sha256 = sha256_file(&file); + text.push_str(&format!( + "\\n[[files]]\\nsource = {{:?}}\\nrelative = {{:?}}\\nsha256 = {{sha256:?}}\\nexecutable = false\\n", + file.display().to_string(), + relative.to_string_lossy().replace('\\\\', "/"), + )); + }} + 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()) +}} +""", + encoding="utf-8", + ) + + +def package_native_extension_cargo_crates( + roots: list[Path], + staging_root: Path, + target: str | None, + dry_run: bool, + strict: bool, + result: SurfaceResult, +) -> list[Path]: + if target is None: + result.add_skip("current host does not map to a supported native extension Cargo target") + return [] + triple = cargo_target_triple(target) + if triple is None: + result.add_skip(f"unsupported native extension Cargo target {target}") + return [] + manifests = discover_extension_manifests(roots) + if not manifests: + result.add_skip("no extension-artifacts.json manifests found for native extension Cargo crates") + return [] + if dry_run: + result.staged.append(f"dry-run native extension Cargo crates for {target}") + return [] + + source_root = staging_root / "native-extension-sources" + output_dir = staging_root / "native-extension-crates" + cargo_target_dir = staging_root / "native-extension-cargo-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: list[Path] = [] + for manifest_path in manifests: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + product = manifest.get("product") + version = manifest.get("version") + sql_name = manifest.get("sqlName") + if not all(isinstance(value, str) and value for value in [product, version, sql_name]): + result.add_skip(f"{rel(manifest_path)} is missing product, version, or sqlName") + continue + release_manifest = extension_release_manifest(manifest_path.parent, str(product), str(version)) + asset = extension_runtime_asset(manifest_path.parent, release_manifest or manifest, target) + if asset is None: + result.add_skip(f"{product}@{version} has no {target} native runtime asset") + continue + name = native_extension_cargo_package_name(str(product), target) + crate_dir = source_root / name + write_native_extension_cargo_crate( + crate_dir, + product=str(product), + version=str(version), + sql_name=str(sql_name), + target=target, + triple=triple, + asset=asset, + ) + crate_path = cargo_package(crate_dir, cargo_target_dir) + size = crate_path.stat().st_size + if size > CARGO_EXTENSION_SPLIT_THRESHOLD_BYTES: + discard_cargo_package_artifact(crate_path) + part_dirs = build_native_extension_part_crates( + crate_dir / "payload", + source_root, + product=str(product), + version=str(version), + sql_name=str(sql_name), + target=target, + ) + write_native_extension_split_aggregator_crate( + crate_dir, + product=str(product), + version=str(version), + sql_name=str(sql_name), + target=target, + triple=triple, + part_dirs=part_dirs, + ) + part_failed = False + for part_dir in part_dirs: + part_crate_path = cargo_package(part_dir, cargo_target_dir) + part_size = part_crate_path.stat().st_size + if part_size > CARGO_PACKAGE_SIZE_LIMIT_BYTES: + message = ( + f"{rel(part_crate_path)} is {part_size} bytes, above the crates.io " + "10 MiB package limit" + ) + result.add_skip(message) + if strict: + raise RuntimeError(message) + part_failed = True + continue + output = output_dir / part_crate_path.name + shutil.copy2(part_crate_path, output) + outputs.append(output) + if part_failed: + continue + crate_path = manual_cargo_package_source( + crate_dir / "Cargo.toml", + cargo_target_dir / "manual-package", + ) + size = crate_path.stat().st_size + if size > CARGO_PACKAGE_SIZE_LIMIT_BYTES: + message = ( + f"{rel(crate_path)} is {size} bytes after splitting, above the crates.io " + "10 MiB package limit" + ) + result.add_skip(message) + if strict: + raise RuntimeError(message) + continue + output = output_dir / crate_path.name + shutil.copy2(crate_path, output) + outputs.append(output) + result.staged.extend(rel(path) for path in outputs) + return outputs + + +def crate_index_path(name: str) -> Path: + lower = name.lower() + if len(lower) == 1: + return Path("1") / lower + if len(lower) == 2: + return Path("2") / lower + if len(lower) == 3: + return Path("3") / lower[:1] / lower + return Path(lower[:2]) / lower[2:4] / lower + + +def cargo_metadata_for_crate(crate_path: Path) -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="oliphaunt-crate-") as temp: + temp_path = Path(temp) + with tarfile.open(crate_path, "r:gz") as archive: + archive.extractall(temp_path, filter="data") + manifests = sorted(temp_path.glob("*/Cargo.toml")) + if not manifests: + raise RuntimeError(f"{rel(crate_path)} does not contain Cargo.toml") + cargo_toml = tomllib.loads(manifests[0].read_text(encoding="utf-8")) + metadata = run( + [ + "cargo", + "metadata", + "--manifest-path", + str(manifests[0]), + "--format-version", + "1", + "--no-deps", + ], + capture=True, + ) + package = json.loads(metadata.stdout)["packages"][0] + package["_oliphaunt_links"] = cargo_toml.get("package", {}).get("links") + return package + + +def cargo_index_dependency(dep: dict[str, Any], local_package_names: set[str]) -> dict[str, Any]: + registry = dep.get("registry") + if dep["name"] in local_package_names: + registry = None + elif registry is None: + registry = CRATES_IO_INDEX + return { + "name": dep["name"], + "req": dep.get("req", "*"), + "features": dep.get("features") or [], + "optional": bool(dep.get("optional")), + "default_features": bool(dep.get("uses_default_features", dep.get("default_features", True))), + "target": dep.get("target"), + "kind": dep.get("kind") or "normal", + "registry": registry, + "package": dep.get("rename") or dep.get("package"), + } + + +def cargo_index_entry(crate_path: Path, package: dict[str, Any], local_package_names: set[str]) -> dict[str, Any]: + checksum = hashlib.sha256(crate_path.read_bytes()).hexdigest() + return { + "name": package["name"], + "vers": package["version"], + "deps": [ + cargo_index_dependency(dep, local_package_names) + for dep in package.get("dependencies", []) + ], + "features": package.get("features", {}), + "features2": None, + "cksum": checksum, + "yanked": False, + "links": package.get("_oliphaunt_links"), + "rust_version": package.get("rust_version"), + "v": 2, + } + + +def clear_local_cargo_home_cache(registry_root: Path) -> list[Path]: + cargo_home_registry = registry_root / "cargo-home" / "registry" + removed: list[Path] = [] + for name in ["cache", "src", "index"]: + path = cargo_home_registry / name + if path.exists(): + shutil.rmtree(path) + removed.append(path) + package_cache = cargo_home_registry / ".package-cache" + if package_cache.exists(): + package_cache.unlink() + removed.append(package_cache) + return removed + + +def cargo_crate_priority(path: Path, registry_root: Path) -> tuple[int, str]: + resolved = path.resolve() + priority = 20 + for root, value in [ + (registry_root / "cargo-generated", 100), + (ROOT / "target/oliphaunt-wasix/cargo-artifacts-check", 90), + (ROOT / "target/local-registry-generated", 80), + (ROOT / "target/oliphaunt-wasix/cargo-artifacts", 70), + (DEFAULT_CURRENT_ARTIFACT_ROOT, 60), + (ROOT / "target/package/tmp-registry", 40), + (ROOT / "target/package/tmp-crate", 30), + ]: + try: + resolved.relative_to(root.resolve()) + except ValueError: + continue + priority = value + break + return priority, str(path) + + +def is_default_cargo_tmp_crate_artifact(path: Path) -> bool: + try: + path.resolve().relative_to((ROOT / "target/package/tmp-crate").resolve()) + except ValueError: + return False + return True + + +def stage_release_asset_cargo_packages( + roots: list[Path], + registry_root: Path, + dry_run: bool, + result: SurfaceResult, + strict: bool, +) -> list[Path]: + if dry_run: + result.staged.append("dry-run generated release-asset Cargo artifact crates") + return [] + + sys.path.insert(0, str(ROOT / "tools" / "release")) + import release # type: ignore + + output_root = registry_root / "cargo-generated" / "release-asset-crates" + shutil.rmtree(output_root, ignore_errors=True) + output_root.mkdir(parents=True, exist_ok=True) + generated_roots: list[Path] = [] + host_target = host_cargo_release_target() + + lib_version = release.current_product_version("liboliphaunt-native") + lib_patterns = (f"liboliphaunt-{lib_version}-*", f"oliphaunt-tools-{lib_version}-*") + lib_asset_dir = ROOT / "target" / "liboliphaunt" / "release-assets" + copied_lib_assets = ( + [] + if host_target is None + else copy_release_asset_set(roots, lib_asset_dir, native_split_release_asset_names(lib_version, host_target)) + ) + lib_output_dir = output_root / "liboliphaunt-native" + if host_target is None: + result.add_skip("current host does not map to a supported native runtime Cargo target") + elif copied_lib_assets or ( + release_asset_dir_selected(roots, lib_asset_dir) + and release_asset_dir_has_files(lib_asset_dir, lib_patterns) + ): + ready, missing = native_split_release_assets_ready(lib_asset_dir, lib_version, host_target) + if not ready: + message = native_split_release_asset_missing_message( + lib_asset_dir, + lib_version, + host_target, + missing, + ) + result.add_skip(message) + if strict: + raise RuntimeError(message) + else: + if copied_lib_assets: + result.staged.append( + f"staged {len(copied_lib_assets)} liboliphaunt release asset(s) for Cargo" + ) + run( + [ + "tools/dev/bun.sh", + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "--version", + lib_version, + "--output-dir", + str(lib_output_dir), + "--target", + host_target, + ] + ) + generated_roots.append(lib_output_dir) + else: + result.add_skip("no liboliphaunt release assets found for native Cargo artifact packages") + + broker_version = release.current_product_version("oliphaunt-broker") + broker_patterns = ("oliphaunt-broker-*.tar.gz", "oliphaunt-broker-*.zip") + broker_asset_dir = ROOT / "target" / "oliphaunt-broker" / "release-assets" + copied_broker_assets = copy_release_assets(roots, broker_asset_dir, broker_patterns) + broker_output_dir = output_root / "oliphaunt-broker" + if host_target is None: + result.add_skip("current host does not map to a supported broker Cargo target") + elif copied_broker_assets or ( + release_asset_dir_selected(roots, broker_asset_dir) + and release_asset_dir_has_files(broker_asset_dir, broker_patterns) + ): + if copied_broker_assets: + result.staged.append( + f"staged {len(copied_broker_assets)} broker release asset(s) for Cargo" + ) + run( + [ + str(ROOT / "tools/dev/bun.sh"), + "tools/release/package_broker_cargo_artifacts.mjs", + "--version", + broker_version, + "--output-dir", + str(broker_output_dir), + "--target", + host_target, + ] + ) + generated_roots.append(broker_output_dir) + else: + result.add_skip("no broker release assets found for broker Cargo artifact packages") + + wasix_version = release.current_product_version("liboliphaunt-wasix") + wasix_patterns = (f"liboliphaunt-wasix-{wasix_version}-*",) + wasix_asset_dir = ROOT / "target" / "oliphaunt-wasix" / "release-assets" + copied_wasix_assets = copy_release_assets(roots, wasix_asset_dir, wasix_patterns) + wasix_output_dir = output_root / "liboliphaunt-wasix" + if copied_wasix_assets or ( + release_asset_dir_selected(roots, wasix_asset_dir) + and release_asset_dir_has_files(wasix_asset_dir, wasix_patterns) + ): + if copied_wasix_assets: + result.staged.append( + f"staged {len(copied_wasix_assets)} WASIX release asset(s) for Cargo" + ) + run( + [ + "python3", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", + "--version", + wasix_version, + "--output-dir", + str(wasix_output_dir), + ] + ) + generated_roots.append(wasix_output_dir) + else: + result.add_skip("no WASIX release assets found for WASIX Cargo artifact packages") + + generated_crates = discover_files(generated_roots, (".crate",)) + if generated_crates: + result.staged.append(f"generated {len(generated_crates)} release-asset Cargo crate(s)") + return generated_roots + return generated_roots + + +def publish_cargo(roots: list[Path], registry_root: Path, dry_run: bool, strict: bool) -> SurfaceResult: + registry_root = registry_root.resolve() + result = SurfaceResult("cargo") + release_asset_roots = stage_release_asset_cargo_packages(roots, registry_root, dry_run, result, strict) + if release_asset_roots: + roots = [*roots, *release_asset_roots] + generated_roots = stage_cargo_source_crates(roots, registry_root, dry_run, result, strict) + generated_roots.extend( + package_native_extension_cargo_crates( + roots, + registry_root / "cargo-generated", + host_cargo_release_target(), + dry_run, + strict, + result, + ) + ) + if generated_roots: + roots = [*roots, *generated_roots] + crates = discover_files(roots, (".crate",)) + if not crates: + result.add_skip("no .crate artifacts found") + if strict: + raise RuntimeError(result.skipped[-1]) + return result + require_command("cargo") + + cargo_root = registry_root / "cargo" + crates_dir = cargo_root / "crates" + index_dir = cargo_root / "index" + config_snippet = cargo_root / "config.toml" + if dry_run: + result.published.extend(f"dry-run cargo index {rel(path)}" for path in crates) + return result + + shutil.rmtree(cargo_root, ignore_errors=True) + crates_dir.mkdir(parents=True, exist_ok=True) + index_dir.mkdir(parents=True, exist_ok=True) + (index_dir / "config.json").write_text( + json.dumps({"dl": f"file://{crates_dir}/{{crate}}-{{version}}.crate"}, sort_keys=True) + "\n", + encoding="utf-8", + ) + + packages_by_target_name: dict[str, tuple[Path, dict[str, Any]]] = {} + for crate_path in sorted(crates, key=lambda path: cargo_crate_priority(path, registry_root)): + if crate_path.name.startswith(NON_PUBLISHABLE_LOCAL_CARGO_CRATE_PREFIXES): + result.add_skip(f"ignored non-publishable local Cargo crate artifact {crate_path.name}") + continue + try: + package = cargo_metadata_for_crate(crate_path) + except RuntimeError as error: + if is_default_cargo_tmp_crate_artifact(crate_path) and "does not contain Cargo.toml" in str(error): + result.add_skip(f"ignored malformed Cargo scratch artifact {rel(crate_path)}") + continue + result.add_skip(str(error)) + if strict: + raise + continue + if package.get("name") in LEGACY_WASIX_ARTIFACT_CRATES: + message = f"ignored legacy WASIX artifact crate {crate_path.name}" + result.add_skip(message) + if strict: + raise RuntimeError(message) + continue + target_name = f"{package['name']}-{package['version']}.crate" + packages_by_target_name[target_name] = (crate_path, package) + + local_package_names = { + str(package["name"]) + for _crate_path, package in packages_by_target_name.values() + if isinstance(package.get("name"), str) + } + entries_by_path: dict[Path, list[dict[str, Any]]] = {} + for target_name, (crate_path, package) in sorted(packages_by_target_name.items()): + entry = cargo_index_entry(crate_path, package, local_package_names) + shutil.copy2(crate_path, crates_dir / target_name) + entries_by_path.setdefault(crate_index_path(entry["name"]), []).append(entry) + result.published.append(target_name) + + for path, entries in entries_by_path.items(): + target = index_dir / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "".join(json.dumps(entry, sort_keys=True, separators=(",", ":")) + "\n" for entry in entries), + encoding="utf-8", + ) + + run(["git", "init"], cwd=index_dir) + run(["git", "config", "user.name", "Oliphaunt Local Registry"], cwd=index_dir) + run(["git", "config", "user.email", "local-registry@oliphaunt.invalid"], cwd=index_dir) + run(["git", "add", "."], cwd=index_dir) + run(["git", "commit", "-m", "local cargo registry"], cwd=index_dir) + config_snippet.write_text( + "\n".join( + [ + "[registries.oliphaunt-local]", + f'index = "file://{index_dir}"', + "", + ] + ), + encoding="utf-8", + ) + removed_cache_paths = clear_local_cargo_home_cache(registry_root) + if removed_cache_paths: + result.staged.extend(f"cleared {rel(path)}" for path in removed_cache_paths) + result.staged.extend([rel(index_dir), rel(config_snippet)]) + return result + + +def copy_tree_contents(source: Path, destination: Path) -> int: + copied = 0 + for path in source.rglob("*"): + if not path.is_file(): + continue + target = destination / path.relative_to(source) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + copied += 1 + return copied + + +def publish_maven(roots: list[Path], registry_root: Path, dry_run: bool, strict: bool) -> SurfaceResult: + result = SurfaceResult("maven") + candidates = sorted( + path + for root in roots + for path in (root.rglob("maven") if root.is_dir() else []) + if path.is_dir() + ) + if not candidates: + result.add_skip("no staged Maven repository directories named maven found") + if strict: + raise RuntimeError(result.skipped[-1]) + return result + maven_root = registry_root / "maven" + if dry_run: + result.published.extend(f"dry-run maven copy {rel(path)}" for path in candidates) + return result + shutil.rmtree(maven_root, ignore_errors=True) + maven_root.mkdir(parents=True, exist_ok=True) + for candidate in candidates: + count = copy_tree_contents(candidate, maven_root) + result.published.append(f"{rel(candidate)} ({count} files)") + result.staged.append(rel(maven_root)) + return result + + +def publish_swift(roots: list[Path], registry_root: Path, dry_run: bool, strict: bool) -> SurfaceResult: + result = SurfaceResult("swift") + swift_files = discover_files(roots, (".swift", ".zip")) + swift_files = [ + path + for path in swift_files + if path.name == "Package.swift.release" or path.name.endswith("-source.zip") or "swift" in str(path) + ] + if not swift_files: + result.add_skip("no SwiftPM package artifacts found") + if strict: + raise RuntimeError(result.skipped[-1]) + return result + if not shutil.which("swift"): + result.add_skip("swift is not installed; staged artifacts are copyable, registry publish skipped on this Linux host") + swift_root = registry_root / "swift" + if dry_run: + result.published.extend(f"dry-run swift stage {rel(path)}" for path in swift_files) + return result + shutil.rmtree(swift_root, ignore_errors=True) + swift_root.mkdir(parents=True, exist_ok=True) + for path in swift_files: + target = swift_root / path.name + shutil.copy2(path, target) + result.staged.append(rel(target)) + return result + + +def publish(args: argparse.Namespace) -> None: + roots = discover_roots(args.artifact_root) + args.registry_root.mkdir(parents=True, exist_ok=True) + surfaces = args.surface or ["npm", "cargo", "maven", "swift"] + results: list[SurfaceResult] = [] + for surface in surfaces: + if surface == "npm": + results.append(publish_npm(roots, args.registry_root, args.dry_run, args.strict, args.verdaccio_port)) + elif surface == "cargo": + results.append(publish_cargo(roots, args.registry_root, args.dry_run, args.strict)) + elif surface == "maven": + results.append(publish_maven(roots, args.registry_root, args.dry_run, args.strict)) + elif surface == "swift": + results.append(publish_swift(roots, args.registry_root, args.dry_run, args.strict)) + else: + raise RuntimeError(f"unsupported surface: {surface}") + + report = { + "registry_root": str(args.registry_root), + "artifact_roots": [str(root) for root in roots], + "dry_run": args.dry_run, + "surfaces": [result.__dict__ for result in results], + } + report_path = args.registry_root / "report.json" + if not args.dry_run: + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(report, indent=2, sort_keys=True)) + + +def status(args: argparse.Namespace) -> None: + roots = discover_roots(args.artifact_root) + report = { + "default_run_id": DEFAULT_RUN_ID, + "artifact_roots": [str(root) for root in roots], + "tools": { + "cargo": bool(shutil.which("cargo")), + "gh": bool(shutil.which("gh")), + "java": bool(shutil.which("java")), + "npm": bool(shutil.which("npm")), + "pnpm": bool(shutil.which("pnpm")), + "swift": bool(shutil.which("swift")), + }, + "artifacts": { + "npm": [rel(path) for path in discover_files(roots, (".tgz",))], + "cargo": [rel(path) for path in discover_files(roots, (".crate",))], + "maven_roots": [ + rel(path) + for root in roots + for path in (root.rglob("maven") if root.is_dir() else []) + if path.is_dir() + ], + "swift": [ + rel(path) + for path in discover_files(roots, (".swift", ".zip")) + if path.name == "Package.swift.release" or "swift" in str(path) + ], + }, + } + print(json.dumps(report, indent=2, sort_keys=True)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + download = subparsers.add_parser("download", help="download GitHub Actions artifacts with gh") + download.add_argument("--repo", default=DEFAULT_REPO) + download.add_argument("--run-id", default=DEFAULT_RUN_ID) + download.add_argument("--destination", type=Path, default=DEFAULT_ARTIFACT_ROOT) + download.add_argument("--artifact", action="append", default=[]) + download.add_argument("--preset", choices=["local-publish"], default=None) + download.add_argument("--force", action="store_true") + download.add_argument("--dry-run", action="store_true") + download.set_defaults(func=download_artifacts) + + publish_parser = subparsers.add_parser("publish", help="publish staged artifacts to local registries") + publish_parser.add_argument("--artifact-root", type=Path, action="append", default=[]) + publish_parser.add_argument("--registry-root", type=Path, default=DEFAULT_REGISTRY_ROOT) + publish_parser.add_argument( + "--surface", + action="append", + choices=["npm", "cargo", "maven", "swift"], + help="publish only this surface; may be repeated", + ) + publish_parser.add_argument("--verdaccio-port", type=int, default=4873) + publish_parser.add_argument("--dry-run", action="store_true") + publish_parser.add_argument("--strict", action="store_true") + publish_parser.set_defaults(func=publish) + + status_parser = subparsers.add_parser("status", help="show locally available staged artifacts") + status_parser.add_argument("--artifact-root", type=Path, action="append", default=[]) + status_parser.set_defaults(func=status) + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + try: + args.func(args) + except RuntimeError as error: + print(f"local_registry_publish.py: {error}", file=sys.stderr) + raise SystemExit(1) from error + + +if __name__ == "__main__": + main() 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..33d3185a --- /dev/null +++ b/tools/release/optimize_native_runtime_payload.mjs @@ -0,0 +1,582 @@ +#!/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)); +} + +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); +} + +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) { + const result = spawnSync(process.execPath, ["tools/release/strip_native_release_binaries.mjs", root], { + 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 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 errors = [ + ...validateRuntimeTree(root, target, requireRuntime, { toolSet }), + ...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); + } + 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..a3a78bf9 100755 --- a/tools/release/package-liboliphaunt-linux-assets.sh +++ b/tools/release/package-liboliphaunt-linux-assets.sh @@ -37,8 +37,10 @@ case "$(uname -m)" in esac require cargo +require bun +require python3 -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 +50,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 +64,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" @@ -72,6 +77,15 @@ 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/" +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 +96,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..44c98911 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" @@ -65,6 +69,15 @@ 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/" +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 +88,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..1e75220e 100755 --- a/tools/release/package-liboliphaunt-mobile-assets.sh +++ b/tools/release/package-liboliphaunt-mobile-assets.sh @@ -19,6 +19,7 @@ require() { source "$root/tools/release/liboliphaunt-extension-guard.sh" require cargo +require bun require python3 require rsync @@ -35,7 +36,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 +48,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 +76,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 "$stage" archive_staged_dir "$stage" } @@ -111,6 +114,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 "$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..4947d0ce 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,26 @@ 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) +} $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 +172,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.py b/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py index d2b472f6..8af03fd5 100644 --- a/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py +++ b/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py @@ -11,6 +11,7 @@ import shutil import subprocess import sys +import tarfile from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import NoReturn @@ -22,13 +23,36 @@ PRODUCT = "liboliphaunt-wasix" SCHEMA = "oliphaunt-liboliphaunt-wasix-cargo-artifacts-v2" CRATES_IO_MAX_BYTES = 10 * 1024 * 1024 -RUNTIME_PACKAGE = "oliphaunt-wasix-assets" +EXTENSION_AOT_SPLIT_THRESHOLD_BYTES = 9 * 1024 * 1024 +RUNTIME_PACKAGE = "liboliphaunt-wasix-portable" +TOOLS_PACKAGE = "oliphaunt-wasix-tools" ICU_PACKAGE = "oliphaunt-icu" +ICU_PAYLOAD_ARCHIVE = "icu-data.tar.zst" +TOOLS_PAYLOAD_FILES = ( + "bin/pg_dump.wasix.wasm", + "bin/psql.wasix.wasm", +) +CORE_RUNTIME_ARCHIVE_FILES = ( + "oliphaunt/bin/initdb", + "oliphaunt/bin/postgres", +) +FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES = ( + "oliphaunt/bin/pg_ctl", + "oliphaunt/bin/pg_dump", + "oliphaunt/bin/psql", +) +TOOLS_AOT_ARTIFACTS = {"tool:pg_dump", "tool:psql"} 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", + "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", +} +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", } AOT_TARGET_TRIPLES = { "macos-arm64": "aarch64-apple-darwin", @@ -36,6 +60,44 @@ "linux-x64-gnu": "x86_64-unknown-linux-gnu", "windows-x64-msvc": "x86_64-pc-windows-msvc", } +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"))', +} +EXPECTED_EXTENSION_AOT_TARGETS = frozenset(AOT_TARGET_TRIPLES.values()) + + +def public_cargo_package_names() -> tuple[str, ...]: + return ( + ICU_PACKAGE, + RUNTIME_PACKAGE, + TOOLS_PACKAGE, + *AOT_PACKAGES.values(), + *TOOLS_AOT_PACKAGES.values(), + ) + + +def public_aot_cargo_dependencies() -> dict[str, str]: + return { + AOT_TARGET_CFGS[AOT_TARGET_TRIPLES[target]]: package + for target, package in AOT_PACKAGES.items() + } + + +def public_tools_aot_cargo_dependencies() -> dict[str, str]: + return { + AOT_TARGET_CFGS[AOT_TARGET_TRIPLES[target]]: package + for target, package in TOOLS_AOT_PACKAGES.items() + } + + +def public_tools_feature_dependencies() -> set[str]: + return { + f"dep:{TOOLS_PACKAGE}", + *(f"dep:{package}" for package in TOOLS_AOT_PACKAGES.values()), + } @dataclass(frozen=True) @@ -59,6 +121,50 @@ class GeneratedPackage: sha256: str +@dataclass(frozen=True) +class ExtensionCargoSpec: + name: str + product: str + version: str + sql_name: str + archive: Path + sha256: str + size: int + requires_aot: bool + aot_targets: tuple["ExtensionAotCargoSpec", ...] + + +@dataclass(frozen=True) +class ExtensionAotCargoSpec: + name: str + version: str + sql_name: str + target: str + source_dir: Path + + +@dataclass(frozen=True) +class ExtensionCargoSource: + spec: ExtensionCargoSpec + source_dir: Path + + +@dataclass(frozen=True) +class ExtensionAotCargoSource: + spec: ExtensionAotCargoSpec + source_dir: Path + part_sources: tuple["ExtensionAotPartCargoSource", ...] = () + + +@dataclass(frozen=True) +class ExtensionAotPartCargoSource: + name: str + version: str + sql_name: str + target: str + source_dir: Path + + def fail(message: str) -> NoReturn: print(f"package_liboliphaunt_wasix_cargo_artifacts.py: {message}", file=sys.stderr) raise SystemExit(1) @@ -149,6 +255,9 @@ def validate_runtime_payload(root: Path) -> None: 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 tool_key in ["pg-dump", "psql"]: + if tool_key in manifest: + fail(f"{rel(root / 'manifest.json')} must not contain split WASIX tool entry {tool_key}") for required in [ "oliphaunt.wasix.tar.zst", "bin/initdb.wasix.wasm", @@ -158,6 +267,15 @@ def validate_runtime_payload(root: Path) -> None: 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") + missing_core_runtime_files = sorted( + member for member in CORE_RUNTIME_ARCHIVE_FILES if member not in runtime_members + ) + if missing_core_runtime_files: + fail( + "WASIX runtime Cargo payload must bundle postgres/initdb inside " + "oliphaunt.wasix.tar.zst; missing " + + ", ".join(missing_core_runtime_files) + ) bundled_icu = [ member for member in runtime_members @@ -168,6 +286,108 @@ def validate_runtime_payload(root: Path) -> None: "WASIX runtime Cargo payload must not bundle ICU data; " f"found {bundled_icu[0]} in oliphaunt.wasix.tar.zst" ) + bundled_tools = sorted( + member + for member in runtime_members + if member in FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES + ) + if bundled_tools: + fail( + "WASIX runtime Cargo payload must not bundle standalone tools inside " + f"oliphaunt.wasix.tar.zst; found {bundled_tools[0]}" + ) + + +def validate_tools_payload(root: Path) -> None: + actual = {path.relative_to(root).as_posix() for path in payload_files(root)} + expected = set(TOOLS_PAYLOAD_FILES) + if actual != expected: + fail(f"WASIX tools Cargo payload file set mismatch for {rel(root)}: expected {sorted(expected)}, got {sorted(actual)}") + + +def prune_runtime_archive_tools(archive: Path, scratch: Path) -> None: + runtime_members = tar_zstd_members(archive) + if not any(member in FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES for member in runtime_members): + return + + extract_tar_zstd(archive, scratch) + for member in FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES: + path = scratch / member + if path.exists(): + path.unlink() + prune_empty_dirs(scratch) + + replacement = archive.with_name(f"{archive.name}.tmp") + if replacement.exists(): + replacement.unlink() + run( + [ + "tar", + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mtime=@0", + "--use-compress-program=zstd -19", + "-cf", + str(replacement), + "-C", + str(scratch), + "oliphaunt", + ] + ) + replacement.replace(archive) + + +def rewrite_runtime_core_manifest(root: Path) -> None: + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + runtime = manifest.get("runtime") + if not isinstance(runtime, dict): + fail(f"{rel(manifest_path)} is missing runtime metadata") + runtime["sha256"] = sha256_file(root / "oliphaunt.wasix.tar.zst") + manifest["extensions"] = [] + manifest.pop("pg-dump", None) + manifest.pop("psql", None) + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + +def split_runtime_tools_payload(runtime_root: Path, extract_root: Path) -> tuple[Path, Path]: + core_root = extract_root / "runtime-core-payload" + tools_root = extract_root / "tools-payload" + shutil.rmtree(core_root, ignore_errors=True) + shutil.rmtree(tools_root, ignore_errors=True) + shutil.copytree(runtime_root, core_root) + shutil.rmtree(core_root / "extensions", ignore_errors=True) + missing: list[str] = [] + for relative in TOOLS_PAYLOAD_FILES: + source = runtime_root / relative + if not source.is_file(): + missing.append(relative) + continue + destination = tools_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + core_file = core_root / relative + if core_file.exists(): + core_file.unlink() + if missing: + fail("WASIX tools Cargo payload is missing " + ", ".join(missing)) + prune_runtime_archive_tools( + core_root / "oliphaunt.wasix.tar.zst", + extract_root / "runtime-archive-core-pruned", + ) + rewrite_runtime_core_manifest(core_root) + prune_empty_dirs(core_root) + return core_root, tools_root + + +def prune_empty_dirs(root: Path) -> None: + for path in sorted((item for item in root.rglob("*") if item.is_dir()), reverse=True): + try: + path.rmdir() + except OSError: + pass def icu_root_contains_data(root: Path) -> bool: @@ -196,6 +416,51 @@ def validate_icu_payload(root: Path) -> None: fail(f"ICU Cargo payload is missing icudt data under {rel(root)}") +def write_icu_payload_archive(root: Path, payload_root: Path) -> Path: + stage = payload_root.parent / "icu-payload-stage" + shutil.rmtree(stage, ignore_errors=True) + shutil.rmtree(payload_root, ignore_errors=True) + (stage / "share").mkdir(parents=True, exist_ok=True) + payload_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(root, stage / "share/icu") + archive = payload_root / ICU_PAYLOAD_ARCHIVE + run( + [ + "tar", + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mtime=@0", + "--use-compress-program=zstd -19", + "-cf", + str(archive), + "-C", + str(stage), + "share/icu", + ] + ) + members = tar_zstd_members(archive) + unexpected = [] + has_icu_data = False + for member in members: + path = PurePosixPath(member) + if path == PurePosixPath("share/icu"): + continue + try: + relative = path.relative_to("share/icu") + except ValueError: + unexpected.append(member) + continue + if len(relative.parts) >= 2 and relative.parts[0].startswith("icudt"): + has_icu_data = True + if not has_icu_data: + fail(f"{rel(archive)} is missing share/icu/icudt* data") + if unexpected: + fail(f"{rel(archive)} must contain only share/icu data, found {unexpected[0]}") + return payload_root + + def validate_aot_payload(root: Path) -> None: manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) artifacts = manifest.get("artifacts") @@ -222,10 +487,101 @@ def validate_aot_payload(root: Path) -> None: 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: +def split_aot_tools_payload(aot_root: Path, extract_root: Path, target_id: str) -> tuple[Path, Path]: + manifest_path = aot_root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + fail(f"{rel(manifest_path)} must contain an artifacts array") + + core_root = extract_root / f"{target_id}-aot-core-payload" + tools_root = extract_root / f"{target_id}-aot-tools-payload" + shutil.rmtree(core_root, ignore_errors=True) + shutil.rmtree(tools_root, ignore_errors=True) + core_artifacts: list[dict[str, object]] = [] + tools_artifacts: list[dict[str, object]] = [] + + for artifact in artifacts: + if not isinstance(artifact, dict): + fail(f"{rel(manifest_path)} contains a non-object artifact") + name = artifact.get("name") + path = artifact.get("path") + if not isinstance(name, str) or not isinstance(path, str): + fail(f"{rel(manifest_path)} contains an artifact without name/path") + target_root = tools_root if name in TOOLS_AOT_ARTIFACTS else core_root + target_artifacts = tools_artifacts if name in TOOLS_AOT_ARTIFACTS else core_artifacts + source = aot_root / path + if not source.is_file(): + fail(f"{rel(manifest_path)} references missing AOT artifact {path}") + destination = target_root / path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + target_artifacts.append(artifact) + + missing = sorted(TOOLS_AOT_ARTIFACTS - {str(item.get("name")) for item in tools_artifacts}) + if missing: + fail(f"{rel(manifest_path)} is missing WASIX tools AOT artifacts: {', '.join(missing)}") + if not core_artifacts: + fail(f"{rel(manifest_path)} generated no core WASIX AOT artifacts") + + for target_root, target_artifacts in [(core_root, core_artifacts), (tools_root, tools_artifacts)]: + target_manifest = {**manifest, "artifacts": target_artifacts} + target_root.mkdir(parents=True, exist_ok=True) + (target_root / "manifest.json").write_text( + json.dumps(target_manifest, indent=2) + "\n", + encoding="utf-8", + ) + return core_root, tools_root + + +def patch_tools_aot_template(crate_dir: Path, target: str) -> None: + manifest = crate_dir / "Cargo.toml" text = manifest.read_text(encoding="utf-8") + links = "oliphaunt_artifact_oliphaunt_wasix_tools_aot_" + target.replace("-", "_") + text = re.sub(r'(?m)^links = "[^"]+"$', f'links = "{links}"', text, count=1) + text = re.sub( + r'(?m)^description = "[^"]+"$', + f'description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on {target}"', + text, + count=1, + ) + manifest.write_text(text, encoding="utf-8") + + build_rs = crate_dir / "build.rs" + text = build_rs.read_text(encoding="utf-8") + text = text.replace( + 'const ARTIFACT_PRODUCT: &str = "liboliphaunt-wasix";', + 'const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools";', + ) + text = text.replace( + 'const ARTIFACT_KIND: &str = "wasix-aot";', + 'const ARTIFACT_KIND: &str = "wasix-tools-aot";', + ) + text = text.replace( + '.strip_prefix("liboliphaunt-wasix-aot-")', + '.strip_prefix("oliphaunt-wasix-tools-aot-")', + ) + text = text.replace( + "AOT crate name starts with liboliphaunt-wasix-aot-", + "AOT crate name starts with oliphaunt-wasix-tools-aot-", + ) + build_rs.write_text(text, encoding="utf-8") + + +def rewrite_cargo_manifest( + manifest: Path, + *, + package_name: str, + version: str, + extension_sources: list[ExtensionCargoSource], + extension_aot_sources: list[ExtensionAotCargoSource], +) -> None: + text = manifest.read_text(encoding="utf-8") + text = re.sub(r'(?m)^name = "[^"]+"$', f'name = "{package_name}"', text, count=1) text = re.sub(r'(?m)^version = "[^"]+"$', f'version = "{version}"', text, count=1) text = re.sub(r'(?m)^publish = false\n?', "", text) + if package_name == RUNTIME_PACKAGE and extension_sources: + text = inject_runtime_extension_dependencies(text, extension_sources, extension_aot_sources) if "\n[workspace]" not in text: text = text.rstrip() + "\n\n[workspace]\n" manifest.write_text(text, encoding="utf-8") @@ -237,7 +593,55 @@ def rewrite_cargo_manifest(manifest: Path, *, package_name: str, version: str) - ) -def copy_package_source(spec: PackageSpec, source_root: Path, version: str) -> Path: +def inject_runtime_extension_dependencies( + text: str, + extension_sources: list[ExtensionCargoSource], + extension_aot_sources: list[ExtensionAotCargoSource], +) -> str: + dependency_lines = [] + target_dependency_lines: dict[str, list[str]] = {} + aot_by_extension: dict[str, list[ExtensionAotCargoSource]] = {} + for source in extension_aot_sources: + aot_by_extension.setdefault(source.spec.sql_name, []).append(source) + for source in extension_sources: + package = source.spec.name + dependency_lines.append( + f'{package} = {{ version = "={source.spec.version}", path = "../{package}", optional = true }}' + ) + feature = extension_feature_name(source.spec.product) + feature_deps = [f"dep:{package}"] + for aot_source in sorted(aot_by_extension.get(source.spec.sql_name, []), key=lambda item: item.spec.name): + feature_deps.append(f"dep:{aot_source.spec.name}") + replacement = f'{feature} = [{", ".join(json.dumps(dep) for dep in feature_deps)}]' + pattern = rf"(?m)^{re.escape(feature)} = \[[^\n]*\]$" + text, count = re.subn(pattern, replacement, text, count=1) + if count == 0: + text = text.replace("[features]\n", f"[features]\n{replacement}\n", 1) + for source in extension_aot_sources: + cfg = AOT_TARGET_CFGS.get(source.spec.target) + if cfg is None: + fail(f"unsupported extension AOT target {source.spec.target}") + target_dependency_lines.setdefault(cfg, []).append( + f'{source.spec.name} = {{ version = "={source.spec.version}", path = "../{source.spec.name}", optional = true }}' + ) + if dependency_lines: + block = "\n".join(dependency_lines) + text = text.replace("\n[build-dependencies]", f"\n{block}\n\n[build-dependencies]", 1) + if target_dependency_lines: + blocks = [] + for cfg, lines in sorted(target_dependency_lines.items()): + blocks.append(f"[target.'{cfg}'.dependencies]\n" + "\n".join(sorted(lines))) + text = text.replace("\n[build-dependencies]", "\n" + "\n\n".join(blocks) + "\n\n[build-dependencies]", 1) + return text + + +def copy_package_source( + spec: PackageSpec, + source_root: Path, + version: str, + extension_sources: list[ExtensionCargoSource], + extension_aot_sources: list[ExtensionAotCargoSource], +) -> Path: crate_dir = source_root / spec.name if crate_dir.exists(): fail(f"duplicate generated WASIX Cargo package source: {rel(crate_dir)}") @@ -246,8 +650,16 @@ def copy_package_source(spec: PackageSpec, source_root: Path, version: str) -> P crate_dir, ignore=shutil.ignore_patterns("target", "payload", "artifacts"), ) + if spec.kind == "wasix-tools-aot": + patch_tools_aot_template(crate_dir, spec.target) shutil.copytree(spec.payload_root, crate_dir / spec.payload_dir_name) - rewrite_cargo_manifest(crate_dir / "Cargo.toml", package_name=spec.name, version=version) + rewrite_cargo_manifest( + crate_dir / "Cargo.toml", + package_name=spec.name, + version=version, + extension_sources=extension_sources, + extension_aot_sources=extension_aot_sources, + ) return crate_dir @@ -271,7 +683,7 @@ def cargo_metadata_package(manifest: Path) -> dict[str, object]: return package -def cargo_package(crate_dir: Path, target_dir: Path) -> Path: +def cargo_package(crate_dir: Path, target_dir: Path, *, no_verify: bool = False) -> Path: manifest = crate_dir / "Cargo.toml" package = cargo_metadata_package(manifest) name = package["name"] @@ -285,6 +697,8 @@ def cargo_package(crate_dir: Path, target_dir: Path) -> Path: 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" @@ -293,6 +707,50 @@ def cargo_package(crate_dir: Path, target_dir: Path) -> Path: return crate_path +def packaged_manifest_text(text: str) -> str: + return re.sub(r', path = "\.\./[^"]+"', "", text) + + +def cargo_package_without_dependency_resolution(crate_dir: Path, target_dir: Path) -> Path: + manifest = crate_dir / "Cargo.toml" + package = cargo_metadata_package(manifest) + name = str(package["name"]) + version = str(package["version"]) + package_root = f"{name}-{version}" + stage_root = target_dir / "manual-package-stage" + stage_dir = stage_root / package_root + crate_path = target_dir / "package" / f"{package_root}.crate" + shutil.rmtree(stage_dir, ignore_errors=True) + crate_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + crate_dir, + stage_dir, + ignore=shutil.ignore_patterns("target", ".git"), + ) + staged_manifest = stage_dir / "Cargo.toml" + staged_manifest.write_text( + packaged_manifest_text(staged_manifest.read_text(encoding="utf-8")), + encoding="utf-8", + ) + cargo_metadata_package(staged_manifest) + if crate_path.exists(): + crate_path.unlink() + with tarfile.open(crate_path, "w:gz") as archive: + for path in sorted(item for item in stage_dir.rglob("*") if item.is_file()): + arcname = f"{package_root}/{path.relative_to(stage_dir).as_posix()}" + info = archive.gettarinfo(path, arcname) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + with path.open("rb") as handle: + archive.addfile(info, handle) + if not crate_path.is_file(): + fail(f"manual 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: @@ -309,9 +767,14 @@ def package_spec( source_root: Path, output_dir: Path, cargo_target_dir: Path, + extension_sources: list[ExtensionCargoSource], + extension_aot_sources: list[ExtensionAotCargoSource], ) -> GeneratedPackage: - crate_dir = copy_package_source(spec, source_root, version) - crate_path = cargo_package(crate_dir, cargo_target_dir) + crate_dir = copy_package_source(spec, source_root, version, extension_sources, extension_aot_sources) + if spec.name == RUNTIME_PACKAGE and extension_sources: + crate_path = cargo_package_without_dependency_resolution(crate_dir, cargo_target_dir) + else: + 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) @@ -326,6 +789,469 @@ def package_spec( ) +def extension_feature_name(package_name: str) -> str: + if not package_name.startswith("oliphaunt-extension-"): + fail(f"invalid extension package name {package_name}") + return "extension-" + package_name.removeprefix("oliphaunt-extension-") + + +def wasix_extension_package_name(product: str) -> str: + if not product.startswith("oliphaunt-extension-"): + fail(f"invalid extension product name {product}") + return f"{product}-wasix" + + +def wasix_extension_aot_package_name(product: str, target: str) -> str: + if not product.startswith("oliphaunt-extension-"): + fail(f"invalid extension product name {product}") + return f"{product}-wasix-aot-{target}" + + +def wasix_extension_aot_part_package_name(package_name: str, index: int) -> str: + return f"{package_name}-part-{index:03d}" + + +def rust_crate_ident(package_name: str) -> str: + return package_name.replace("-", "_") + + +def discover_extension_manifests(roots: list[Path]) -> list[Path]: + manifests: list[Path] = [] + for root in roots: + if root.is_file() and root.name == "extension-artifacts.json": + manifests.append(root) + continue + if root.is_dir(): + manifests.extend(path for path in root.rglob("extension-artifacts.json") if path.is_file()) + return sorted(set(manifests)) + + +def extension_wasix_asset(extension_dir: Path, manifest: dict[str, object]) -> Path | None: + for asset in manifest.get("assets", []): + if not isinstance(asset, dict): + continue + if ( + asset.get("family") == "wasix" + and asset.get("kind") == "wasix-runtime" + and asset.get("target") == "wasix-portable" + and isinstance(asset.get("name"), str) + ): + path = extension_dir / "release-assets" / str(asset["name"]) + if path.is_file(): + return path + return None + + +def extension_aot_specs(extension_dir: Path, *, product: str, version: str, sql_name: str) -> tuple[ExtensionAotCargoSpec, ...]: + aot_root = extension_dir / "wasix-aot" + if not aot_root.is_dir(): + return () + specs: list[ExtensionAotCargoSpec] = [] + seen_targets: set[str] = set() + for manifest_path in sorted(aot_root.glob("*/manifest.json")): + data = json.loads(manifest_path.read_text(encoding="utf-8")) + target = data.get("target-triple") + artifacts = data.get("artifacts") + if not isinstance(target, str) or not target: + fail(f"{rel(manifest_path)} is missing target-triple") + if target in seen_targets: + fail(f"{rel(aot_root)} has duplicate extension AOT target {target}") + if not isinstance(artifacts, list) or not artifacts: + fail(f"{rel(manifest_path)} must contain extension AOT artifacts") + expected_prefix = f"extension:{sql_name}" + for artifact in artifacts: + if not isinstance(artifact, dict): + fail(f"{rel(manifest_path)} contains a non-object AOT artifact") + name = artifact.get("name") + path = artifact.get("path") + if not isinstance(name, str) or not ( + name == expected_prefix or name.startswith(f"{expected_prefix}:") + ): + fail(f"{rel(manifest_path)} contains AOT artifact {name!r} for {sql_name}") + if not isinstance(path, str) or not path: + fail(f"{rel(manifest_path)} artifact {name!r} is missing path") + checked = PurePosixPath(path) + if checked.is_absolute() or any(part in {"", ".", ".."} for part in checked.parts): + fail(f"{rel(manifest_path)} artifact {name!r} path must be simple relative path, got {path!r}") + if not (manifest_path.parent / path).is_file(): + fail(f"{rel(manifest_path)} references missing AOT artifact {path}") + seen_targets.add(target) + specs.append( + ExtensionAotCargoSpec( + name=wasix_extension_aot_package_name(product, target), + version=version, + sql_name=sql_name, + target=target, + source_dir=manifest_path.parent, + ) + ) + return tuple(sorted(specs, key=lambda spec: spec.target)) + + +def extension_cargo_specs(extension_roots: list[Path]) -> list[ExtensionCargoSpec]: + specs: list[ExtensionCargoSpec] = [] + for manifest_path in discover_extension_manifests(extension_roots): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + product = manifest.get("product") + version = manifest.get("version") + sql_name = manifest.get("sqlName") + native_module_stem = manifest.get("nativeModuleStem") + if not all(isinstance(value, str) and value for value in [product, version, sql_name]): + fail(f"{rel(manifest_path)} is missing product, version, or sqlName") + archive = extension_wasix_asset(manifest_path.parent, manifest) + if archive is None: + continue + specs.append( + ExtensionCargoSpec( + name=wasix_extension_package_name(str(product)), + product=str(product), + version=str(version), + sql_name=str(sql_name), + archive=archive, + sha256=sha256_file(archive), + size=archive.stat().st_size, + requires_aot=isinstance(native_module_stem, str) and bool(native_module_stem), + aot_targets=extension_aot_specs( + manifest_path.parent, + product=str(product), + version=str(version), + sql_name=str(sql_name), + ), + ) + ) + return sorted(specs, key=lambda spec: spec.name) + + +def validate_extension_aot_coverage(extension_specs: list[ExtensionCargoSpec]) -> None: + for spec in extension_specs: + if not spec.requires_aot: + continue + actual_targets = {aot_spec.target for aot_spec in spec.aot_targets} + if actual_targets != EXPECTED_EXTENSION_AOT_TARGETS: + fail( + f"{spec.product} has a WASIX native module but incomplete extension AOT artifacts; " + f"expected={sorted(EXPECTED_EXTENSION_AOT_TARGETS)}, actual={sorted(actual_targets)}" + ) + + +def write_extension_cargo_source(spec: ExtensionCargoSpec, source_root: Path) -> ExtensionCargoSource: + crate_dir = source_root / spec.name + if crate_dir.exists(): + fail(f"duplicate generated WASIX extension Cargo package source: {rel(crate_dir)}") + (crate_dir / "src").mkdir(parents=True, exist_ok=True) + (crate_dir / "payload").mkdir(parents=True, exist_ok=True) + shutil.copy2(spec.archive, crate_dir / "payload/extension.tar.zst") + crate_dir.joinpath("README.md").write_text( + "\n".join( + [ + f"# {spec.name}", + "", + f"Cargo artifact package for the `{spec.sql_name}` Oliphaunt WASIX extension.", + "", + ] + ), + encoding="utf-8", + ) + crate_dir.joinpath("Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{spec.name}"', + f'version = "{spec.version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Oliphaunt WASIX artifact package for the {spec.sql_name} 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]", + "", + ] + ), + encoding="utf-8", + ) + crate_dir.joinpath("src/lib.rs").write_text( + "\n".join( + [ + "#![deny(unsafe_code)]", + "", + f'pub const SQL_NAME: &str = "{spec.sql_name}";', + f'pub const ARCHIVE_SHA256: &str = "{spec.sha256}";', + f"pub const ARCHIVE_SIZE: u64 = {spec.size};", + "", + "pub fn archive() -> Option<&'static [u8]> {", + ' Some(include_bytes!("../payload/extension.tar.zst"))', + "}", + "", + ] + ), + encoding="utf-8", + ) + return ExtensionCargoSource(spec=spec, source_dir=crate_dir) + + +def write_extension_aot_cargo_source( + spec: ExtensionAotCargoSpec, + source_root: Path, +) -> ExtensionAotCargoSource: + crate_dir = source_root / spec.name + if crate_dir.exists(): + fail(f"duplicate generated WASIX extension AOT Cargo package source: {rel(crate_dir)}") + (crate_dir / "src").mkdir(parents=True, exist_ok=True) + manifest_path = spec.source_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + artifacts: list[tuple[str, str, Path, int]] = [] + for artifact in sorted(manifest.get("artifacts", []), key=lambda item: item.get("name", "")): + name = artifact.get("name") + path = artifact.get("path") + if not isinstance(name, str) or not isinstance(path, str): + fail(f"{rel(manifest_path)} contains an AOT artifact without name/path") + source = spec.source_dir / path + if not source.is_file(): + fail(f"{rel(manifest_path)} references missing AOT artifact {path}") + artifacts.append((name, path, source, source.stat().st_size)) + if not artifacts: + fail(f"{rel(manifest_path)} must contain extension AOT artifacts") + + split_parts = sum(size for _, _, _, size in artifacts) > EXTENSION_AOT_SPLIT_THRESHOLD_BYTES + part_sources: list[ExtensionAotPartCargoSource] = [] + + if split_parts: + (crate_dir / "artifacts").mkdir(parents=True, exist_ok=True) + shutil.copy2(manifest_path, crate_dir / "artifacts/manifest.json") + for index, (name, path, source, _) in enumerate(artifacts): + part_name = wasix_extension_aot_part_package_name(spec.name, index) + part_dir = source_root / part_name + if part_dir.exists(): + fail(f"duplicate generated WASIX extension AOT Cargo package source: {rel(part_dir)}") + (part_dir / "src").mkdir(parents=True, exist_ok=True) + destination = part_dir / "artifacts" / path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + part_dir.joinpath("README.md").write_text( + "\n".join( + [ + f"# {part_name}", + "", + f"Cargo artifact package part for `{spec.sql_name}` Oliphaunt WASIX AOT artifacts on `{spec.target}`.", + "", + ] + ), + encoding="utf-8", + ) + part_dir.joinpath("Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{part_name}"', + f'version = "{spec.version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Oliphaunt WASIX AOT artifact package part for the {spec.sql_name} 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]", + "", + ] + ), + encoding="utf-8", + ) + part_dir.joinpath("src/lib.rs").write_text( + "".join( + [ + "#![deny(unsafe_code)]\n\n", + f'pub const SQL_NAME: &str = "{spec.sql_name}";\n', + f'pub const TARGET_TRIPLE: &str = "{spec.target}";\n\n', + "pub fn aot_artifact_bytes(name: &str) -> Option<&'static [u8]> {\n", + " match name {\n", + f' {json.dumps(name)} => Some(include_bytes!("../artifacts/{path}")),\n', + " _ => None,\n", + " }\n", + "}\n", + ] + ), + encoding="utf-8", + ) + part_sources.append( + ExtensionAotPartCargoSource( + name=part_name, + version=spec.version, + sql_name=spec.sql_name, + target=spec.target, + source_dir=part_dir, + ) + ) + else: + shutil.copytree(spec.source_dir, crate_dir / "artifacts") + + artifact_cases = [] + for name, path, _, _ in artifacts: + artifact_cases.append( + f' {json.dumps(name)} => Some(include_bytes!("../artifacts/{path}")),\n' + ) + crate_dir.joinpath("README.md").write_text( + "\n".join( + [ + f"# {spec.name}", + "", + f"Cargo artifact package for `{spec.sql_name}` Oliphaunt WASIX AOT artifacts on `{spec.target}`.", + "", + ] + ), + encoding="utf-8", + ) + crate_dir.joinpath("Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{spec.name}"', + f'version = "{spec.version}"', + 'edition = "2024"', + 'rust-version = "1.93"', + f'description = "Oliphaunt WASIX AOT artifact package for the {spec.sql_name} 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"', + "", + *( + [ + "[dependencies]", + *[ + f'{part.name} = {{ version = "={part.version}", path = "../{part.name}" }}' + for part in part_sources + ], + "", + ] + if part_sources + else [] + ), + "[workspace]", + "", + ] + ), + encoding="utf-8", + ) + if part_sources: + artifact_bytes_lines: list[str] = [] + for part in part_sources: + artifact_bytes_lines.extend( + [ + f" if let Some(bytes) = {rust_crate_ident(part.name)}::aot_artifact_bytes(name) {{\n", + " return Some(bytes);\n", + " }\n", + ] + ) + artifact_bytes_body = "".join(artifact_bytes_lines) + else: + artifact_bytes_body = "".join( + [ + " match name {\n", + *artifact_cases, + " _ => None,\n", + " }\n", + ] + ) + crate_dir.joinpath("src/lib.rs").write_text( + "".join( + [ + "#![deny(unsafe_code)]\n\n", + f'pub const SQL_NAME: &str = "{spec.sql_name}";\n', + f'pub const TARGET_TRIPLE: &str = "{spec.target}";\n', + 'pub const MANIFEST_JSON: &str = include_str!("../artifacts/manifest.json");\n\n', + "pub fn aot_manifest_json() -> Option<&'static str> {\n", + " Some(MANIFEST_JSON)\n", + "}\n\n", + "pub fn aot_artifact_bytes(name: &str) -> Option<&'static [u8]> {\n", + artifact_bytes_body, + " None\n" if part_sources else "", + "}\n", + ] + ), + encoding="utf-8", + ) + return ExtensionAotCargoSource(spec=spec, source_dir=crate_dir, part_sources=tuple(part_sources)) + + +def package_extension_source( + source: ExtensionCargoSource, + *, + output_dir: Path, + cargo_target_dir: Path, +) -> GeneratedPackage: + crate_path = cargo_package(source.source_dir, cargo_target_dir) + validate_crate_size(crate_path) + output = output_dir / crate_path.name + shutil.copy2(crate_path, output) + return GeneratedPackage( + name=source.spec.name, + manifest_path=source.source_dir / "Cargo.toml", + crate_path=output, + target="wasix-portable", + kind="wasix-extension", + size=output.stat().st_size, + sha256=sha256_file(output), + ) + + +def package_extension_aot_source( + source: ExtensionAotCargoSource, + *, + output_dir: Path, + cargo_target_dir: Path, +) -> list[GeneratedPackage]: + packages: list[GeneratedPackage] = [] + for part in source.part_sources: + crate_path = cargo_package(part.source_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.name, + manifest_path=part.source_dir / "Cargo.toml", + crate_path=output, + target=part.target, + kind="wasix-extension-aot", + size=output.stat().st_size, + sha256=sha256_file(output), + ) + ) + if source.part_sources: + crate_path = cargo_package_without_dependency_resolution(source.source_dir, cargo_target_dir) + else: + crate_path = cargo_package(source.source_dir, cargo_target_dir) + validate_crate_size(crate_path) + output = output_dir / crate_path.name + shutil.copy2(crate_path, output) + packages.append( + GeneratedPackage( + name=source.spec.name, + manifest_path=source.source_dir / "Cargo.toml", + crate_path=output, + target=source.spec.target, + kind="wasix-extension-aot", + size=output.stat().st_size, + sha256=sha256_file(output), + ) + ) + return packages + + 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" @@ -334,14 +1260,26 @@ def package_specs(asset_dir: Path, extract_root: Path, version: str) -> list[Pac 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) + runtime_core_root, tools_root = split_runtime_tools_payload(runtime_root, extract_root) + validate_runtime_payload(runtime_core_root) + validate_tools_payload(tools_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_root=runtime_core_root, + payload_dir_name="payload", + ) + ) + specs.append( + PackageSpec( + name=TOOLS_PACKAGE, + target="portable", + kind="wasix-tools", + template_dir=ROOT / "src/runtimes/liboliphaunt/wasix/crates/tools", + payload_root=tools_root, payload_dir_name="payload", ) ) @@ -352,14 +1290,15 @@ def package_specs(asset_dir: Path, extract_root: Path, version: str) -> list[Pac extract_tar_zstd(icu_archive, icu_extract) icu_root = canonical_icu_root(target_icu_root(icu_extract)) validate_icu_payload(icu_root) + icu_payload_root = write_icu_payload_archive(icu_root, extract_root / "icu-payload") 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", + payload_root=icu_payload_root, + payload_dir_name="payload", ) ) @@ -372,13 +1311,24 @@ def package_specs(asset_dir: Path, extract_root: Path, version: str) -> list[Pac triple = AOT_TARGET_TRIPLES[target_id] aot_root = target_aot_root(extracted, triple) validate_aot_payload(aot_root) + aot_core_root, tools_aot_root = split_aot_tools_payload(aot_root, extract_root, target_id) 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_root=aot_core_root, + payload_dir_name="artifacts", + ) + ) + specs.append( + PackageSpec( + name=TOOLS_AOT_PACKAGES[target_id], + target=triple, + kind="wasix-tools-aot", + template_dir=ROOT / "src/runtimes/liboliphaunt/wasix/crates/tools-aot" / triple, + payload_root=tools_aot_root, payload_dir_name="artifacts", ) ) @@ -419,6 +1369,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="directory where generated .crate files are written", ) parser.add_argument("--version", default=product_metadata.read_current_version(PRODUCT)) + parser.add_argument( + "--extension-artifact-root", + action="append", + default=["target/extension-artifacts"], + help="directory containing staged exact-extension artifacts with WASIX archives", + ) return parser.parse_args(argv) @@ -430,6 +1386,12 @@ def main(argv: list[str]) -> int: asset_dir = ROOT / asset_dir if not output_dir.is_absolute(): output_dir = ROOT / output_dir + extension_roots = [] + for value in args.extension_artifact_root: + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + extension_roots.append(path) if not asset_dir.is_dir(): fail(f"WASIX release asset directory does not exist: {rel(asset_dir)}") @@ -444,16 +1406,48 @@ def main(argv: list[str]) -> int: extract_root.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True) + extension_specs = extension_cargo_specs(extension_roots) + validate_extension_aot_coverage(extension_specs) + extension_sources = [ + write_extension_cargo_source(spec, source_root) + for spec in extension_specs + ] + extension_aot_sources = [ + write_extension_aot_cargo_source(aot_spec, source_root) + for spec in extension_specs + for aot_spec in spec.aot_targets + ] 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 + *[ + package_extension_source( + source, + output_dir=output_dir, + cargo_target_dir=cargo_target_dir, + ) + for source in extension_sources + ], + *[ + package + for source in extension_aot_sources + for package in package_extension_aot_source( + source, + output_dir=output_dir, + cargo_target_dir=cargo_target_dir, + ) + ], + *[ + package_spec( + spec, + version=args.version, + source_root=source_root, + output_dir=output_dir, + cargo_target_dir=cargo_target_dir, + extension_sources=extension_sources, + extension_aot_sources=extension_aot_sources, + ) + for spec in specs + ], ] write_packages_manifest(packages, output_dir) print("generated liboliphaunt-wasix Cargo artifact crates:") 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..b814fca5 --- /dev/null +++ b/tools/release/package_oliphaunt_wasix_sdk_crate.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env bun +import { gzipSync } from 'node:zlib'; +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 cargoPackageSizeLimitBytes = 10 * 1024 * 1024; + +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 }; +} + +async function readCargoPackageNameVersion(manifest) { + return parseCargoPackageNameVersion(await fs.readFile(manifest, 'utf8'), rel(manifest)); +} + +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 compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +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 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)), + }); +} + +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; +} + +async function cargoMetadataPackageFromManifest(manifest) { + const proc = Bun.spawn( + ['cargo', 'metadata', '--manifest-path', manifest, '--format-version', '1', '--no-deps'], + { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }, + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) { + fail(`cargo metadata failed for ${rel(manifest)}: ${stderr.trim()}`); + } + const packages = JSON.parse(stdout).packages; + if (!Array.isArray(packages) || packages.length !== 1 || typeof packages[0] !== 'object') { + fail(`cargo metadata for ${rel(manifest)} did not return exactly one package`); + } + return packages[0]; +} + +async function listFilesRecursive(directory) { + const files = []; + const entries = await fs.readdir(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(...(await listFilesRecursive(fullPath))); + } else if (entry.isFile() || entry.isSymbolicLink()) { + files.push(fullPath); + } + } + return files; +} + +function tarPathParts(relativePath) { + 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 }; + } + } + fail(`crate archive path is too long for ustar: ${normalized}`); +} + +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(relativePath, size, mode) { + const header = Buffer.alloc(512, 0); + const { name, prefix } = tarPathParts(relativePath); + 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, '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 ${relativePath}`); + } + writeString(header, 148, 8, `${checksumText.padStart(6, '0')}\0 `); + return header; +} + +async function createTar(stageDir, packageRoot) { + const chunks = []; + const files = await 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 stat = await fs.stat(file); + const data = await fs.readFile(file); + chunks.push(tarHeader(archivePath, data.length, stat.mode & 0o777)); + 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); +} + +async function manualCargoPackageSource(manifest, outputDir) { + const { name, version } = await readCargoPackageNameVersion(manifest); + 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`); + await copySourceTree(sourceDir, stageDir, new Set(['target', '.git', '.DS_Store'])); + + const stagedManifest = path.join(stageDir, 'Cargo.toml'); + await fs.writeFile( + stagedManifest, + packagedCargoManifestText(await fs.readFile(stagedManifest, 'utf8')), + ); + const packageMetadata = await cargoMetadataPackageFromManifest(stagedManifest); + if (packageMetadata.name !== name || packageMetadata.version !== version) { + fail(`${rel(stagedManifest)} produced unexpected cargo metadata`); + } + + await fs.mkdir(outputDir, { recursive: true }); + await fs.rm(cratePath, { force: true }); + await fs.writeFile(cratePath, gzipSync(await createTar(stageDir, packageRoot), { mtime: 0 })); + const size = (await fs.stat(cratePath)).size; + if (size > cargoPackageSizeLimitBytes) { + fail(`${rel(cratePath)} is ${size} bytes, above the crates.io 10 MiB package limit`); + } + return cratePath; +} + +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), + }; +} + +const { outputDir } = parseArgs(Bun.argv.slice(2)); +const version = await currentOliphauntWasixSdkVersion(); +const manifest = await prepareOliphauntWasixReleaseSource(version); +const cratePath = await manualCargoPackageSource(manifest, outputDir); +console.log(rel(cratePath)); diff --git a/tools/release/product-version.mjs b/tools/release/product-version.mjs new file mode 100644 index 00000000..585adaa9 --- /dev/null +++ b/tools/release/product-version.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env bun +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const CONFIG_PATH = path.join(ROOT, "release-please-config.json"); + +function fail(message) { + console.error(`product-version.mjs: ${message}`); + process.exit(2); +} + +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; +} + +function rel(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") ? file : relative; +} + +function usage() { + fail("usage: tools/release/product-version.mjs version "); +} + +function assertRelativePath(value, context) { + if (typeof value !== "string" || value.length === 0) { + fail(`${context} must be a non-empty string`); + } + if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]/).includes("..")) { + fail(`${context} must stay inside release package path: ${JSON.stringify(value)}`); + } + return value; +} + +async function findPackageConfig(product) { + const config = await readJson(CONFIG_PATH); + const packages = config.packages; + if (packages === null || Array.isArray(packages) || typeof packages !== "object") { + fail("release-please-config.json must define packages"); + } + let foundPath; + let foundConfig; + for (const [packagePath, packageConfig] of Object.entries(packages)) { + if (packageConfig === null || Array.isArray(packageConfig) || typeof packageConfig !== "object") { + fail(`${packagePath} release-please config must be an object`); + } + if (packageConfig.component === product) { + if (foundPath !== undefined) { + fail(`duplicate release-please component ${product}`); + } + foundPath = assertRelativePath(packagePath, `${product}.packagePath`); + foundConfig = packageConfig; + } + } + if (foundPath === undefined || foundConfig === undefined) { + fail(`unknown release product ${JSON.stringify(product)}`); + } + return { packagePath: foundPath, packageConfig: foundConfig }; +} + +function packageRelativePath(packagePath, relative, context) { + return path.join(assertRelativePath(packagePath, `${context}.packagePath`), assertRelativePath(relative, context)); +} + +function canonicalVersionFile(product, packagePath, packageConfig) { + const versionFile = packageConfig["version-file"]; + if (typeof versionFile === "string" && versionFile.length > 0) { + return packageRelativePath(packagePath, versionFile, `${product}.version-file`); + } + const releaseType = packageConfig["release-type"]; + if (releaseType === "rust") { + return packageRelativePath(packagePath, "Cargo.toml", `${product}.rust`); + } + if (releaseType === "node" || releaseType === "expo") { + return packageRelativePath(packagePath, "package.json", `${product}.node`); + } + fail(`${product} release-please config must declare version-file for release type ${JSON.stringify(releaseType)}`); +} + +function parserForVersionFile(product, file) { + const name = path.basename(file); + if (name === "Cargo.toml") { + return "cargo"; + } + if (name === "package.json" || name === "jsr.json") { + return "json:version"; + } + if (name === "gradle.properties") { + return "gradle:VERSION_NAME"; + } + if (name === "VERSION" || name === "LIBOLIPHAUNT_VERSION") { + return "raw"; + } + fail(`${product}.version_files has unsupported version file type: ${file}`); +} + +function parseJsonPath(text, dotted) { + let value = JSON.parse(text); + for (const key of dotted.split(".")) { + if (value === null || Array.isArray(value) || typeof value !== "object" || !(key in value)) { + return ""; + } + value = value[key]; + } + return String(value); +} + +function parseTomlPath(text, dotted) { + let value = Bun.TOML.parse(text); + for (const key of dotted.split(".")) { + if (value === null || Array.isArray(value) || typeof value !== "object" || !(key in value)) { + return ""; + } + value = value[key]; + } + return String(value); +} + +function parseGradleProperty(text, name) { + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#") || !line.includes("=")) { + continue; + } + const [key, ...rest] = line.split("="); + if (key.trim() === name) { + return rest.join("=").trim(); + } + } + return ""; +} + +function parseVersionText(text, file, parser) { + if (parser === "raw") { + return text.trim(); + } + if (parser === "cargo") { + return parseTomlPath(text, "package.version"); + } + if (parser.startsWith("gradle:")) { + return parseGradleProperty(text, parser.slice("gradle:".length)); + } + if (parser.startsWith("json:")) { + return parseJsonPath(text, parser.slice("json:".length)); + } + if (parser.startsWith("toml:")) { + return parseTomlPath(text, parser.slice("toml:".length)); + } + fail(`unknown version parser ${JSON.stringify(parser)} for ${file}`); +} + +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) { + const { packagePath, packageConfig } = await findPackageConfig(product); + const versionFile = canonicalVersionFile(product, packagePath, packageConfig); + const parser = parserForVersionFile(product, versionFile); + const file = path.join(ROOT, versionFile); + let text; + try { + text = await readFile(file, "utf8"); + } catch { + fail(`${product} version file does not exist: ${versionFile}`); + } + const version = parseVersionText(text, versionFile, parser); + if (!version) { + fail(`${versionFile} does not define a release version for ${product}`); + } + return ensureSemver(product, version); +} + +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 index 61bf6ad2..4aaa6ed0 100644 --- a/tools/release/product_metadata.py +++ b/tools/release/product_metadata.py @@ -9,19 +9,19 @@ from __future__ import annotations import json -import os import re import subprocess import sys import tomllib +from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from typing import Any, NoReturn +from types import SimpleNamespace +from typing import Any, Iterable, 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", @@ -84,18 +84,6 @@ 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") @@ -124,99 +112,21 @@ def _release_please_packages_by_component() -> dict[str, tuple[str, dict[str, An 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}") + 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 moon_release_metadata(product: str) -> dict[str, Any]: - metadata = _moon_release_projects_by_component().get(product) - if metadata is None: + projects = load_graph().get("moon_projects") + project = projects.get(product) if isinstance(projects, dict) else None + if not isinstance(project, dict): fail(f"unknown Moon release component {product!r}") - release = metadata.get("release") + project_config = project.get("project") + metadata = project_config.get("metadata") if isinstance(project_config, dict) else None + release = metadata.get("release") if isinstance(metadata, dict) else None if not isinstance(release, dict): fail(f"Moon release component {product!r} has no release metadata") return release @@ -250,67 +160,515 @@ def _release_metadata(product: str) -> dict[str, Any]: 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 _release_graph() + + +@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_query_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_query_rows(command: str, args: tuple[str, ...] = ()) -> tuple[dict[str, Any], ...]: + rows = _release_graph_query_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=1) +def _release_graph() -> dict[str, Any]: + value = _release_graph_query_json("graph") + if not isinstance(value, dict): + fail("release graph query must return a JSON object") + products = value.get("products") + if not isinstance(products, dict) or not products: + fail("release graph query must return a non-empty products object") + return value + + +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_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 raw_artifact_target_tables(graph: dict | None = None) -> list[dict[str, Any]]: + """Return raw artifact target rows from the canonical Bun release graph.""" + + return [ + dict(row) + for row in _release_graph_query_rows("raw-artifact-targets") + ] + + +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]: + rows = _release_graph_query_rows( + "artifact-targets", + _artifact_target_args( + product=product, + kind=kind, + surface=surface, + published_only=published_only, + ), + ) + return [_artifact_target_from_row(row) for row in rows] + + +@lru_cache(maxsize=1) +def _wasix_cargo_artifact_contract() -> dict[str, Any]: + value = _release_graph_query_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_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_cargo_artifact_schema() -> str: + return _wasix_contract_string("schema") + + +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 wasix_extension_package_name(product: str) -> str: + if not product: + fail("WASIX extension package product must be non-empty") + return f"{product}-wasix" + + +def wasix_extension_aot_package_name(product: str, target: str) -> str: + if not product or not target: + fail("WASIX extension AOT package product and target must be non-empty") + return f"{product}-wasix-aot-{target}" + + +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: + fail(f"{product} has no artifact targets for surface {surface}") + return sorted(assets) + + +def ci_release_asset_artifact_names(product: str, kind: str) -> list[str]: + names = [ + f"{product}-release-assets-{target.target}" + for target in artifact_targets( + product=product, + kind=kind, + surface="github-release", + published_only=True, + ) + ] + if not names: + fail(f"{product} has no published {kind} CI release asset targets") + return sorted(names) + + +def ci_npm_package_artifact_names(product: str, kind: str) -> list[str]: + names = [ + f"{product}-npm-package-{target.target}" + for target in artifact_targets( + product=product, + kind=kind, + surface="npm-optional", + published_only=True, + ) + ] + if not names: + fail(f"{product} has no published {kind} CI npm package targets") + return sorted(names) + + +def ci_wasix_aot_runtime_artifact_names() -> list[str]: + names = [ + f"liboliphaunt-wasix-runtime-aot-{target.target}" + for target in artifact_targets( + product="liboliphaunt-wasix", + kind="wasix-aot-runtime", + published_only=True, + ) + ] + if not names: + fail("liboliphaunt-wasix has no published WASIX AOT runtime targets") + return sorted(names) + + +def ci_aggregate_release_asset_artifact_name(product: str) -> str: + config = product_config(product) + release_artifacts = config.get("release_artifacts") + if not isinstance(release_artifacts, list) or not release_artifacts: + fail(f"{product} does not publish aggregate release assets") + return f"{product}-release-assets" + + +def ci_wasix_runtime_artifact_names() -> list[str]: + names = [ + f"liboliphaunt-wasix-runtime-{target.target}" + for target in artifact_targets( + product="liboliphaunt-wasix", + kind="wasix-runtime", + published_only=True, + ) + ] + if not names: + fail("liboliphaunt-wasix has no published WASIX runtime targets") + return sorted(names) + + +def ci_sdk_package_artifact_name(product: str) -> str: + config = product_config(product) + if config.get("kind") != "sdk": + fail(f"{product} is not an SDK release product") + if product == "oliphaunt-wasix-rust": + return f"{product}-package-artifacts" + return f"{product}-sdk-package-artifacts" + + +def sdk_package_products() -> tuple[str, ...]: + return tuple( + product + for product, config in graph_products().items() + if config.get("kind") == "sdk" + ) + + +def ci_sdk_package_artifact_names(product: str | None = None) -> list[str]: + if product is not None: + return [ci_sdk_package_artifact_name(product)] + return [ci_sdk_package_artifact_name(sdk_product) for sdk_product in sdk_package_products()] + + +def typescript_optional_runtime_package_products() -> dict[str, str]: + package_products: dict[str, str] = {} + selectors = [ + ("oliphaunt-broker", "broker-helper", "typescript-broker"), + ("liboliphaunt-native", "native-runtime", "typescript-native-direct"), + ("liboliphaunt-native", "native-tools", "typescript-native-direct"), + ("oliphaunt-node-direct", "node-direct-addon", "npm-optional"), + ] + for product, kind, surface in selectors: + targets = artifact_targets( + product=product, + kind=kind, + surface=surface, + published_only=True, + ) + if not targets: + fail(f"{product} has no published {kind} TypeScript optional package targets") + for target in targets: + if target.npm_package is None: + fail(f"{target.id} must declare npm_package for TypeScript optional dependencies") + if target.npm_package in package_products: + fail(f"duplicate TypeScript optional package target {target.npm_package}") + package_products[target.npm_package] = target.product + return dict(sorted(package_products.items())) + + +def typescript_optional_runtime_package_versions() -> dict[str, str]: return { - "policy": { - "repository": "f0rr0/oliphaunt", - "default_branch": "main", - "versioning": "independent", - }, - "products": graph_products(), - "artifact_targets": [], + package_name: read_current_version(product) + for package_name, product in typescript_optional_runtime_package_products().items() } 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 + source = load_graph() if graph is None else graph + products = source.get("products") if isinstance(source, dict) else None + if not isinstance(products, dict) or not products: + fail("release graph must contain a non-empty products object") + parsed: dict[str, dict[str, Any]] = {} + for product, config in products.items(): + if not isinstance(product, str) or not product: + fail("release graph product ids must be non-empty strings") + if not isinstance(config, dict): + fail(f"release graph product {product} config must be an object") + parsed[product] = dict(config) + return parsed def product_config(product: str, graph: dict | None = None) -> dict[str, Any]: - config = graph_products().get(product) + config = graph_products(graph).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()) + return list(graph_products(graph)) def extension_product_ids(graph: dict | None = None) -> list[str]: return sorted( product - for product, config in graph_products().items() + for product, config in graph_products(graph).items() if config.get("kind") == "exact-extension-artifact" ) +@lru_cache(maxsize=None) +def extension_artifact_targets( + *, + product: str | None = None, + family: str | None = None, + published_only: bool = False, +) -> tuple[SimpleNamespace, ...]: + args = ["tools/dev/bun.sh", "tools/release/release_graph_query.mjs", "extension-targets"] + 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") + try: + output = subprocess.check_output(args, cwd=ROOT, text=True, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + if detail: + fail(f"release graph extension target query failed: {detail}") + fail(f"release graph extension target query failed with exit code {error.returncode}") + rows = json.loads(output) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + fail("release graph extension-targets query must return a JSON object list") + return tuple(SimpleNamespace(**row) for row in 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 published_extension_target_ids(*, family: str) -> list[str]: + return sorted( + { + target.target + for target in extension_artifact_targets(family=family, published_only=True) + } + ) + + +def ci_wasix_extension_artifact_names() -> list[str]: + names = [ + f"liboliphaunt-wasix-extension-artifacts-{target_id}" + for target_id in published_extension_target_ids(family="wasix") + ] + if not names: + fail("exact-extension metadata has no published WASIX artifact targets") + return names + + +def ci_extension_package_artifact_names() -> list[str]: + names = ["oliphaunt-extension-package-artifacts"] + mobile_targets = [ + target + for target in extension_artifact_targets(family="native", published_only=True) + if target.kind == "native-static-registry" + ] + if mobile_targets: + names.append("oliphaunt-mobile-extension-package-artifacts") + return names + + 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): @@ -318,6 +676,23 @@ def string_list(config: dict, key: str, product: str) -> list[str]: return value +def registry_package_names(product: str, package_kind: str) -> list[str]: + names: list[str] = [] + for raw in string_list(product_config(product), "registry_packages", product): + kind, separator, name = raw.partition(":") + if not separator or not kind or not name: + fail(f"{product}.registry_packages entry {raw!r} must use kind:name") + if kind == package_kind: + 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 + + def _string_field(config: dict[str, Any], key: str, context: str) -> str: value = config.get(key) if not isinstance(value, str) or not value: 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..4504aeda --- /dev/null +++ b/tools/release/release-artifact-targets.mjs @@ -0,0 +1,880 @@ +import { existsSync, readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +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 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 artifactTargets(product, kind, prefix) { + return allArtifactTargets({ product, kind, publishedOnly: true }, prefix); +} + +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`); +} + +async function readJson(file, prefix) { + try { + return JSON.parse(await fs.readFile(file, "utf8")); + } catch (error) { + fail(prefix, `failed to read ${rel(file)}: ${error.message}`); + } +} + +export async function currentProductVersion(product, prefix) { + const release = releaseMetadata(product, prefix); + const packagePath = release.packagePath; + const config = await readJson(path.join(ROOT, "release-please-config.json"), prefix); + const packageConfig = config.packages?.[packagePath]; + if (typeof packageConfig !== "object" || packageConfig === null) { + fail(prefix, `release-please-config.json does not include ${packagePath}`); + } + const versionFile = + packageConfig["version-file"] ?? + (packageConfig["release-type"] === "rust" + ? "Cargo.toml" + : packageConfig["release-type"] === "node" + ? "package.json" + : null); + if (typeof versionFile !== "string" || !versionFile) { + fail(prefix, `${product} release-please config must declare a supported version file`); + } + const file = path.join(ROOT, packagePath, versionFile); + const text = await fs.readFile(file, "utf8"); + if (path.basename(versionFile) === "Cargo.toml") { + return parseCargoVersion(text, file, prefix); + } + if (path.basename(versionFile) === "package.json") { + const data = JSON.parse(text); + if (typeof data.version === "string" && data.version) { + return data.version; + } + } else if (path.basename(versionFile) === "VERSION") { + const version = text.trim(); + if (version) { + return version; + } + } + fail(prefix, `${rel(file)} does not define a release version for ${product}`); +} + +export function expectedAssets(product, kind, version, prefix) { + const assets = artifactTargets(product, kind, prefix).map((target) => + target.asset.replaceAll("{version}", version), + ); + assets.push(`${product}-${version}-release-assets.sha256`); + return assets.sort(compareText); +} + +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); +} + +function extensionSqlName(product, prefix) { + const value = graph(prefix).products[product]?.extension_sql_name; + if (typeof value !== "string" || !value) { + fail(prefix, `${product} release.toml must declare extension_sql_name`); + } + return value; +} + +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-graph.mjs b/tools/release/release-graph.mjs new file mode 100644 index 00000000..71f102cf --- /dev/null +++ b/tools/release/release-graph.mjs @@ -0,0 +1,745 @@ +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; +} + +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 }; +} + +export function moonProjectsById(prefix = "release-graph") { + const data = commandJson([moonBin(), "query", "projects"], prefix); + const projects = data.projects; + if (!Array.isArray(projects)) { + fail(prefix, "moon query projects did not return a projects array"); + } + const parsed = new Map(); + for (const project of projects) { + if (project === null || Array.isArray(project) || typeof project !== "object" || typeof project.id !== "string") { + continue; + } + const config = project.config && typeof project.config === "object" && !Array.isArray(project.config) ? project.config : {}; + const rawDeps = project.dependencies ?? config.dependsOn ?? []; + const dependencyScopes = {}; + if (Array.isArray(rawDeps)) { + for (const dependency of rawDeps) { + if (typeof dependency === "string") { + dependencyScopes[dependency] = "production"; + } else if ( + dependency !== null && + typeof dependency === "object" && + !Array.isArray(dependency) && + typeof dependency.id === "string" + ) { + dependencyScopes[dependency.id] = String(dependency.scope || "production"); + } + } + } + parsed.set(project.id, { + id: project.id, + source: project.source || config.source || "", + dependsOn: Object.keys(dependencyScopes).sort(compareText), + dependencyScopes, + tags: Array.isArray(config.tags) ? [...config.tags].sort(compareText) : [], + project: config.project && typeof config.project === "object" && !Array.isArray(config.project) ? config.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 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.py b/tools/release/release.py index 41a8a7de..f5ba1676 100755 --- a/tools/release/release.py +++ b/tools/release/release.py @@ -7,6 +7,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -16,24 +17,28 @@ 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 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" + + +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: @@ -48,6 +53,57 @@ 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) + + +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() @@ -57,6 +113,39 @@ def succeeds(args: list[str], *, cwd: Path = ROOT) -> bool: 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 cratesio_crate_version_exists(crate: str, version: str) -> bool: + value = registry_check_json( + "crate-version-exists", + "--crate", + crate, + "--version", + version, + ) + exists = value.get("exists") + if not isinstance(exists, bool): + fail(f"registry publication helper returned invalid crates.io status for {crate} {version}") + return exists + + def pnpm_pack_for_npm_publish(package_dir: Path) -> Path: """Pack with pnpm so workspace: dependency specs become publishable versions.""" @@ -185,7 +274,7 @@ def verify_staged_cargo_crate_identity( def verify_staged_cargo_product_crates(product: str, version: str, *, allow_dirty: bool) -> None: - crates = check_cratesio_publication.product_crates(product) + crates = cratesio_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)) @@ -366,9 +455,17 @@ def selected_products_from_passthrough(args: list[str]) -> list[str]: 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: @@ -383,6 +480,60 @@ 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]]: + if is_extension_product(product): + return { + "github-release-assets": {"github-release-assets"}, + "maven-central": {"maven-central"}, + } + return { + "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": {"npm", "jsr"}, + }, + "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"}, + }, + }.get(product, {}) + + +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 extension_sql_name(product: str) -> str: config = product_metadata.product_config(product) value = config.get("extension_sql_name") @@ -391,9 +542,8 @@ def extension_sql_name(product: str) -> str: 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: @@ -401,7 +551,7 @@ def current_product_version(product: str) -> str: 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 +569,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), @@ -475,12 +626,11 @@ def product_tag_points_at(product: str, head_ref: str) -> bool: def product_registry_is_published(product: str) -> bool: return succeeds( - [ - "tools/release/check_registry_publication.py", + registry_check_args( "--product", product, "--require-published", - ] + ) ) @@ -490,7 +640,7 @@ def published_rerun(product: str, head_ref: str) -> bool: 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): + if cratesio_crate_version_exists(crate, version): return if attempt < retries: print(f"waiting for crates.io to index {crate} {version}...") @@ -498,8 +648,20 @@ def wait_for_cratesio_package(crate: str, version: str, *, retries: int = 12, re fail(f"crates.io did not report {crate} {version} after publish") +def verify_generated_cratesio_packages_published(product: str, crates: list[str], version: str) -> None: + generated_crates = sorted(set(crates)) + if not generated_crates: + fail(f"{product} generated no Cargo artifact crates to verify") + for crate in generated_crates: + wait_for_cratesio_package(crate, version) + print( + f"{product} generated Cargo artifact publication verified: " + + ", ".join(generated_crates) + ) + + def cargo_publish_package(package: str, version: str, *, allow_dirty: bool = False) -> None: - if check_cratesio_publication.crate_version_exists(package, version): + if cratesio_crate_version_exists(package, version): print(f"{package} {version} is already published on crates.io; skipping cargo publish.") return run( @@ -516,7 +678,7 @@ def cargo_publish_package(package: str, version: str, *, allow_dirty: bool = Fal 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): + if cratesio_crate_version_exists(package, version): print(f"{package} {version} is already published on crates.io; skipping cargo publish.") return run( @@ -534,21 +696,21 @@ def cargo_publish_manifest(package: str, version: str, manifest_path: Path, *, a 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 + return sorted(product_metadata.registry_package_names(product, "crates")) + + +def maven_pom_url(coordinate: str, version: str) -> str: + group_id, separator, artifact_id = coordinate.partition(":") + if not separator or not group_id or not artifact_id: + fail(f"invalid Maven coordinate {coordinate!r}; expected group:artifact") + group_path = group_id.replace(".", "/") + return ( + f"https://repo1.maven.org/maven2/{group_path}/{artifact_id}/" + f"{version}/{artifact_id}-{version}.pom" + ) -def rust_artifact_cargo_target_cfg(target: artifact_targets.ArtifactTarget) -> str: +def rust_artifact_cargo_target_cfg(target: product_metadata.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": @@ -577,22 +739,24 @@ def render_oliphaunt_release_cargo_toml(source: str, native_version: str, broker "# artifacts are published and indexed.", ] target_dependencies: dict[str, list[str]] = {} - for target in artifact_targets.artifact_targets( + for target in product_metadata.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) + crate = liboliphaunt_cargo_package_name(target.target) + tools_facade = LIBOLIPHAUNT_TOOLS_PRODUCT cfg = rust_artifact_cargo_target_cfg(target) target_dependencies.setdefault(cfg, []).append(f'{crate} = {{ version = "={native_version}" }}') - for target in artifact_targets.artifact_targets( + target_dependencies.setdefault(cfg, []).append(f'{tools_facade} = {{ version = "={native_version}" }}') + for target in product_metadata.artifact_targets( product="oliphaunt-broker", kind="broker-helper", surface="rust-broker", published_only=True, ): - crate = package_broker_cargo_artifacts.cargo_package_name(target.target) + crate = broker_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): @@ -616,13 +780,18 @@ def validate_generated_oliphaunt_release_artifact_coverage(manifest_path: Path) + ", ".join(missing_broker) ) - native_targets = artifact_targets.artifact_targets( + native_version = current_product_version("liboliphaunt-native") + native_targets = product_metadata.artifact_targets( product="liboliphaunt-native", kind="native-runtime", surface="rust-native-direct", published_only=True, ) - native_crates = cargo_registry_packages("liboliphaunt-native") + native_runtime_crates = { + liboliphaunt_cargo_package_name(target.target) + for target in native_targets + } + native_crates = set(cargo_registry_packages("liboliphaunt-native")) if not native_crates: target_names = ", ".join(target.target for target in native_targets) fail( @@ -632,12 +801,88 @@ def validate_generated_oliphaunt_release_artifact_coverage(manifest_path: Path) "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] + tools_facade = LIBOLIPHAUNT_TOOLS_PRODUCT + missing_native = sorted( + crate for crate in native_runtime_crates if f'{crate} = {{ version = "={native_version}" }}' not in manifest + ) if missing_native: fail( "generated oliphaunt release source is missing native runtime Cargo artifact dependencies: " + ", ".join(missing_native) ) + if f'{tools_facade} = {{ version = "={native_version}" }}' not in manifest: + fail(f"generated oliphaunt release source is missing native tools facade dependency {tools_facade}") + direct_tool_deps = sorted( + crate + for crate in native_crates + if crate.startswith(f"{tools_facade}-") and f"{crate} = " in manifest + ) + if direct_tool_deps: + fail( + "generated oliphaunt release source must depend on oliphaunt-tools, not target tools crates: " + + ", ".join(direct_tool_deps) + ) + + +def render_oliphaunt_wasix_release_cargo_toml(source: str, runtime_version: str) -> str: + text = source.replace( + "repository.workspace = true", + 'repository = "https://github.com/f0rr0/oliphaunt"', + ).replace( + "homepage.workspace = true", + 'homepage = "https://oliphaunt.dev"', + ) + text = re.sub(r', path = "[^"]+"', "", text) + artifact_crates = set(product_metadata.wasix_public_cargo_package_names()) + for crate in sorted(artifact_crates): + pattern = rf'(?m)^({re.escape(crate)}\s*=\s*\{{[^}}\n]*version\s*=\s*")=[^"]+("[^}}\n]*\}})$' + text, count = re.subn(pattern, rf"\1={runtime_version}\2", text, count=1) + if count != 1: + fail(f"generated oliphaunt-wasix release source is missing dependency {crate}") + if "\n[workspace]" not in text: + text = text.rstrip() + "\n\n[workspace]\n" + return text + + +def validate_generated_oliphaunt_wasix_release_artifact_coverage(manifest_path: Path) -> None: + manifest = manifest_path.read_text(encoding="utf-8") + if re.search(r'=\s*\{[^}\n]*path\s*=', manifest): + fail("generated oliphaunt-wasix release source must not contain local path dependencies") + runtime_version = current_product_version("liboliphaunt-wasix") + required_crates = set(product_metadata.wasix_public_cargo_package_names()) + missing = [ + crate + for crate in sorted(required_crates) + if f'{crate} = {{ version = "={runtime_version}"' not in manifest + ] + if missing: + fail( + "generated oliphaunt-wasix release source is missing WASIX artifact dependency pins: " + + ", ".join(missing) + ) + + +def prepare_oliphaunt_wasix_release_source(version: str) -> Path: + runtime_version = current_product_version("liboliphaunt-wasix") + source_dir = ROOT / "src" / "bindings" / "wasix-rust" / "crates" / "oliphaunt-wasix" + stage_dir = ROOT / "target" / "release" / "cargo-package-sources" / "oliphaunt-wasix" + shutil.rmtree(stage_dir, ignore_errors=True) + shutil.copytree( + source_dir, + stage_dir, + ignore=shutil.ignore_patterns("target"), + ) + cargo_toml = stage_dir / "Cargo.toml" + rendered = render_oliphaunt_wasix_release_cargo_toml( + cargo_toml.read_text(encoding="utf-8"), + runtime_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-wasix release source must keep SDK version {version}") + validate_generated_oliphaunt_wasix_release_artifact_coverage(cargo_toml) + return cargo_toml def prepare_oliphaunt_release_source(version: str) -> Path: @@ -662,22 +907,25 @@ def prepare_oliphaunt_release_source(version: str) -> Path: 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( + for target in product_metadata.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) + crate = liboliphaunt_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( + tools_facade = LIBOLIPHAUNT_TOOLS_PRODUCT + if f'{tools_facade} = {{ version = "={native_version}" }}' not in rendered: + fail(f"generated oliphaunt release source is missing native tools facade dependency {tools_facade}") + for target in product_metadata.artifact_targets( product="oliphaunt-broker", kind="broker-helper", surface="rust-broker", published_only=True, ): - crate = package_broker_cargo_artifacts.cargo_package_name(target.target) + crate = broker_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 @@ -718,7 +966,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(product_metadata.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: @@ -854,6 +1102,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 +1122,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 +1142,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: @@ -953,9 +1226,15 @@ def validate_wasix_aot_release_asset(archive: Path) -> None: def run_wasm_release_dry_run(allow_dirty: bool) -> None: _ = allow_dirty + version = current_product_version("oliphaunt-wasix-rust") validate_staged_sdk_package("oliphaunt-wasix-rust") + release_manifest = prepare_oliphaunt_wasix_release_source(version) + validate_generated_oliphaunt_wasix_release_artifact_coverage(release_manifest) print( - "validated staged WASIX Rust binding package shape; " + f"validated generated WASIX Rust binding release source: {release_manifest.relative_to(ROOT)}" + ) + print( + "validated staged WASIX Rust binding package shape and generated publish manifest; " "source publish runs after WASIX artifact crates are published." ) @@ -968,7 +1247,7 @@ def publish_wasm_crates_io(head_ref: str) -> None: verify_release_tag("oliphaunt-wasix-rust", head_ref) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-wasix", "--registry-kind", @@ -982,10 +1261,12 @@ def publish_wasm_crates_io(head_ref: str) -> None: ) version = current_product_version("oliphaunt-wasix-rust") validate_staged_sdk_package("oliphaunt-wasix-rust") - cargo_publish_package("oliphaunt-wasix", version) + release_manifest = prepare_oliphaunt_wasix_release_source(version) + validate_generated_oliphaunt_wasix_release_artifact_coverage(release_manifest) + cargo_publish_manifest("oliphaunt-wasix", version, release_manifest) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-wasix-rust", "--require-published", @@ -1010,7 +1291,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 " @@ -1077,7 +1358,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 +1369,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 +1384,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 +1395,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: @@ -1248,7 +1529,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 product_metadata.extension_artifact_targets( product=product, family="native", published_only=True, @@ -1256,7 +1537,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 product_metadata.extension_artifact_targets( product=product, family="wasix", published_only=True, @@ -1357,8 +1638,8 @@ def build_maven_artifact_manifest( ) -> Path: output_path = ROOT / "target" / "release" / "maven-artifacts" / f"{name}.tsv" command = [ - "python3", - "tools/release/build_maven_artifact_manifest.py", + "tools/dev/bun.sh", + "tools/release/build_maven_artifact_manifest.mjs", "--output", str(output_path.relative_to(ROOT)), ] @@ -1408,7 +1689,16 @@ def run_extension_maven_artifact_dry_run(product: str) -> None: def validate_staged_sdk_package(product: str) -> None: - run(["python3", "tools/release/check_staged_artifacts.py", "--require-sdk-product", product]) + run(["tools/dev/bun.sh", "tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product]) + + +def command_prepare_rust_release_source(passthrough: list[str]) -> None: + if passthrough: + fail("prepare-rust-release-source does not accept extra arguments: " + " ".join(passthrough)) + version = current_product_version("oliphaunt-rust") + release_manifest = prepare_oliphaunt_release_source(version) + validate_generated_oliphaunt_release_artifact_coverage(release_manifest) + print(release_manifest.relative_to(ROOT)) def run_rust_sdk_dry_run(allow_dirty: bool, head_ref: str) -> None: @@ -1493,15 +1783,20 @@ def run_product_publish_dry_runs(products: list[str], *, allow_dirty: bool, head def command_plan(args: list[str]) -> None: - raise SystemExit(release_plan.main(args)) + result = subprocess.run( + ["tools/dev/bun.sh", "tools/release/release_plan.mjs", *args], + cwd=ROOT, + check=False, + ) + raise SystemExit(result.returncode) 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(["tools/release/check_release_please_config.mjs"]) 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(["tools/dev/bun.sh", "tools/release/sync-release-pr.mjs", "--check"]) + run(["bun", "tools/release/check_release_pr_coverage.mjs"]) run(["python3", "tools/release/check_release_metadata.py"]) run(["tools/release/release.py", "consumer-shape", "--format", "json", "--require-ready"]) run( @@ -1523,14 +1818,14 @@ def command_check_registries(args: list[str]) -> None: if not args: print("No release products selected; registry publication checks skipped.") return - run(["tools/release/check_release_versions.py", *args, "--check-registries"]) + run(["tools/dev/bun.sh", "tools/release/check_release_versions.mjs", *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", + *REGISTRY_PUBLICATION_CHECK, "--products-json", products_json, "--require-identities", @@ -1544,6 +1839,43 @@ def command_consumer_shape(args: list[str]) -> None: raise SystemExit(result.returncode) +def command_ci_artifacts(args: list[str]) -> None: + parser = argparse.ArgumentParser(description="Emit CI artifact names derived from release target metadata.") + parser.add_argument("--product", required=True) + parser.add_argument("--kind") + parser.add_argument("--family", choices=["release-assets", "npm-package", "sdk-package"], required=True) + parsed = parser.parse_args(args) + if parsed.family == "release-assets": + if parsed.kind is None: + fail("ci-artifacts --family release-assets requires --kind") + names = product_metadata.ci_release_asset_artifact_names(parsed.product, parsed.kind) + elif parsed.family == "npm-package": + if parsed.kind is None: + fail("ci-artifacts --family npm-package requires --kind") + names = product_metadata.ci_npm_package_artifact_names(parsed.product, parsed.kind) + else: + if parsed.kind is not None: + fail("ci-artifacts --family sdk-package does not accept --kind") + names = product_metadata.ci_sdk_package_artifact_names(parsed.product) + for name in names: + print(name) + + +def command_ci_products(args: list[str]) -> None: + parser = argparse.ArgumentParser(description="Emit selected CI products derived from release metadata.") + parser.add_argument("--family", choices=["sdk-package"], required=True) + parser.add_argument("--products-json") + parsed = parser.parse_args(args) + sdk_products = set(product_metadata.sdk_package_products()) + if parsed.products_json is None: + products = list(product_metadata.sdk_package_products()) + else: + products = selected_products_from_passthrough(["--products-json", parsed.products_json]) + for product in products: + if product in sdk_products: + print(product) + + def consumer_shape_scope_args(args: list[str]) -> list[str]: scoped: list[str] = [] index = 0 @@ -1562,21 +1894,9 @@ def consumer_shape_scope_args(args: list[str]) -> list[str]: def command_verify_release(args: list[str]) -> None: - run(["tools/release/check_release_versions.py", *args, "--check-registries"]) + run(["tools/dev/bun.sh", "tools/release/check_release_versions.mjs", *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}") + run(["tools/dev/bun.sh", "tools/release/verify_github_release_attestations.mjs", *args]) def publish_liboliphaunt_github_assets(head_ref: str) -> None: @@ -1594,7 +1914,8 @@ def publish_swift_release(head_ref: str) -> None: manifest = prepare_staged_swift_release_manifest() run( [ - "tools/release/publish_swiftpm_source_tag.py", + "tools/dev/bun.sh", + "tools/release/publish_swiftpm_source_tag.mjs", "--target", head_ref, "--manifest", @@ -1608,12 +1929,10 @@ def publish_swift_release(head_ref: str) -> None: 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) + return all( + url_exists(maven_pom_url(coordinate, version)) + for coordinate in product_metadata.registry_package_names("oliphaunt-kotlin", "maven") + ) def publish_kotlin_maven(head_ref: str) -> None: @@ -1639,7 +1958,7 @@ def publish_kotlin_maven(head_ref: str) -> None: ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-kotlin", "--require-published", @@ -1659,7 +1978,7 @@ def publish_liboliphaunt_runtime_maven(head_ref: str) -> None: version = current_product_version("liboliphaunt-native") if succeeds( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-native", "--registry-kind", @@ -1676,7 +1995,7 @@ def publish_liboliphaunt_runtime_maven(head_ref: str) -> None: ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-native", "--registry-kind", @@ -1702,7 +2021,7 @@ def publish_react_native_npm(head_ref: str) -> None: ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-react-native", "--require-published", @@ -1726,7 +2045,7 @@ def publish_rust_crates_io(head_ref: str) -> None: native_version = current_product_version("liboliphaunt-native") run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-native", "--registry-kind", @@ -1738,7 +2057,7 @@ def publish_rust_crates_io(head_ref: str) -> None: ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-broker", "--registry-kind", @@ -1754,7 +2073,7 @@ def publish_rust_crates_io(head_ref: str) -> None: cargo_publish_manifest("oliphaunt", version, release_manifest) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-rust", "--require-published", @@ -1784,9 +2103,10 @@ def publish_node_direct_release_assets(head_ref: str) -> None: 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, product_metadata.ArtifactTarget]]: + package_dirs = npm_package_dirs_under(NODE_DIRECT_PACKAGE_ROOT) + packages: list[tuple[str, Path, product_metadata.ArtifactTarget]] = [] + for target in product_metadata.artifact_targets( product="oliphaunt-node-direct", kind="node-direct-addon", surface="npm-optional", @@ -1795,7 +2115,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 +2124,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 +2153,10 @@ def artifact_npm_package_targets( kind: str, surface: str, package_root: Path, -) -> list[tuple[str, Path, artifact_targets.ArtifactTarget]]: +) -> list[tuple[str, Path, product_metadata.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, product_metadata.ArtifactTarget]] = [] + for target in product_metadata.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 +2468,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 +2485,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 +2510,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 +2587,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 +2604,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( @@ -2265,21 +2656,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 +2699,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 +2769,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 +2780,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 product_metadata.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 +2819,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 +2836,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 = product_metadata.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 +2883,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 @@ -2473,19 +2934,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") != product_metadata.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( + product_metadata.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 +2961,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 product_metadata.extension_product_ids()) + ) and not ( + kind == "wasix-extension-aot" + and any(name.startswith(f"{product}-wasix-aot-") for product in product_metadata.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 +2982,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 @@ -2542,9 +3008,17 @@ def publish_liboliphaunt_cargo_artifacts(head_ref: str) -> None: for crate, _crate_path, manifest_path, role in packages: if role == "aggregator": cargo_publish_manifest(crate, version, manifest_path) + for crate, _crate_path, manifest_path, role in packages: + if role == "facade": + cargo_publish_manifest(crate, version, manifest_path) + verify_generated_cratesio_packages_published( + "liboliphaunt-native", + [crate for crate, _crate_path, _manifest_path, _role in packages], + version, + ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-native", "--registry-kind", @@ -2564,9 +3038,14 @@ def publish_liboliphaunt_wasix_cargo_artifacts(head_ref: str) -> None: packages = liboliphaunt_wasix_cargo_artifact_crates(version) for crate, _crate_path, manifest_path in packages: cargo_publish_manifest(crate, version, manifest_path) + verify_generated_cratesio_packages_published( + "liboliphaunt-wasix", + [crate for crate, _crate_path, _manifest_path in packages], + version, + ) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-wasix", "--registry-kind", @@ -2587,7 +3066,7 @@ def publish_broker_cargo_artifacts(head_ref: str) -> None: cargo_publish_manifest(crate, version, manifest_path) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-broker", "--registry-kind", @@ -2613,7 +3092,7 @@ def publish_node_direct_npm_optional_packages(head_ref: str) -> None: run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-node-direct", "--require-published", @@ -2631,7 +3110,7 @@ def publish_liboliphaunt_npm_packages(head_ref: str) -> None: npm_publish_packages(liboliphaunt_npm_tarballs(version), version) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "liboliphaunt-native", "--registry-kind", @@ -2651,7 +3130,7 @@ def publish_broker_npm_packages(head_ref: str) -> None: npm_publish_packages(broker_npm_tarballs(version), version) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-broker", "--registry-kind", @@ -2669,7 +3148,8 @@ def publish_typescript_npm_jsr(head_ref: str) -> None: verify_release_tag("oliphaunt-js", head_ref) run( [ - "tools/release/check_release_versions.py", + "tools/dev/bun.sh", + "tools/release/check_release_versions.mjs", "--products-json", '["oliphaunt-js"]', "--head-ref", @@ -2684,7 +3164,7 @@ def publish_typescript_npm_jsr(head_ref: str) -> None: npm_publish_pnpm_packed_package(ROOT / "src/sdks/js", product="oliphaunt-js") if succeeds( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-js", "--registry-kind", @@ -2698,7 +3178,7 @@ def publish_typescript_npm_jsr(head_ref: str) -> None: run(["pnpm", "exec", "jsr", "publish"], cwd=jsr_source) run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--product", "oliphaunt-js", "--require-published", @@ -2735,7 +3215,7 @@ def publish_selected_extension_release_assets(products: list[str], head_ref: str def extension_maven_artifacts_published(products: list[str]) -> bool: return succeeds( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--products-json", json.dumps(products), "--registry-kind", @@ -2748,7 +3228,7 @@ def extension_maven_artifacts_published(products: list[str]) -> bool: def require_extension_maven_artifacts_published(products: list[str]) -> None: run( [ - "tools/release/check_registry_publication.py", + *REGISTRY_PUBLICATION_CHECK, "--products-json", json.dumps(products), "--registry-kind", @@ -2795,9 +3275,7 @@ def command_publish_product_step(args: argparse.Namespace) -> None: 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": + if 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) @@ -2869,7 +3347,7 @@ def command_publish(args: argparse.Namespace, passthrough: list[str]) -> None: command_publish_product_step(args) return products_args = passthrough - run(["tools/release/check_publish_environment.py", *products_args]) + run(["tools/release/check_publish_environment.mjs", *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") @@ -2878,7 +3356,16 @@ 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"]: + for name in [ + "plan", + "check", + "check-registries", + "consumer-shape", + "ci-artifacts", + "ci-products", + "prepare-rust-release-source", + "verify-release", + ]: subparsers.add_parser(name, add_help=False) dry_run = subparsers.add_parser("publish-dry-run") @@ -2891,7 +3378,6 @@ def main(argv: list[str]) -> int: 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 @@ -2904,6 +3390,12 @@ def main(argv: list[str]) -> int: command_check_registries(passthrough) elif command == "consumer-shape": command_consumer_shape(passthrough) + elif command == "ci-artifacts": + command_ci_artifacts(passthrough) + elif command == "ci-products": + command_ci_products(passthrough) + elif command == "prepare-rust-release-source": + command_prepare_rust_release_source(passthrough) elif command == "verify-release": command_verify_release(passthrough) elif command == "publish-dry-run": diff --git a/tools/release/release_graph_query.mjs b/tools/release/release_graph_query.mjs new file mode 100644 index 00000000..85a15f27 --- /dev/null +++ b/tools/release/release_graph_query.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env bun +import { + allArtifactTargets, + extensionArtifactTargets, + rawArtifactTargetRows, +} from "./release-artifact-targets.mjs"; +import { + buildPlan, + compareText, + loadGraph, + normalizeFiles, + releaseOrder, + releaseProductProjectId, +} from "./release-graph.mjs"; +import { wasixCargoArtifactContract } 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 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 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 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 usage() { + return `usage: tools/release/release_graph_query.mjs [options] + +Commands: + graph + product-projects + 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] + extension-targets [--product PRODUCT] [--family native|wasix] [--published-only] + wasix-cargo-artifact-contract +`; +} + +function main(argv) { + const [command, ...rest] = argv; + if (command === "graph") { + runGraph(); + } else if (command === "product-projects") { + runProductProjects(); + } 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 === "extension-targets") { + runExtensionTargets(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..543bdd8c --- /dev/null +++ b/tools/release/strip_native_release_binaries.mjs @@ -0,0 +1,222 @@ +#!/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 stripToolFor(native) { + 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"] }; + } + if (native.archive && path.extname(native.path).toLowerCase() === ".lib") { + 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"] }; + } + 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) { + const before = (await stat(native.path)).size; + const command = stripToolFor(native); + 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; +} + +const roots = Bun.argv.slice(2); +if (roots.length === 0) { + fail("usage: strip_native_release_binaries.mjs [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)) { + 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..4a115ac8 --- /dev/null +++ b/tools/release/sync-release-pr.mjs @@ -0,0 +1,809 @@ +#!/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, + allArtifactTargets, + compareText, + currentProductVersion, + exactExtensionProducts, + extensionArtifactTargets, +} from "./release-artifact-targets.mjs"; +import { 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() { + const products = graphProducts(); + const known = new Set(Object.keys(products)); + const specs = {}; + for (const [product, config] of Object.entries(products)) { + const rawSpecs = config.compatibility_versions ?? {}; + if (rawSpecs === null || Array.isArray(rawSpecs) || typeof rawSpecs !== "object") { + fail(`${product}.compatibility_versions must be a table when present`); + } + for (const [specId, spec] of Object.entries(rawSpecs)) { + if (!specId) { + fail(`${product}.compatibility_versions keys must be non-empty strings`); + } + if (spec === null || Array.isArray(spec) || typeof spec !== "object") { + fail(`${product}.compatibility_versions.${specId} must be a table`); + } + const sourceProduct = spec.source_product; + if (typeof sourceProduct !== "string" || !sourceProduct) { + fail(`${product}.compatibility_versions.${specId}.source_product must be a non-empty string`); + } + if (!known.has(sourceProduct)) { + fail(`${product}.compatibility_versions.${specId}.source_product must name a release product, got ${JSON.stringify(sourceProduct)}`); + } + const specPath = spec.path; + const parser = spec.parser; + if (typeof specPath !== "string" || !specPath) { + fail(`${product}.compatibility_versions.${specId}.path must be a non-empty string`); + } + if (typeof parser !== "string" || !parser) { + fail(`${product}.compatibility_versions.${specId}.parser must be a non-empty string`); + } + if (!existsSync(path.join(ROOT, specPath))) { + fail(`${product}.compatibility_versions.${specId} path does not exist: ${specPath}`); + } + specs[specId] = [sourceProduct, specPath, parser]; + } + } + return specs; +} + +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()) { + versions[packageName] = `workspace:${await currentProductVersion(product, PREFIX)}`; + } + return versions; +} + +function typescriptOptionalRuntimePackageProducts() { + 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("no TypeScript optional runtime package targets found"); + } + const pairs = []; + const seen = new Set(); + for (const target of selected) { + if (typeof target.npmPackage !== "string" || !target.npmPackage) { + fail(`${target.id} must declare npmPackage for TypeScript optional dependencies`); + } + if (seen.has(target.npmPackage)) { + fail(`duplicate TypeScript optional package target ${target.npmPackage}`); + } + seen.add(target.npmPackage); + pairs.push([target.npmPackage, target.product]); + } + return pairs.sort(([left], [right]) => compareText(left, right)); +} + +function typescriptOptionalRuntimePackages() { + return typescriptOptionalRuntimePackageProducts().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 = ["src/extensions/tools/check-extension-model.py", write ? "--write-evidence" : "--check"]; + const before = Object.fromEntries(EXTENSION_EVIDENCE_PATHS.map((file) => [file, readOptionalText(file)])); + const result = spawnSync("python3", command, { + 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(`\`python3 ${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..c5114f8d 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) @@ -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),