From f18ab429e42a6c917329cb71e9c43a9673d91161 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:46:17 +0800 Subject: [PATCH] ci(release): pack rehearsal tarballs concurrently --- .github/workflows/release-vendor.yml | 4 ++- .github/workflows/release.yml | 6 ++-- scripts/release/pack.ts | 44 ++++++++++++++++++++++------ scripts/release/process.ts | 22 +++++++++++++- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index 34290bb7a2..8e1d531a03 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -71,8 +71,10 @@ jobs: - name: Build run: pnpm run build:lib:host + # Concurrency here is rehearsal-only: the credentialed publish workflows + # invoke release:pack without the flag and keep the strictly serial path. - name: Pack release tarballs - run: pnpm run release:pack --family vendor --out dist/npm-vendor + run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8 - name: Verify packed install run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b84a299b9..4ae1940c0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,8 +68,10 @@ jobs: - name: Build run: pnpm run build:official + # Concurrency here is rehearsal-only: the credentialed publish workflows + # invoke release:pack without the flag and keep the strictly serial path. - name: Pack release tarballs - run: pnpm run release:pack --family dsh --out dist/npm + run: pnpm run release:pack --family dsh --out dist/npm --concurrency 8 # The harness packages declare the vendored framework as a peer, and this # verification must not depend on the registry already carrying matching @@ -77,7 +79,7 @@ jobs: # publishes — so it installs that family's pack output too. Only dist/npm # is published. - name: Pack the vendored framework for verification - run: pnpm run release:pack --family vendor --out dist/npm-vendor + run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8 # dsh-sandbox-local declares the Landlock entry as a runtime dependency, so # the verification needs its tarball. Its platform packages stay out: they diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 3b68a1e49c..0742eedcda 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -11,7 +11,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' -import { isEntry, run } from './process.ts' +import { isEntry, runConcurrent } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' /** Where pack output lands when `--out` is omitted. */ @@ -24,8 +24,8 @@ const DEFAULT_OUTPUT = 'dist/npm' * @param destination - absolute output directory. * @returns The tarball filename. */ -function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string { - run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) +async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise { + await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) const filename = tarballName(member) const tarball = join(destination, filename) @@ -34,13 +34,27 @@ function packMember(family: ReleaseFamily, member: ReleaseMember, destination: s return filename } +/** + * @returns The validated `--concurrency` value; 1 (the default) packs the + * members one at a time, exactly as the credentialed publish workflows run it. + */ +function parseConcurrency(raw: string | undefined): number { + if (raw === undefined) return 1 + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`--concurrency must be a positive integer, got ${JSON.stringify(raw)}`) + } + return parsed +} + /** Pack the family named by `--family` into `--out`. */ -function main(): void { +async function main(): Promise { const { values } = parseArgs({ - options: { family: { type: 'string' }, out: { type: 'string' } }, + options: { family: { type: 'string' }, out: { type: 'string' }, concurrency: { type: 'string' } }, allowPositionals: false, }) - if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm]') + if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm] [--concurrency 1]') + const concurrency = parseConcurrency(values.concurrency) const family = releaseFamily(values.family) const root = process.cwd() @@ -52,11 +66,23 @@ function main(): void { rmSync(destination, { recursive: true, force: true }) mkdirSync(destination, { recursive: true }) - const order: string[] = [] - for (const member of members) order.push(packMember(family, member, destination)) + // Members pack in a bounded pool; the recorded publish order stays the + // members' order regardless of completion order, because each worker writes + // its result at the member's own position. + const order = new Array(members.length) + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(concurrency, members.length) }, async () => { + while (cursor < members.length) { + const index = cursor + cursor += 1 + const member = members[index] + if (member === undefined) break + order[index] = await packMember(family, member, destination) + } + })) writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`) console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) } -if (isEntry(import.meta.url)) main() +if (isEntry(import.meta.url)) await main() diff --git a/scripts/release/process.ts b/scripts/release/process.ts index f73d8d53b8..3147be4d4a 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -3,7 +3,7 @@ * `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours. */ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -83,6 +83,26 @@ export function run(command: string, args: readonly string[], options: RunOption if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) } +/** + * Run a command with inherited streams without blocking the event loop, so a + * caller can hold several commands in flight, and fail on a non-zero exit. + * Concurrent children interleave their output at line granularity. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + * @returns Resolves when the command exits with status zero. + */ +export function runConcurrent(command: string, args: readonly string[], options: RunOptions = {}): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' }) + child.once('error', rejectRun) + child.once('close', (status, signal) => { + if (status === 0) resolveRun() + else rejectRun(new Error(`${command} ${args.join(' ')} exited with ${String(status ?? signal)}`)) + }) + }) +} + /** * Return whether Node started the given module as the process entry point. * @param moduleUrl - the caller's `import.meta.url`.