浏览代码

ci(release): pack rehearsal tarballs concurrently

imccyu 1 周之前
父节点
当前提交
f18ab429e4
共有 4 个文件被更改,包括 63 次插入13 次删除
  1. 3 1
      .github/workflows/release-vendor.yml
  2. 4 2
      .github/workflows/release.yml
  3. 35 9
      scripts/release/pack.ts
  4. 21 1
      scripts/release/process.ts

+ 3 - 1
.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

+ 4 - 2
.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

+ 35 - 9
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<string> {
+  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<void> {
   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 <dsh|vendor> [--out dist/npm]')
+  if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--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<string>(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()

+ 21 - 1
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<void> {
+  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`.