From 8ae3246370d8486bf3263445f677b88216c8d53d Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Mon, 18 May 2026 16:07:25 -0500 Subject: [PATCH] Merge pull request #89 from tloncorp/po/testing-strategy-phase-1c Harden release packaging and publish smoke gates --- .github/workflows/publish.yml | 164 +++++++++++++--- package.json | 5 +- scripts/cli-test-matrix.ts | 4 +- scripts/release-binary-smoke.ts | 31 +++ scripts/release-package-smoke.ts | 302 +++++++++++++++++++++++++++++ scripts/release-package.ts | 288 ++++++++++++++++++++++++++++ scripts/release-utils.ts | 317 +++++++++++++++++++++++++++++++ 7 files changed, 1081 insertions(+), 30 deletions(-) create mode 100644 scripts/release-binary-smoke.ts create mode 100644 scripts/release-package-smoke.ts create mode 100644 scripts/release-package.ts create mode 100644 scripts/release-utils.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5274d41..fb5fa46 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,18 +12,45 @@ on: type: boolean default: true +permissions: + contents: read + jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.4 + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + build: + needs: quality + timeout-minutes: 20 strategy: + fail-fast: false matrix: include: - - os: macos-14 + - os: macos-15 target: darwin-arm64 - - os: macos-14 # Cross-compile x64 from ARM64 + - os: macos-15-intel target: darwin-x64 - - os: ubuntu-latest + - os: ubuntu-24.04 target: linux-x64 - - os: ubuntu-latest # Cross-compile arm64 from x64 + - os: ubuntu-24.04-arm target: linux-arm64 runs-on: ${{ matrix.os }} @@ -45,23 +72,99 @@ jobs: - name: Build binary run: node scripts/build-all.js --target=${{ matrix.target }} - - name: Upload artifact + - name: Smoke binary + run: npm run release:binary-smoke -- --binary npm/${{ matrix.target }}/tlon + + - name: Upload binary artifact uses: actions/upload-artifact@v4 with: name: binary-${{ matrix.target }} path: npm/${{ matrix.target }}/tlon + if-no-files-found: error - publish: + package: needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.4 + + - name: Install dependencies + run: npm ci + + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + pattern: binary-* + path: artifacts + + - name: Pack release tarballs + run: npm run release:package -- --artifacts-dir artifacts --out-dir release-tarballs + + - name: Upload package tarballs + uses: actions/upload-artifact@v4 + with: + name: package-tarballs + path: release-tarballs/* + if-no-files-found: error + + package-smoke: + needs: package + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: darwin-arm64 + - os: macos-15-intel + target: darwin-x64 + - os: ubuntu-24.04 + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.4 + + - name: Install dependencies + run: npm ci + + - name: Download package tarballs + uses: actions/download-artifact@v4 + with: + name: package-tarballs + path: release-tarballs + + - name: Smoke installed package + run: npm run release:package-smoke -- --tarball-dir release-tarballs --target=${{ matrix.target }} + + publish: + needs: package-smoke + runs-on: ubuntu-24.04 + timeout-minutes: 15 permissions: contents: read id-token: write - # Run on: version tag push, workflow_call from bump, or manual dispatch with dry_run=false - if: >- - (startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push') || - github.event_name == 'workflow_call' || - github.event.inputs.dry_run == 'false' steps: - uses: actions/checkout@v4 @@ -71,27 +174,34 @@ jobs: node-version-file: .tool-versions registry-url: "https://registry.npmjs.org" - - name: Download all artifacts + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.4 + + - name: Install dependencies + run: npm ci + + - name: Download package tarballs uses: actions/download-artifact@v4 with: - path: artifacts + name: package-tarballs + path: release-tarballs - - name: Copy binaries to npm packages - run: | - for target in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do - mkdir -p npm/$target - cp artifacts/binary-$target/tlon npm/$target/ - chmod +x npm/$target/tlon - done + - name: Read package version + id: package + run: node -p "'version=' + require('./package.json').version" >> "$GITHUB_OUTPUT" + + - name: Dry run + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' }} + run: echo "Dry run: skipping npm publish" - name: Publish platform packages + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run == 'false' }} run: | for target in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do - echo "Publishing @tloncorp/tlon-skill-$target..." - cd npm/$target - npm publish --access public --provenance - cd ../.. + npm publish "release-tarballs/tloncorp-tlon-skill-${target}-${{ steps.package.outputs.version }}.tgz" --access public --provenance done - - name: Publish main package - run: npm publish --access public --provenance + - name: Publish root package + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run == 'false' }} + run: npm publish "release-tarballs/tloncorp-tlon-skill-${{ steps.package.outputs.version }}.tgz" --access public --provenance diff --git a/package.json b/package.json index 55e8392..4c5cbc3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "tlon": "./bin/tlon.js" }, "files": [ - "bin/", + "bin/tlon.js", "scripts/postinstall.js", "SKILL.md", "references/" @@ -18,6 +18,9 @@ "build:smoke": "npm run build && bun run scripts/build-smoke.ts", "check": "npm run typecheck && npm test && npm run build:smoke", "dev:link": "bun run build:all && cp \"npm/$(node -e 'console.log(process.platform + \"-\" + process.arch)')/tlon\" bin/", + "release:binary-smoke": "bun run scripts/release-binary-smoke.ts", + "release:package": "bun run scripts/release-package.ts", + "release:package-smoke": "bun run scripts/release-package-smoke.ts", "test": "npm run test:unit && npm run test:integration", "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage ./scripts ./tests/unit ./tests/hermetic", "test:integration": "bun test ./tests/hermetic", diff --git a/scripts/cli-test-matrix.ts b/scripts/cli-test-matrix.ts index 400c9be..21732a9 100644 --- a/scripts/cli-test-matrix.ts +++ b/scripts/cli-test-matrix.ts @@ -174,7 +174,7 @@ export const MISSING_REQUIRED_CASES: CliCase[] = [ export const SPECIAL_INPUT_CASES: CliCase[] = [ usageErrorCase( - "contacts update-profile missing flag value", + "contacts update-profile missing option value", ["contacts", "update-profile", "--nickname"], "Usage: tlon contacts update-profile" ), @@ -189,7 +189,7 @@ export const SPECIAL_INPUT_CASES: CliCase[] = [ "Usage: tlon posts edit" ), usageErrorCase( - "groups update missing update flag", + "groups update missing update option", ["groups", "update", "~host/group-slug"], "At least one of --title, --description, --image, or --cover is required" ), diff --git a/scripts/release-binary-smoke.ts b/scripts/release-binary-smoke.ts new file mode 100644 index 0000000..a0ffafd --- /dev/null +++ b/scripts/release-binary-smoke.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fail, smokeCliBinary } from "./release-utils"; + +function argValue(name: string): string | undefined { + const prefix = `${name}=`; + const inline = process.argv.find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + + const index = process.argv.indexOf(name); + if (index !== -1) { + return process.argv[index + 1]; + } + + return undefined; +} + +const rootDir = process.cwd(); +const binaryArg = argValue("--binary"); +if (!binaryArg) { + fail("Usage: bun run scripts/release-binary-smoke.ts --binary "); +} + +const packageJson = JSON.parse( + readFileSync(resolve(rootDir, "package.json"), "utf-8") +) as { version: string }; + +smokeCliBinary(resolve(rootDir, binaryArg), packageJson.version, rootDir); +console.log(`ok - smoked ${binaryArg}`); diff --git a/scripts/release-package-smoke.ts b/scripts/release-package-smoke.ts new file mode 100644 index 0000000..b88ef1a --- /dev/null +++ b/scripts/release-package-smoke.ts @@ -0,0 +1,302 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { + assertExecutableFile, + assertPlatformTarball, + assertRootTarball, + baseEnv, + currentTarget, + fail, + isTarget, + nodeModulesPackagePath, + npmPackFilename, + PLATFORM_PACKAGES, + runCommand, + sha256File, + smokeCliBinary, + TARGETS, + type Target, +} from "./release-utils"; + +type RootPackageJson = { + name: string; + version: string; + optionalDependencies?: Record; +}; + +type LockPackage = { + version?: string; + resolved?: string; + optionalDependencies?: Record; +}; + +type PackageLock = { + packages?: Record; +}; + +type BinaryHashes = Partial< + Record< + Target, + { + packageName: string; + sha256: string; + stagedPath: string; + } + > +>; + +function argValue(name: string): string | undefined { + const prefix = `${name}=`; + const inline = process.argv.find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + + const index = process.argv.indexOf(name); + if (index !== -1) { + return process.argv[index + 1]; + } + + return undefined; +} + +function parseTarget(): Target { + const value = argValue("--target") ?? currentTarget(); + if (!isTarget(value)) { + fail(`Unknown target ${value}. Supported targets: ${TARGETS.join(", ")}`); + } + return value; +} + +function requirePath(path: string, label: string): void { + if (!existsSync(path)) { + fail(`Missing ${label}: ${path}`); + } +} + +function assertLockResolvedFromTarball( + lock: PackageLock, + packageName: string, + expectedVersion: string, + tarballPath: string +): void { + const key = `node_modules/${packageName}`; + const entry = lock.packages?.[key]; + if (!entry) { + fail(`package-lock.json is missing ${key}`); + } + if (entry.version !== expectedVersion) { + fail(`${key} version ${entry.version ?? ""} did not match ${expectedVersion}`); + } + if (!entry.resolved?.startsWith("file:")) { + fail(`${key} did not resolve from a local file tarball: ${entry.resolved ?? ""}`); + } + if (!entry.resolved.includes(basename(tarballPath))) { + fail(`${key} resolved to ${entry.resolved}, expected ${basename(tarballPath)}`); + } +} + +function assertRootDeclaresNativeOptionalDependency( + installedRootPackageJson: RootPackageJson, + rootLockEntry: LockPackage | undefined, + nativePackageName: string +): void { + const expectedVersion = installedRootPackageJson.version; + const packageJsonVersion = + installedRootPackageJson.optionalDependencies?.[nativePackageName]; + if (packageJsonVersion !== expectedVersion) { + fail( + `Installed root package optionalDependencies.${nativePackageName} was ${packageJsonVersion ?? ""}, expected ${expectedVersion}` + ); + } + + const lockVersion = rootLockEntry?.optionalDependencies?.[nativePackageName]; + if (lockVersion !== expectedVersion) { + fail( + `package-lock root optionalDependencies.${nativePackageName} was ${lockVersion ?? ""}, expected ${expectedVersion}` + ); + } +} + +function assertWrapperBinResolvesToRootWrapper( + wrapperPath: string, + rootPackageDir: string +): void { + const expectedWrapper = join(rootPackageDir, "bin", "tlon.js"); + const actual = realpathSync(wrapperPath); + const expected = realpathSync(expectedWrapper); + if (actual !== expected) { + fail( + `node_modules/.bin/tlon resolved to ${actual}, expected root wrapper ${expected}` + ); + } +} + +function assertNoRunnableNonNativePackages(projectDir: string, target: Target): void { + for (const otherTarget of TARGETS) { + if (otherTarget === target) { + continue; + } + const packageDir = nodeModulesPackagePath(projectDir, PLATFORM_PACKAGES[otherTarget]); + const binaryPath = join(packageDir, "tlon"); + if (!existsSync(binaryPath)) { + continue; + } + + const stat = statSync(binaryPath); + if (stat.isFile() && (stat.mode & 0o111) !== 0) { + fail(`Non-native package ${PLATFORM_PACKAGES[otherTarget]} installed runnable binary ${binaryPath}`); + } + } +} + +const rootDir = process.cwd(); +const target = parseTarget(); +const runtimeTarget = currentTarget(); +if (target !== runtimeTarget) { + fail(`Package smoke target ${target} must match native runner ${runtimeTarget}`); +} + +const tarballDir = resolve(rootDir, argValue("--tarball-dir") ?? "release-tarballs"); +const packageJson = JSON.parse( + readFileSync(join(rootDir, "package.json"), "utf-8") +) as RootPackageJson; +const nativePackageName = PLATFORM_PACKAGES[target]; +const rootTarball = join( + tarballDir, + npmPackFilename(packageJson.name, packageJson.version) +); +const nativeTarball = join( + tarballDir, + npmPackFilename(nativePackageName, packageJson.version) +); +const hashesPath = join(tarballDir, "binary-hashes.json"); + +requirePath(rootTarball, "root tarball"); +requirePath(nativeTarball, `${target} tarball`); +requirePath(hashesPath, "binary hash manifest"); +assertRootTarball(rootTarball, rootDir); +assertPlatformTarball(nativeTarball, rootDir, target); + +const hashes = JSON.parse(readFileSync(hashesPath, "utf-8")) as BinaryHashes; +const expectedHash = hashes[target]?.sha256; +if (!expectedHash) { + fail(`binary-hashes.json is missing ${target}`); +} +if (hashes[target]?.packageName !== nativePackageName) { + fail(`binary-hashes.json package for ${target} did not match ${nativePackageName}`); +} + +const tempRoot = mkdtempSync(join(tmpdir(), "tlon-package-smoke-")); +try { + const projectDir = join(tempRoot, "project"); + const cacheDir = join(tempRoot, "npm-cache"); + const userConfig = join(tempRoot, "npmrc"); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync( + join(projectDir, "package.json"), + JSON.stringify({ private: true }, null, 2), + "utf-8" + ); + writeFileSync( + userConfig, + [ + "registry=http://127.0.0.1:9/", + "fetch-retries=0", + "fetch-timeout=1000", + "fetch-retry-mintimeout=1000", + "fetch-retry-maxtimeout=1000", + "audit=false", + "fund=false", + "", + ].join("\n"), + "utf-8" + ); + + runCommand( + "npm", + [ + "--cache", + cacheDir, + "--userconfig", + userConfig, + "install", + "--fetch-retries=0", + "--fetch-timeout=1000", + "--fetch-retry-mintimeout=1000", + "--fetch-retry-maxtimeout=1000", + "--no-audit", + "--fund=false", + rootTarball, + nativeTarball, + ], + { + cwd: projectDir, + env: baseEnv(tempRoot), + } + ); + + const lock = JSON.parse( + readFileSync(join(projectDir, "package-lock.json"), "utf-8") + ) as PackageLock; + assertLockResolvedFromTarball(lock, packageJson.name, packageJson.version, rootTarball); + assertLockResolvedFromTarball(lock, nativePackageName, packageJson.version, nativeTarball); + + const rootPackageDir = nodeModulesPackagePath(projectDir, packageJson.name); + const rootLockEntry = lock.packages?.[`node_modules/${packageJson.name}`]; + const installedRootPackageJson = JSON.parse( + readFileSync(join(rootPackageDir, "package.json"), "utf-8") + ) as RootPackageJson; + assertRootDeclaresNativeOptionalDependency( + installedRootPackageJson, + rootLockEntry, + nativePackageName + ); + + const rootLocalBinary = join(rootPackageDir, "bin", "tlon"); + if (existsSync(rootLocalBinary)) { + fail(`Installed root package must not contain ${rootLocalBinary}`); + } + + const nativePackageDir = nodeModulesPackagePath(projectDir, nativePackageName); + const nativePackageJson = JSON.parse( + readFileSync(join(nativePackageDir, "package.json"), "utf-8") + ) as RootPackageJson; + if (nativePackageJson.name !== nativePackageName) { + fail(`Installed native package name did not match ${nativePackageName}`); + } + if (nativePackageJson.version !== packageJson.version) { + fail( + `Installed native package version ${nativePackageJson.version} did not match ${packageJson.version}` + ); + } + + const nativeBinary = join(nativePackageDir, "tlon"); + assertExecutableFile(nativeBinary, `${target} installed native binary`); + const installedHash = sha256File(nativeBinary); + if (installedHash !== expectedHash) { + fail( + `${target} installed binary hash ${installedHash} did not match workflow-built hash ${expectedHash}` + ); + } + + assertNoRunnableNonNativePackages(projectDir, target); + + const wrapper = join(projectDir, "node_modules", ".bin", "tlon"); + assertWrapperBinResolvesToRootWrapper(wrapper, rootPackageDir); + smokeCliBinary(wrapper, packageJson.version, projectDir); + console.log(`ok - package smoke passed for ${target}`); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/release-package.ts b/scripts/release-package.ts new file mode 100644 index 0000000..0d2b895 --- /dev/null +++ b/scripts/release-package.ts @@ -0,0 +1,288 @@ +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + assertExecutableFile, + assertPlatformTarball, + assertRootTarball, + baseEnv, + currentTarget, + fail, + isTarget, + npmPackFilename, + PLATFORM_PACKAGES, + runCommand, + sha256File, + TARGETS, + type Target, +} from "./release-utils"; + +type RootPackageJson = { + name: string; + version: string; + files?: string[]; +}; + +type PackOutput = Array<{ + filename: string; +}>; + +type BinaryHashes = Partial< + Record< + Target, + { + packageName: string; + sha256: string; + stagedPath: string; + } + > +>; + +function argValue(name: string): string | undefined { + const prefix = `${name}=`; + const inline = process.argv.find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + + const index = process.argv.indexOf(name); + if (index !== -1) { + return process.argv[index + 1]; + } + + return undefined; +} + +function parseTargets(): Target[] { + const value = argValue("--targets") ?? argValue("--target"); + if (!value) { + return [...TARGETS]; + } + if (value === "current") { + return [currentTarget()]; + } + + const targets = value.split(",").map((target) => target.trim()); + if (targets.length === 0) { + fail("--targets must include at least one target"); + } + return targets.map((target) => { + if (!isTarget(target)) { + fail(`Unknown target ${target}. Supported targets: ${TARGETS.join(", ")}`); + } + return target; + }); +} + +function requirePath(path: string, label: string): void { + if (!existsSync(path)) { + fail(`Missing ${label}: ${path}`); + } +} + +function ensureEmptyOutputDir(outDir: string): void { + mkdirSync(outDir, { recursive: true }); + const entries = readdirSync(outDir); + if (entries.length > 0) { + fail(`Output directory must be empty: ${outDir}`); + } +} + +function copyRequired(source: string, destination: string, label: string): void { + requirePath(source, label); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true }); +} + +function copyOptional(source: string, destination: string): void { + if (!existsSync(source)) { + return; + } + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true }); +} + +function assertRootFilesField(packageJson: RootPackageJson): void { + const files = packageJson.files ?? []; + const required = [ + "bin/tlon.js", + "scripts/postinstall.js", + "SKILL.md", + "references/", + ]; + + for (const entry of required) { + if (!files.includes(entry)) { + fail(`Root package.json files is missing ${entry}`); + } + } + for (const forbidden of ["bin", "bin/"]) { + if (files.includes(forbidden)) { + fail(`Root package.json files must not include ${forbidden}`); + } + } +} + +function parsePackOutput(stdout: string): PackOutput { + const jsonStart = stdout.indexOf("["); + if (jsonStart === -1) { + fail(`npm pack did not return JSON:\n${stdout}`); + } + + try { + const parsed = JSON.parse(stdout.slice(jsonStart)) as PackOutput; + if (parsed.length !== 1 || !parsed[0]?.filename) { + fail(`Unexpected npm pack JSON:\n${stdout}`); + } + return parsed; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`Failed to parse npm pack JSON: ${message}\n${stdout}`); + } +} + +function packDirectory( + packageDir: string, + outDir: string, + cacheDir: string, + userConfig: string, + env: Record +): string { + const result = runCommand( + "npm", + [ + "--cache", + cacheDir, + "--userconfig", + userConfig, + "pack", + "--json", + "--pack-destination", + outDir, + ], + { + cwd: packageDir, + env, + } + ); + + const [packed] = parsePackOutput(result.stdout); + const tarballPath = resolve(outDir, packed.filename); + requirePath(tarballPath, "packed tarball"); + return tarballPath; +} + +function stageRootPackage(rootDir: string, stageDir: string): RootPackageJson { + const packageJsonPath = join(rootDir, "package.json"); + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf-8") + ) as RootPackageJson; + assertRootFilesField(packageJson); + + copyRequired(packageJsonPath, join(stageDir, "package.json"), "root package.json"); + copyRequired(join(rootDir, "bin", "tlon.js"), join(stageDir, "bin", "tlon.js"), "bin/tlon.js"); + copyRequired( + join(rootDir, "scripts", "postinstall.js"), + join(stageDir, "scripts", "postinstall.js"), + "scripts/postinstall.js" + ); + copyRequired(join(rootDir, "SKILL.md"), join(stageDir, "SKILL.md"), "SKILL.md"); + copyRequired(join(rootDir, "references"), join(stageDir, "references"), "references"); + copyOptional(join(rootDir, "README.md"), join(stageDir, "README.md")); + copyOptional(join(rootDir, "LICENSE"), join(stageDir, "LICENSE")); + + return packageJson; +} + +function stagePlatformPackage( + rootDir: string, + stageDir: string, + artifactsDir: string, + target: Target +): { stagedBinaryPath: string; packageStageDir: string; sha256: string } { + const artifactPath = join(artifactsDir, `binary-${target}`, "tlon"); + requirePath(artifactPath, `${target} build artifact`); + + const sourcePackageDir = join(rootDir, "npm", target); + const sourcePackageJson = join(sourcePackageDir, "package.json"); + const packageStageDir = join(stageDir, "npm", target); + const stagedBinaryPath = join(packageStageDir, "tlon"); + copyRequired(sourcePackageJson, join(packageStageDir, "package.json"), `${target} package.json`); + copyRequired(artifactPath, stagedBinaryPath, `${target} build artifact`); + chmodSync(stagedBinaryPath, 0o755); + assertExecutableFile(stagedBinaryPath, `${target} package-stage binary`); + + return { + stagedBinaryPath, + packageStageDir, + sha256: sha256File(stagedBinaryPath), + }; +} + +const rootDir = process.cwd(); +const artifactsDir = resolve(rootDir, argValue("--artifacts-dir") ?? "artifacts"); +const outDir = resolve(rootDir, argValue("--out-dir") ?? "release-tarballs"); +const targets = parseTargets(); + +ensureEmptyOutputDir(outDir); + +const tempRoot = mkdtempSync(join(tmpdir(), "tlon-release-package-")); +try { + const cacheDir = join(tempRoot, "npm-cache"); + const userConfig = join(tempRoot, "npmrc"); + const env = baseEnv(tempRoot); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(userConfig, "audit=false\nfund=false\n", "utf-8"); + + const rootStageDir = join(tempRoot, "root"); + const packageJson = stageRootPackage(rootDir, rootStageDir); + const rootTarball = packDirectory(rootStageDir, outDir, cacheDir, userConfig, env); + const expectedRootTarball = resolve( + outDir, + npmPackFilename(packageJson.name, packageJson.version) + ); + if (rootTarball !== expectedRootTarball) { + fail(`Unexpected root tarball name: ${rootTarball}`); + } + assertRootTarball(rootTarball, rootDir); + console.log(`ok - packed ${rootTarball}`); + + const hashes: BinaryHashes = {}; + for (const target of targets) { + const staged = stagePlatformPackage(rootDir, tempRoot, artifactsDir, target); + const tarball = packDirectory(staged.packageStageDir, outDir, cacheDir, userConfig, env); + const expectedTarball = resolve( + outDir, + npmPackFilename(PLATFORM_PACKAGES[target], packageJson.version) + ); + if (tarball !== expectedTarball) { + fail(`Unexpected ${target} tarball name: ${tarball}`); + } + assertPlatformTarball(tarball, rootDir, target); + hashes[target] = { + packageName: PLATFORM_PACKAGES[target], + sha256: staged.sha256, + stagedPath: `npm/${target}/tlon`, + }; + console.log(`ok - packed ${tarball}`); + } + + writeFileSync( + join(outDir, "binary-hashes.json"), + `${JSON.stringify(hashes, null, 2)}\n`, + "utf-8" + ); + console.log(`ok - wrote ${join(outDir, "binary-hashes.json")}`); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/release-utils.ts b/scripts/release-utils.ts new file mode 100644 index 0000000..7028f51 --- /dev/null +++ b/scripts/release-utils.ts @@ -0,0 +1,317 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { normalizeCliOutput } from "./cli-test-matrix"; + +export const TARGETS = [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-arm64", +] as const; + +export type Target = (typeof TARGETS)[number]; + +export const PLATFORM_PACKAGES: Record = { + "darwin-arm64": "@tloncorp/tlon-skill-darwin-arm64", + "darwin-x64": "@tloncorp/tlon-skill-darwin-x64", + "linux-x64": "@tloncorp/tlon-skill-linux-x64", + "linux-arm64": "@tloncorp/tlon-skill-linux-arm64", +}; + +export const CLI_TIMEOUT_MS = 15_000; +export const NPM_TIMEOUT_MS = 120_000; + +export type CommandResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +export function fail(message: string): never { + console.error(message); + process.exit(1); +} + +export function isTarget(value: string): value is Target { + return TARGETS.includes(value as Target); +} + +export function currentTarget(): Target { + const target = `${process.platform}-${process.arch}`; + if (!isTarget(target)) { + fail(`Unsupported runtime target: ${target}`); + } + return target; +} + +export function npmPackFilename(packageName: string, version: string): string { + return `${packageName.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; +} + +export function nodeModulesPackagePath(projectDir: string, packageName: string): string { + if (packageName.startsWith("@")) { + const [scope, name] = packageName.split("/"); + if (!scope || !name) { + fail(`Invalid scoped package name: ${packageName}`); + } + return join(projectDir, "node_modules", scope, name); + } + return join(projectDir, "node_modules", packageName); +} + +export function sha256File(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function assertExecutableFile(path: string, label: string): void { + if (!existsSync(path)) { + fail(`Missing ${label}: ${path}`); + } + const stat = statSync(path); + if (!stat.isFile()) { + fail(`${label} is not a file: ${path}`); + } + if ((stat.mode & 0o111) === 0) { + fail(`${label} is not executable: ${path}`); + } +} + +export function baseEnv( + _tempRoot: string, + extraEnv: Record = {} +): Record { + const env: Record = {}; + for (const key of ["PATH", "HOME", "SystemRoot", "WINDIR", "ASDF_DIR", "ASDF_DATA_DIR"]) { + const value = process.env[key]; + if (value) { + env[key] = value; + } + } + return { ...env, ...extraEnv }; +} + +export function hermeticCliEnv( + tempRoot: string, + extraEnv: Record = {} +): Record { + const home = join(tempRoot, "home"); + const cacheDir = join(tempRoot, "cache"); + mkdirSync(home, { recursive: true }); + mkdirSync(cacheDir, { recursive: true }); + + return { + ...baseEnv(tempRoot), + HOME: home, + TLON_CACHE_DIR: cacheDir, + OPENCLAW_CONFIG: join(home, "missing-openclaw.json"), + ...extraEnv, + }; +} + +export function runCommand( + command: string, + args: string[], + options: { + cwd: string; + env?: Record; + timeoutMs?: number; + allowFailure?: boolean; + } +): CommandResult { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf-8", + timeout: options.timeoutMs ?? NPM_TIMEOUT_MS, + }); + + if (result.error) { + fail( + `Failed to run ${command} ${args.join(" ")}: ${result.error.message}` + ); + } + + const commandResult = { + exitCode: result.status ?? 1, + stdout: normalizeCliOutput(result.stdout), + stderr: normalizeCliOutput(result.stderr), + }; + + if (!options.allowFailure && commandResult.exitCode !== 0) { + fail( + `${command} ${args.join(" ")} exited ${commandResult.exitCode}\nstdout:\n${commandResult.stdout}\nstderr:\n${commandResult.stderr}` + ); + } + + return commandResult; +} + +export function runCli( + binaryPath: string, + args: string[], + options: { + cwd: string; + tempRoot: string; + } +): CommandResult { + return runCommand(binaryPath, args, { + cwd: options.cwd, + env: hermeticCliEnv(options.tempRoot), + timeoutMs: CLI_TIMEOUT_MS, + allowFailure: true, + }); +} + +export function assertCliSuccess( + name: string, + result: CommandResult, + assertions: { + stdout?: string; + stderr?: string; + stdoutIncludes?: string[]; + } = {} +): void { + if (result.exitCode !== 0) { + fail( + `${name}: expected exit 0, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + if (assertions.stdout !== undefined && result.stdout !== assertions.stdout) { + fail( + `${name}: unexpected stdout\nexpected:\n${assertions.stdout}\nactual:\n${result.stdout}` + ); + } + if (assertions.stderr !== undefined && result.stderr !== assertions.stderr) { + fail( + `${name}: unexpected stderr\nexpected:\n${assertions.stderr}\nactual:\n${result.stderr}` + ); + } + for (const expected of assertions.stdoutIncludes ?? []) { + if (!result.stdout.includes(expected)) { + fail( + `${name}: stdout did not include ${JSON.stringify(expected)}\nstdout:\n${result.stdout}` + ); + } + } +} + +export function smokeCliBinary( + binaryPath: string, + expectedVersion: string, + cwd: string +): void { + assertExecutableFile(binaryPath, "CLI binary"); + + const tempRoot = mkdtempSync(join(tmpdir(), "tlon-release-cli-")); + try { + assertCliSuccess( + `${basename(binaryPath)} --version`, + runCli(binaryPath, ["--version"], { cwd, tempRoot }), + { + stdout: `${expectedVersion}\n`, + stderr: "", + } + ); + assertCliSuccess( + `${basename(binaryPath)} --help`, + runCli(binaryPath, ["--help"], { cwd, tempRoot }), + { + stderr: "", + stdoutIncludes: ["Usage:"], + } + ); + assertCliSuccess( + `${basename(binaryPath)} activity --help`, + runCli(binaryPath, ["activity", "--help"], { cwd, tempRoot }), + { + stderr: "", + stdoutIncludes: ["Usage: tlon activity"], + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +export function listTarballFiles(tarballPath: string, cwd: string): string[] { + const result = runCommand("tar", ["-tzf", tarballPath], { + cwd, + timeoutMs: 30_000, + }); + return result.stdout + .split("\n") + .filter(Boolean) + .map((entry) => entry.replace(/^package\//, "")) + .filter(Boolean) + .sort(); +} + +export function assertRootTarball(tarballPath: string, cwd: string): void { + const files = listTarballFiles(tarballPath, cwd); + if (!files.includes("bin/tlon.js")) { + fail(`Root tarball is missing bin/tlon.js: ${tarballPath}`); + } + if (files.includes("bin/tlon")) { + fail(`Root tarball must not contain bin/tlon: ${tarballPath}`); + } + + const unexpected = files.filter((file) => { + if ( + file === "package.json" || + file === "README.md" || + file === "LICENSE" || + file === "SKILL.md" || + file === "bin/tlon.js" || + file === "scripts/postinstall.js" + ) { + return false; + } + return !file.startsWith("references/"); + }); + + if (unexpected.length > 0) { + fail( + `Root tarball contains unexpected files:\n${unexpected + .map((file) => ` - ${file}`) + .join("\n")}` + ); + } +} + +export function assertPlatformTarball( + tarballPath: string, + cwd: string, + target: Target +): void { + const files = listTarballFiles(tarballPath, cwd); + for (const required of ["package.json", "tlon"]) { + if (!files.includes(required)) { + fail(`${target} tarball is missing ${required}: ${tarballPath}`); + } + } + + const unexpected = files.filter( + (file) => + file !== "package.json" && + file !== "tlon" && + file !== "README.md" && + file !== "LICENSE" + ); + if (unexpected.length > 0) { + fail( + `${target} tarball contains unexpected files:\n${unexpected + .map((file) => ` - ${file}`) + .join("\n")}` + ); + } +}