diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ba40c4fb89cba..5a16a9b4c170f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -390,6 +390,15 @@ jobs: mkdir -p "$target_dir" cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + # The Linux archive ships libatomic.so.1 and points t3's RUNPATH at it. + - name: Install patchelf + if: inputs.cli_archive && inputs.platform == 'linux' + shell: bash + run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } + - name: Build CLI archive if: inputs.cli_archive shell: bash @@ -433,9 +442,6 @@ jobs: path: release-cli/* if-no-files-found: error - - uses: ./.github/actions/setup-apt-mirrors - if: inputs.platform == 'linux' - - name: Install Linux desktop build libraries if: inputs.platform == 'linux' shell: bash diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index ee7c45ada5dd6..47b8cef8a9794 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -251,6 +251,67 @@ const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, ta } }); +/** Node links libatomic.so.1, which Ubuntu server and container images lack, so t3 carries its own. */ +const bundleLinuxLibatomic = Effect.fn("bundleLinuxLibatomic")(function* (input: { + readonly contentDir: string; + readonly executablePath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ldd = yield* spawner.string(ChildProcess.make("ldd", [input.executablePath])); + const source = /libatomic\.so\.1 => (\/\S+)/.exec(ldd)?.[1]; + if (source === undefined) { + return yield* new CliArchiveInputMissingError({ + inputPath: "libatomic.so.1", + hint: `ldd did not resolve it for ${input.executablePath}. Install libatomic1 on the build host.`, + }); + } + yield* fs.makeDirectory(path.join(input.contentDir, "lib")); + yield* fs.copyFile(source, path.join(input.contentDir, "lib/libatomic.so.1")); + + // libatomic is GPL-3.0-or-later WITH GCC-exception-3.1, so its terms and source travel with it. + const owner = yield* spawner.string( + ChildProcess.make("dpkg-query", ["-S", yield* fs.realPath(source)]), + ); + // Keep the arch qualifier so a second installed arch cannot add its own line. + const [, qualifiedPkg, pkg] = /^(([^:\s]+)(?::[^:\s]+)?): /.exec(owner) ?? []; + const versions = qualifiedPkg + ? yield* spawner.string( + ChildProcess.make("dpkg-query", [ + "-W", + "-f=${Version} ${source:Package} ${source:Version}", + qualifiedPkg, + ]), + ) + : ""; + const [version, sourcePackage, sourceVersion] = versions.trim().split(" "); + if (!pkg || !version || !sourcePackage || !sourceVersion) { + return yield* new CliArchiveInputMissingError({ + inputPath: `dpkg package for ${source}`, + hint: "The archive copies libatomic's license and source reference from its Ubuntu package.", + }); + } + const header = [ + `libatomic.so.1 is copied from the Ubuntu package ${pkg} ${version}.`, + `Source: https://launchpad.net/ubuntu/+source/${sourcePackage}/${sourceVersion}`, + "License: GPL-3.0-or-later WITH GCC-exception-3.1. The package copyright file and GPL-3 follow.", + ].join("\n"); + const copyright = yield* fs.readFileString(`/usr/share/doc/${pkg}/copyright`); + const gpl3 = yield* fs.readFileString("/usr/share/common-licenses/GPL-3"); + yield* fs.writeFileString( + path.join(input.contentDir, "lib/libatomic.so.1.LICENSE"), + `${header}\n\n${copyright}\n${gpl3}`, + ); + + // RUNPATH resolves through symlinks and is not inherited by child processes, unlike LD_LIBRARY_PATH. + yield* runCommand( + ChildProcess.make("patchelf", ["--set-rpath", "$ORIGIN/lib", input.executablePath]), + "patchelf --set-rpath", + ); + yield* Effect.log(`[cli-archive] Bundled ${source} as lib/libatomic.so.1.`); +}); + const MacSigningConfig = Config.all({ identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option), appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option), @@ -444,11 +505,6 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { builtExecutable, `Run \`node apps/server/scripts/cli.ts build-exe${targetKey === hostKey ? "" : ` --target ${targetKey}`}\` first.`, ); - // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 - if (input.platform === "linux" && input.arch === "arm64") { - const bytes = yield* fs.readFile(builtExecutable); - yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); - } yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); yield* requireInput( resourceMonitorDir, @@ -473,6 +529,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { }); const executablePath = path.join(contentDir, executableName); + if (input.platform === "linux") { + yield* bundleLinuxLibatomic({ contentDir, executablePath }); + } + // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 + if (input.platform === "linux" && input.arch === "arm64") { + const bytes = yield* fs.readFile(executablePath); + yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); + } if (input.platform === "mac") { yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); } else if (input.platform === "win") { diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index c5344bdeb43fe..1318aa3c0c6dd 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -97,7 +97,12 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } const contentDir = path.join(scratch, root); const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3"); - for (const required of [executable, path.join(contentDir, "client/index.html")]) { + const bundledLibatomic = path.join(contentDir, "lib/libatomic.so.1"); + for (const required of [ + executable, + path.join(contentDir, "client/index.html"), + ...(platform === "linux" ? [bundledLibatomic, `${bundledLibatomic}.LICENSE`] : []), + ]) { if (!(yield* fs.exists(required))) { return yield* new CliArchiveSmokeError({ step: "checking the archive layout", @@ -106,6 +111,22 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } } + // The runner has libatomic installed; fresh Ubuntu servers do not. + if (platform === "linux") { + const ldd = yield* spawner.string(ChildProcess.make("ldd", [executable])); + // Older glibc prints the path as given, newer prints it resolved; compare both resolved. + const loaded = /libatomic\.so\.1 => (\/.*) \(0x[0-9a-f]+\)/.exec(ldd)?.[1]; + if ( + loaded === undefined || + (yield* fs.realPath(loaded)) !== (yield* fs.realPath(bundledLibatomic)) + ) { + return yield* new CliArchiveSmokeError({ + step: "checking that t3 loads the bundled libatomic", + detail: ldd, + }); + } + } + const version = yield* runExecutable(executable, ["--version"], contentDir); if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) { return yield* new CliArchiveSmokeError({