diff --git a/scripts/runtime/release-host-live.ts b/scripts/runtime/release-host-live.ts index 30a2b4b..7272c30 100644 --- a/scripts/runtime/release-host-live.ts +++ b/scripts/runtime/release-host-live.ts @@ -370,6 +370,12 @@ function packageRelease( `--target=${target.bunTarget}`, "--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", + // Keep @akua-dev/* as a real runtime import instead of bundling + // it: @akua-dev/native's platform .node binding is a binary file + // the bundler cannot inline. The compiled binary resolves it via + // process.execPath at runtime (src/runtime/services-live.ts). + "--external", + "@akua-dev/*", `--outfile=${binaryPath}`, ]); const bytes = yield* attempt("read compiled executable", () => @@ -767,10 +773,15 @@ function stagePackageRuntime( const nativeDestination = join(scopeRoot, "native"); const archiveFiles: string[] = []; const runtimeDirectories = new Set(); - for (const packageName of ["native", "native-engines"]) { + for (const packageName of ["native", "native-engines", "sdk"]) { const manifest = yield* readPackageManifest(packageRoot, packageName); const declaredFiles = yield* packageManifestFiles(manifest, packageName); - for (const file of ["package.json", ...declaredFiles]) { + const expandedFiles = yield* expandPackageManifestFiles( + packageRoot, + packageName, + declaredFiles, + ); + for (const file of ["package.json", ...expandedFiles]) { const destination = join(scopeRoot, packageName, file); yield* stagePackageRuntimeFile( join(packageRoot, packageName, file), @@ -856,6 +867,57 @@ function packageManifestFiles( ); } +// @akua-dev/sdk declares directory entries (e.g. "dist") in package.json's +// `files`, unlike native/native-engines' flat file lists — expand every +// directory entry into its individual files so each one gets staged. +function expandPackageManifestFiles( + packageRoot: string, + packageName: string, + files: string[], +): Effect.Effect { + return Effect.gen(function* () { + const expanded: string[] = []; + for (const file of files) { + const absolute = join(packageRoot, packageName, file); + const info = yield* attempt("stat package runtime file", () => + statSync(absolute), + ); + if (info.isDirectory()) { + expanded.push(...(yield* walkPackageDirectory(absolute, file))); + } else { + expanded.push(file); + } + } + return expanded; + }); +} + +function walkPackageDirectory( + absoluteDirectory: string, + relativeDirectory: string, +): Effect.Effect { + return Effect.gen(function* () { + const entries = yield* attempt("read package runtime directory", () => + readdirSync(absoluteDirectory, { withFileTypes: true }), + ); + const files: string[] = []; + for (const entry of entries) { + const relativePath = join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + files.push( + ...(yield* walkPackageDirectory( + join(absoluteDirectory, entry.name), + relativePath, + )), + ); + } else { + files.push(relativePath); + } + } + return files; + }); +} + function packageManifestMain( value: unknown, packageName: string, diff --git a/scripts/runtime/release-services.ts b/scripts/runtime/release-services.ts index 68bcd9a..e5e00c1 100644 --- a/scripts/runtime/release-services.ts +++ b/scripts/runtime/release-services.ts @@ -69,7 +69,13 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ archive: "tar.gz", executable: "akua", bindingPackage: "native-linux-x64-gnu", - runner: "akua-x64-ci-v2", + // GitHub-hosted, matching every other leg in this matrix. The + // self-hosted akua-x64-ci-v2 pool's shared egress IP gets + // codeload.github.com 429-rate-limited under concurrent CI load + // (observed 3x in a row on this exact job), and this leg only + // needs a generic Linux x64 execution environment to smoke-test + // an already-built artifact. + runner: "ubuntu-24.04", homebrew: { os: "linux", arch: "intel" }, }, { diff --git a/src/runtime/services-live.ts b/src/runtime/services-live.ts index 5643638..b579be2 100644 --- a/src/runtime/services-live.ts +++ b/src/runtime/services-live.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import { chmod, mkdir, @@ -10,7 +11,7 @@ import { import { dirname, join } from "node:path"; import { Data, Duration, Effect, Layer } from "effect"; -import { execute as executePackageCommand } from "@akua-dev/sdk/execute"; +import type * as PackageExecuteModule from "@akua-dev/sdk/execute"; import { FetchHttpClient, HttpBody, @@ -180,10 +181,20 @@ export const PublicInputLive = Layer.succeed(PublicInput, { }); export const PackageCliLive = Layer.succeed(PackageCli, { - execute: (args) => Effect.try({ - try: () => executePackageCommand(args, { binName: "akua pkg" }), - catch: (cause) => new PackageCliFailure({ cause }), - }), + execute: (args) => + resolvePackageExecute.pipe( + Effect.flatMap((execute) => + Effect.try({ + try: () => execute(args, { binName: "akua pkg" }), + catch: (cause) => new PackageCliFailure({ cause }), + }), + ), + Effect.mapError((cause) => + cause instanceof PackageCliFailure + ? cause + : new PackageCliFailure({ cause }), + ), + ), }); export const CliLive: Layer.Layer = Layer.mergeAll( @@ -313,3 +324,50 @@ function isRecord(value: unknown): value is Record { function isNotFound(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } + +// `bun build --compile` cannot statically bundle @akua-dev/sdk/execute: it +// transitively loads @akua-dev/native's platform .node binding, which is a +// real binary file, not JS the bundler can inline. Marking the package +// --external (release-host-live.ts) makes the compiled binary keep a real +// runtime import instead of inlining it, but a compiled executable resolves +// bare specifiers against its own embedded virtual filesystem ($bunfs), not +// the real one — so it never sees the sidecar node_modules staged next to +// it on disk. process.execPath, unlike module-resolution base paths, does +// resolve to the executable's real filesystem location even when compiled, +// so an absolute dynamic import from there reaches the sidecar package. +// This is the one place in the CLI that needs a dynamic import for that +// reason; dev/test/build (no compiled sidecar present) fall back to normal +// package resolution. The result is cached for the process lifetime via +// Effect.cached rather than a hand-rolled mutable variable. +const resolvePackageExecute: Effect.Effect< + typeof PackageExecuteModule.execute, + PackageCliFailure +> = Effect.runSync( + Effect.cached( + Effect.sync(() => + join( + dirname(process.execPath), + "node_modules", + "@akua-dev", + "sdk", + "dist", + "execute.js", + ), + ).pipe( + Effect.flatMap((sidecarPath) => + Effect.sync(() => existsSync(sidecarPath)).pipe( + Effect.map((exists) => + exists ? sidecarPath : "@akua-dev/sdk/execute", + ), + ), + ), + Effect.flatMap((specifier) => + Effect.tryPromise({ + try: () => import(specifier), + catch: (cause) => new PackageCliFailure({ cause }), + }), + ), + Effect.map((loaded: typeof PackageExecuteModule) => loaded.execute), + ), + ), +); diff --git a/test/release.test.ts b/test/release.test.ts index 6b1113f..754d231 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -41,6 +41,13 @@ async function makePackageRuntimeFixture(root: string): Promise { packageName: "native-engines", files: ["index.js", "helm-engine.wasm", "kustomize-engine.wasm"], }, + { + // Real @akua-dev/sdk declares a directory entry ("dist") rather than + // a flat file list, unlike native/native-engines — this exercises + // directory expansion in packageManifestFiles. + packageName: "sdk", + files: ["dist", "README.md"], + }, ]; for (const runtimePackage of packages) { const { packageName, files } = runtimePackage; @@ -51,6 +58,15 @@ async function makePackageRuntimeFixture(root: string): Promise { `${JSON.stringify({ name: `@akua-dev/${packageName}`, files })}\n`, ); for (const file of files) { + if (packageName === "sdk" && file === "dist") { + await mkdir(join(directory, "dist"), { recursive: true }); + await writeFile(join(directory, "dist", "mod.js"), "sdk/dist/mod.js\n"); + await writeFile( + join(directory, "dist", "execute.js"), + "sdk/dist/execute.js\n", + ); + continue; + } await writeFile(join(directory, file), `${packageName}/${file}\n`); } } @@ -184,7 +200,7 @@ describe("release target contract", () => { archive: "tar.gz", executable: "akua", bindingPackage: "native-linux-x64-gnu", - runner: "akua-x64-ci-v2", + runner: "ubuntu-24.04", homebrew: { os: "linux", arch: "intel" }, }, { @@ -214,7 +230,7 @@ describe("release target contract", () => { { target: "darwin-arm64", runner: "macos-15" }, { target: "darwin-x64", runner: "macos-15-intel" }, { target: "linux-arm64", runner: "ubuntu-24.04-arm" }, - { target: "linux-x64", runner: "akua-x64-ci-v2" }, + { target: "linux-x64", runner: "ubuntu-24.04" }, { target: "windows-x64", runner: "windows-2025" }, ], }); @@ -853,6 +869,82 @@ describe("release target contract", () => { } }); + test("stages the sdk package's runtime files, including directory-listed entries, into the archive", async () => { + const release = (await import("../scripts/release")) as Record< + string, + unknown + >; + const hostTargetId = release.hostTargetId as () => Effect.Effect< + string, + Error, + ReleaseHost + >; + const packageExistingExecutables = + release.packageExistingExecutables as (input: { + version: string; + outputDir: string; + binaries: Record; + packageRoot: string; + }) => Effect.Effect; + const artifactName = release.artifactName as ( + version: string, + target: { id: string }, + ) => string; + const root = await makeReleaseTempDir(); + + try { + const source = join(root, "akua-fixture"); + const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); + await writeFile(source, "#!/bin/sh\nexit 0\n"); + await chmod(source, 0o755); + const { RELEASE_TARGETS: targets } = release as { + RELEASE_TARGETS: Array<{ id: string }>; + }; + runRelease( + packageExistingExecutables({ + version: "1.2.3", + outputDir, + binaries: Object.fromEntries( + targets.map((target) => [target.id, source]), + ), + packageRoot, + }), + ); + + // The staging directory is cleaned up after packaging, so verify the + // real produced archive contents (what an installer actually + // extracts), not the intermediate .staging tree. + const targetId = runRelease(hostTargetId()); + const target = targets.find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`Unknown host target: ${targetId}`); + const archivePath = join(outputDir, artifactName("1.2.3", target)); + const extractDir = join(root, "extracted"); + await mkdir(extractDir, { recursive: true }); + const extract = Bun.spawnSync({ + cmd: ["tar", "-xzf", archivePath, "-C", extractDir], + stderr: "pipe", + }); + expect(extract.exitCode).toBe(0); + + const sdkDir = join(extractDir, "node_modules", "@akua-dev", "sdk"); + expect(await readFile(join(sdkDir, "dist", "mod.js"), "utf8")).toBe( + "sdk/dist/mod.js\n", + ); + expect(await readFile(join(sdkDir, "dist", "execute.js"), "utf8")).toBe( + "sdk/dist/execute.js\n", + ); + expect(await readFile(join(sdkDir, "README.md"), "utf8")).toBe( + "sdk/README.md\n", + ); + expect(await readFile(join(sdkDir, "package.json"), "utf8")).toContain( + '"@akua-dev/sdk"', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("rejects an install-smoke executable whose longer version contains the expected version", async () => { const release = (await import("../scripts/release")) as Record< string,