zx-bulk-release 3.1.6 → 3.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [3.1.8](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.7...v3.1.8) (2026-04-13)
2
+
3
+ ### Fixes & improvements
4
+ * perf: minor internal code imprs ([f3f0aae](https://github.com/semrel-extra/zx-bulk-release/commit/f3f0aae2e7dc4045a6573a8e270acdf6a3d983d8))
5
+
6
+ ## [3.1.7](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.6...v3.1.7) (2026-04-13)
7
+
8
+ ### Fixes & improvements
9
+ * perf: apply traverseQueue to delivery phase ([6f1ba58](https://github.com/semrel-extra/zx-bulk-release/commit/6f1ba58885e7739b23de1d895f80ea4680645347))
10
+ * perf: let `--verify` accept out dir ([80015bb](https://github.com/semrel-extra/zx-bulk-release/commit/80015bb067306892bbcc969c5ee2700a256b22f4))
11
+
1
12
  ## [3.1.6](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.5...v3.1.6) (2026-04-13)
2
13
 
3
14
  ### Fixes & improvements
package/README.md CHANGED
@@ -46,7 +46,7 @@ GH_TOKEN=ghtoken GH_USER=username NPM_TOKEN=npmtoken npx zx-bulk-release [opts]
46
46
  |------------------------------|---------------------------------------------------------------------------------------------------|------------------|
47
47
  | `--receive` | Consume rebuild signal, analyze, preflight. Writes `zbr-context.json`. Run before deps install. | |
48
48
  | `--pack [dir]` | Pack only: build, test, and write delivery tars to `dir`. No credentials needed. | `parcels` |
49
- | `--verify [dir]` | Verify untrusted parcels against context, copy valid ones to `parcels/`. Use `--context` for path. | `parcels` |
49
+ | `--verify [in:out]` | Verify untrusted parcels against context, copy valid ones to output dir. Use `--context` for path. | `parcels:parcels` |
50
50
  | `--context <path>` | Path to trusted `zbr-context.json` (used with `--verify`). | `zbr-context.json` |
51
51
  | `--deliver [dir]` | Deliver only: read tars from `dir` and run delivery channels. No source code needed. | `parcels` |
52
52
  | `--ignore` | Packages to ignore: `a, b` | |
@@ -181,7 +181,7 @@ jobs:
181
181
  path: parcels-unverified/
182
182
 
183
183
  # verify — validate untrusted parcels against trusted context
184
- - run: npx zx-bulk-release --verify parcels-unverified/
184
+ - run: npx zx-bulk-release --verify parcels-unverified/:parcels/
185
185
 
186
186
  # deliver — only verified parcels
187
187
  - run: npx zx-bulk-release --deliver
@@ -381,7 +381,7 @@ Each step has a uniform signature `(pkg, ctx)`:
381
381
  - **`preflight`** — checks tag availability on remote, re-resolves version on conflict, skips duplicates.
382
382
  - **`build`** — runs `buildCmd` (with dep traversal and optional npm artifact fetch).
383
383
  - **`test`** — runs `testCmd`.
384
- - **`pack`** — stages delivery artifacts into self-describing tar containers (`npm pack`, docs copy, assets, release notes). Each tar is named `parcel.{sha7}.{channel}.{tag}.{hash6}.tar` and contains a `manifest.json` with channel name, delivery instructions, and template credentials (`${{ENV_VAR}}`). A directive meta-parcel is also generated, listing all parcels and their delivery steps. After this step, everything the courier needs is outside the project dir.
384
+ - **`pack`** — stages delivery artifacts into self-describing tar containers (`npm pack`, docs copy, assets, release notes). Each tar is named `parcel.{sha7}.{channel}.{name}.{version}.{hash6}.tar` and contains a `manifest.json` with channel name, delivery instructions, and template credentials (`${{ENV_VAR}}`). A directive meta-parcel is also generated, listing all parcels and their delivery steps. After this step, everything the courier needs is outside the project dir.
385
385
  - **`publish`** — hands tars to courier's `deliver()`, runs `cmd` channel separately. Tag push is handled by the `git-tag` channel.
386
386
  - **`clean`** — restores `package.json` files and unsets git user config.
387
387
 
@@ -397,13 +397,13 @@ Set `config.releaseRules` to override the default rules preset:
397
397
  ### Tar containers
398
398
  Each delivery artifact is a self-describing tar archive:
399
399
  ```
400
- parcel.{sha7}.{channel}.{tag}.{hash6}.tar
400
+ parcel.{sha7}.{channel}.{name}.{version}.{hash6}.tar
401
401
  manifest.json — channel name, delivery params, template credentials
402
402
  package.tgz — (npm channel) npm tarball
403
403
  assets/ — (gh-release channel) release assets
404
404
  docs/ — (gh-pages channel) documentation files
405
405
  ```
406
- The `sha7` prefix groups all parcels of one commit. The `hash6` suffix is a content hash for deduplication — two builds of the same commit producing identical content yield the same filename (last-writer-wins), while different content gets a different hash.
406
+ The `sha7` prefix groups all parcels of one commit. `name` is the sanitized package name (`@scope/pkg` → `scope-pkg`). The `hash6` suffix is a content hash for deduplication — two builds of the same commit producing identical content yield the same filename (last-writer-wins), while different content gets a different hash.
407
407
 
408
408
  A **directive** meta-parcel (`parcel.{sha7}.directive.{ts}.tar`) is generated alongside regular parcels. It contains the complete delivery map: package queue, per-package channel steps, and an authoritative list of parcel filenames. The directive enables coordinated delivery — see [DELIVER_SPEC.md](./DELIVER_SPEC.md).
409
409
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zx-bulk-release",
3
3
  "alias": "bulk-release",
4
- "version": "3.1.6",
4
+ "version": "3.1.8",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
@@ -15,7 +15,7 @@ Modes:
15
15
  (no flags) All-in-one: analyze, build, test, pack, deliver
16
16
  --receive Analyze & preflight, write zbr-context.json. Run BEFORE deps install
17
17
  --pack [dir] Build, test, pack tars to dir [default: parcels]
18
- --verify [input-dir] Validate parcels against context, copy valid to parcels/ [default: parcels]
18
+ --verify [in:out] Validate parcels against context, copy to out dir [default: parcels]
19
19
  --deliver [dir] Deliver parcels through channels [default: parcels]
20
20
 
21
21
  Options:
@@ -1,10 +1,10 @@
1
- export const DEFAULT_GIT_COMMITTER_NAME = 'Semrel Extra Bot'
2
- export const DEFAULT_GIT_COMMITTER_EMAIL = 'semrel-extra-bot@hotmail.com'
3
-
4
1
  import {$, fs, path, tempy, copy} from 'zx-extra'
5
2
  import {log} from '../log.js'
6
3
  import {attempt2, attempt3, memoizeBy} from '../../util.js'
7
4
 
5
+ export const DEFAULT_GIT_COMMITTER_NAME = 'Semrel Extra Bot'
6
+ export const DEFAULT_GIT_COMMITTER_EMAIL = 'semrel-extra-bot@hotmail.com'
7
+
8
8
  export const fetchRepo = memoizeBy(async ({cwd: _cwd, branch, origin: _origin, basicAuth}) => {
9
9
  if (!_origin && !_cwd) throw new Error('fetchRepo requires either origin or cwd')
10
10
  const origin = _origin || (await getRepo(_cwd, {basicAuth})).repoAuthedUrl
@@ -3,8 +3,9 @@ import _fs from 'node:fs/promises'
3
3
  import _path from 'node:path'
4
4
  import tar from 'tar-stream'
5
5
  import {Readable} from 'node:stream'
6
- import {log} from '../log.js'
7
6
  import {$, semver, fs, INI, fetch, tempy} from 'zx-extra'
7
+
8
+ import {log} from '../log.js'
8
9
  import {attempt2, memoizeBy} from '../../util.js'
9
10
 
10
11
  const FETCH_TIMEOUT_MS = 15_000
@@ -2,6 +2,7 @@ import {$, tempy, within, path, semver, fs} from 'zx-extra'
2
2
  import {unpackTar} from '../tar.js'
3
3
  import {log} from '../log.js'
4
4
  import {pool} from '../../util.js'
5
+ import {traverseQueue} from '../depot/deps.js'
5
6
  import {scanDirectives, invalidateOrphans} from '../parcel/directive.js'
6
7
  import {tryLock, unlock, signalRebuild} from './semaphore.js'
7
8
  import gitTag from './channels/git-tag.js'
@@ -160,43 +161,38 @@ const deliverParcel = async (tarPath, channelName, pkgName, version, env, {dryRu
160
161
  return res === 'duplicate' ? 'duplicate' : 'ok'
161
162
  }
162
163
 
163
- const deliverDirective = async (directive, tarMap, env, {dryRun, cwd}) => {
164
+ const deliverPkg = async (pkgName, pkg, tarMap, env, {dryRun, cwd}) => {
164
165
  const entries = []
165
166
  const conflicts = []
166
167
  const skipped = []
167
168
 
168
- for (const pkgName of directive.queue) {
169
- const pkg = directive.packages[pkgName]
170
- if (!pkg) continue
171
-
172
- let pkgConflict = false
169
+ let pkgConflict = false
173
170
 
174
- for (const step of pkg.deliver) {
175
- if (pkgConflict) break
171
+ for (const step of pkg.deliver) {
172
+ if (pkgConflict) break
176
173
 
177
- const results = await Promise.all(step.map(async (channelName) => {
178
- const parcelName = (pkg.parcels || []).find(p => p.includes(`.${channelName}.`))
179
- const tarPath = parcelName && tarMap.get(parcelName)
180
- if (!tarPath) return 'missing'
174
+ const results = await Promise.all(step.map(async (channelName) => {
175
+ const parcelName = (pkg.parcels || []).find(p => p.includes(`.${channelName}.`))
176
+ const tarPath = parcelName && tarMap.get(parcelName)
177
+ if (!tarPath) return 'missing'
181
178
 
182
- return deliverParcel(tarPath, channelName, pkgName, pkg.version, env, {dryRun, cwd})
183
- }))
179
+ return deliverParcel(tarPath, channelName, pkgName, pkg.version, env, {dryRun, cwd})
180
+ }))
184
181
 
185
- for (let i = 0; i < step.length; i++) {
186
- const r = results[i]
187
- if (r === 'skip') skipped.push({channelName: step[i], pkg: pkgName})
188
- else if (r !== 'missing' && r !== 'already') entries.push({channel: step[i], name: pkgName, version: pkg.version})
189
- }
182
+ for (let i = 0; i < step.length; i++) {
183
+ const r = results[i]
184
+ if (r === 'skip') skipped.push({channelName: step[i], pkg: pkgName})
185
+ else if (r !== 'missing' && r !== 'already') entries.push({channel: step[i], name: pkgName, version: pkg.version})
186
+ }
190
187
 
191
- if (results.includes('conflict')) {
192
- pkgConflict = true
193
- conflicts.push(pkgName)
194
- for (const p of pkg.parcels || []) {
195
- const tp = tarMap.get(p)
196
- if (!tp) continue
197
- const c = await fs.readFile(tp, 'utf8').catch(() => null)
198
- if (c !== 'released') await fs.writeFile(tp, 'conflict')
199
- }
188
+ if (results.includes('conflict')) {
189
+ pkgConflict = true
190
+ conflicts.push(pkgName)
191
+ for (const p of pkg.parcels || []) {
192
+ const tp = tarMap.get(p)
193
+ if (!tp) continue
194
+ const c = await fs.readFile(tp, 'utf8').catch(() => null)
195
+ if (c !== 'released') await fs.writeFile(tp, 'conflict')
200
196
  }
201
197
  }
202
198
  }
@@ -204,6 +200,25 @@ const deliverDirective = async (directive, tarMap, env, {dryRun, cwd}) => {
204
200
  return {entries, conflicts, skipped}
205
201
  }
206
202
 
203
+ const deliverDirective = async (directive, tarMap, env, {dryRun, cwd}) => {
204
+ const entries = []
205
+ const conflicts = []
206
+ const skipped = []
207
+
208
+ const prev = new Map(Object.entries(directive.prev || {}))
209
+
210
+ await traverseQueue({queue: directive.queue, prev, cb: async (pkgName) => {
211
+ const pkg = directive.packages[pkgName]
212
+ if (!pkg) return
213
+ const r = await deliverPkg(pkgName, pkg, tarMap, env, {dryRun, cwd})
214
+ entries.push(...r.entries)
215
+ conflicts.push(...r.conflicts)
216
+ skipped.push(...r.skipped)
217
+ }})
218
+
219
+ return {entries, conflicts, skipped}
220
+ }
221
+
207
222
  // --- Main entry point ---
208
223
 
209
224
  export const deliver = async (tars, env = process.env, {concurrency = 4, dryRun = false, cwd} = {}) => {
@@ -10,8 +10,8 @@ import {publish} from '../depot/steps/publish.js'
10
10
  import {clean} from '../depot/steps/clean.js'
11
11
  import {test} from '../depot/steps/test.js'
12
12
  import {preflight} from '../depot/reconcile.js'
13
- import {buildDirective, PARCELS_DIR} from '../parcel/index.js'
14
13
  import {CONTEXT_FILE, readContext} from '../depot/context.js'
14
+ import {buildDirective, PARCELS_DIR} from '../parcel/index.js'
15
15
 
16
16
  export const runPack = async ({cwd, env, flags}, ctx) => {
17
17
  const {report, packages, queue, prev} = ctx
@@ -4,9 +4,9 @@ import {PARCELS_DIR, verifyParcels} from '../parcel/index.js'
4
4
  import {CONTEXT_FILE, readContext} from '../depot/context.js'
5
5
 
6
6
  export const runVerify = async ({cwd, flags}) => {
7
- const inputDir = typeof flags.verify === 'string' ? flags.verify : PARCELS_DIR
7
+ const [inputDir = PARCELS_DIR, outputDir_ = PARCELS_DIR] = typeof flags.verify === 'string' ? flags.verify.split(':') : []
8
+ const outputDir = path.resolve(cwd, outputDir_)
8
9
  const contextPath = typeof flags.context === 'string' ? flags.context : path.resolve(cwd, CONTEXT_FILE)
9
- const outputDir = path.resolve(cwd, PARCELS_DIR)
10
10
 
11
11
  log.info(`verifying parcels in ${inputDir} against ${contextPath}`)
12
12
 
@@ -17,6 +17,7 @@ export const buildDirective = async (ctx, packedPkgs, outputDir) => {
17
17
  const {sha, timestamp} = packedPkgs[0].ctx.git
18
18
  const packages = {}
19
19
  const parcels = []
20
+ const packedNames = new Set(packedPkgs.map(p => p.name))
20
21
 
21
22
  for (const pkg of packedPkgs) {
22
23
  const pkgParcels = (pkg.tars || []).map(t => path.basename(t))
@@ -29,12 +30,21 @@ export const buildDirective = async (ctx, packedPkgs, outputDir) => {
29
30
  parcels.push(...pkgParcels)
30
31
  }
31
32
 
33
+ // Store dependency graph for parallel delivery in topo order
34
+ const prev = {}
35
+ if (ctx.prev) {
36
+ for (const name of packedNames) {
37
+ const deps = (ctx.prev.get(name) || []).filter(d => packedNames.has(d))
38
+ if (deps.length) prev[name] = deps
39
+ }
40
+ }
41
+
32
42
  const sha7 = sha.slice(0, 7)
33
43
  const manifest = {
34
44
  channel: 'directive',
35
45
  sha, timestamp: Number(timestamp),
36
46
  queue: packedPkgs.map(p => p.name),
37
- packages, parcels,
47
+ packages, parcels, prev,
38
48
  }
39
49
 
40
50
  const tarPath = path.join(outputDir, `parcel.${sha7}.directive.${timestamp}.tar`)
@@ -1,8 +1,7 @@
1
1
  import tar from 'tar-stream'
2
2
  import crypto from 'node:crypto'
3
- import {fs, path} from 'zx-extra'
4
3
  import {pipeline} from 'node:stream/promises'
5
- import {createWriteStream, createReadStream} from 'node:fs'
4
+ import {fs, path} from 'zx-extra'
6
5
 
7
6
  export const packTar = async (tarPath, manifest, files = []) => {
8
7
  await fs.ensureDir(path.dirname(tarPath))
@@ -20,13 +19,13 @@ export const packTar = async (tarPath, manifest, files = []) => {
20
19
  }
21
20
 
22
21
  pack.finalize()
23
- await pipeline(pack, createWriteStream(tarPath))
22
+ await pipeline(pack, fs.createWriteStream(tarPath))
24
23
  return tarPath
25
24
  }
26
25
 
27
26
  export const hashFile = async (filePath) => {
28
27
  const hash = crypto.createHash('sha1')
29
- await pipeline(createReadStream(filePath), hash)
28
+ await pipeline(fs.createReadStream(filePath), hash)
30
29
  return hash.digest('hex').slice(0, 6)
31
30
  }
32
31
 
@@ -68,7 +67,7 @@ export const unpackTar = async (tarPath, destDir) => {
68
67
  extract.on('error', reject)
69
68
  })
70
69
 
71
- await pipeline(createReadStream(tarPath), extract)
70
+ await pipeline(fs.createReadStream(tarPath), extract)
72
71
  await done
73
72
  return {manifest, dir: destDir}
74
73
  }
@@ -19,7 +19,7 @@ function proc(stdout = '', code = 0) {
19
19
  return p
20
20
  }
21
21
 
22
- export function createMock(responses = []) {
22
+ export function createSpawnMock(responses = []) {
23
23
  const calls = []
24
24
  const spawn = (cmd, args) => {
25
25
  const command = args?.[args.length - 1] || cmd