zx-bulk-release 3.1.0 → 3.1.1

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,9 @@
1
+ ## [3.1.1](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.0...v3.1.1) (2026-04-12)
2
+
3
+ ### Fixes & improvements
4
+ * perf: add `--help` ([99fcfde](https://github.com/semrel-extra/zx-bulk-release/commit/99fcfde332c7730e335e46b4c2a8c844d53f8986))
5
+ * refactor: separate parcel subdomain ([b168044](https://github.com/semrel-extra/zx-bulk-release/commit/b16804497693b42998cea2638ff6d11bd29eb84f))
6
+
1
7
  ## [3.1.0](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.5...v3.1.0) (2026-04-12)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "zx-bulk-release",
3
3
  "alias": "bulk-release",
4
- "version": "3.1.0",
4
+ "version": "3.1.1",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/main/js/index.js",
9
9
  "./test-utils": "./src/test/js/utils/repo.js",
10
- "./meta": "./src/main/js/processor/generators/meta.js"
10
+ "./meta": "./src/main/js/post/depot/generators/meta.js"
11
11
  },
12
12
  "bin": "./src/main/js/cli.js",
13
13
  "files": [
@@ -146,18 +146,18 @@ const unzip = (stream, {pick, omit, cwd = process.cwd(), strip = 0} = {}) => new
146
146
  extract.on('entry', ({name, type}, stream, cb) => {
147
147
  const _name = safePath(strip ? name.split('/').slice(strip).join('/') : name)
148
148
  const fp = _path.join(cwd, _name)
149
+ const skip = type !== 'file' || omit?.includes(_name) || (pick && !pick.includes(_name))
149
150
 
150
- let data = ''
151
+ const chunks = []
151
152
  stream.on('data', (chunk) => {
152
- if (type !== 'file' || omit?.includes(_name) || (pick && !pick.includes(_name))) return
153
- data += chunk
153
+ if (!skip) chunks.push(chunk)
154
154
  })
155
155
 
156
156
  stream.on('end', () => {
157
- if (data) {
157
+ if (chunks.length) {
158
158
  results.push(
159
159
  _fs.mkdir(_path.dirname(fp), {recursive: true})
160
- .then(() => _fs.writeFile(fp, data, 'utf8'))
160
+ .then(() => _fs.writeFile(fp, Buffer.concat(chunks)))
161
161
  )
162
162
  }
163
163
  cb()
@@ -1,4 +1,4 @@
1
- import {$} from 'zx-extra'
1
+ import {fs, path} from 'zx-extra'
2
2
  import {queuefy} from 'queuefy'
3
3
  import {fetchRepo, pushCommit} from '../../api/git.js'
4
4
  import {log} from '../../log.js'
@@ -15,8 +15,10 @@ const run = queuefy(async (manifest, dir) => {
15
15
  log.info('push changelog')
16
16
 
17
17
  const _cwd = await fetchRepo({branch, origin: repoAuthedUrl, basicAuth: ghBasicAuth})
18
+ const filePath = path.resolve(_cwd, file)
19
+ const prev = await fs.readFile(filePath, 'utf8').catch(() => '')
20
+ await fs.outputFile(filePath, releaseNotes + '\n' + prev)
18
21
 
19
- await $({cwd: _cwd})`echo ${releaseNotes}"\n$(cat ./${file})" > ./${file}`
20
22
  await pushCommit({
21
23
  branch,
22
24
  msg,
@@ -1,5 +1,4 @@
1
- import {pushTag} from '../../api/git.js'
2
- import {DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
1
+ import {pushTag, DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
3
2
 
4
3
  export const isTagConflict = (e) =>
5
4
  /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
@@ -1,8 +1,7 @@
1
1
  import {queuefy} from 'queuefy'
2
2
  import {log} from '../../log.js'
3
3
  import {pushCommit} from '../../api/git.js'
4
- import {getArtifactPath, isAssetMode} from '../../depot/generators/meta.js'
5
- import {prepareMeta} from '../../depot/generators/meta.js'
4
+ import {getArtifactPath, isAssetMode, prepareMeta} from '../../depot/generators/meta.js'
6
5
  import {hasHigherVersion} from '../seniority.js'
7
6
 
8
7
  const pushMetaBranch = queuefy(async (manifest, dir) => {
@@ -1,7 +1,8 @@
1
1
  import {$, tempy, within, path, semver, fs} from 'zx-extra'
2
2
  import {unpackTar} from '../tar.js'
3
3
  import {log} from '../log.js'
4
- import {scanDirectives, invalidateOrphans} from './directive.js'
4
+ import {pool} from '../../util.js'
5
+ import {scanDirectives, invalidateOrphans} from '../parcel/directive.js'
5
6
  import {tryLock, unlock, signalRebuild} from './semaphore.js'
6
7
  import gitTag from './channels/git-tag.js'
7
8
  import meta from './channels/meta.js'
@@ -11,11 +12,16 @@ import ghPages from './channels/gh-pages.js'
11
12
  import changelog from './channels/changelog.js'
12
13
  import cmd from './channels/cmd.js'
13
14
 
14
- export {buildParcels} from './parcel.js'
15
15
 
16
16
  export const channels = {'git-tag': gitTag, meta, npm, 'gh-release': ghRelease, 'gh-pages': ghPages, changelog, cmd}
17
17
  export const defaultOrder = ['git-tag', 'meta', 'npm', 'gh-release', 'gh-pages', 'changelog', 'cmd']
18
18
 
19
+ export const getActiveChannels = (pkg, channelNames, snapshot) =>
20
+ channelNames.filter(n => {
21
+ const ch = channels[n]
22
+ return ch && ch.transport !== false && (!snapshot || ch.snapshot) && ch.when(pkg)
23
+ })
24
+
19
25
  export const prepare = async (names, pkg) => {
20
26
  for (const n of names) await channels[n]?.prepare?.(pkg)
21
27
  }
@@ -61,22 +67,6 @@ const openParcel = async (tarPath, env) => {
61
67
  return {ch, resolved, destDir, tarPath}
62
68
  }
63
69
 
64
- const pool = async (tasks, concurrency, fn) => {
65
- const active = new Set()
66
- let i = 0
67
- await new Promise((resolve, reject) => {
68
- const next = () => {
69
- if (i >= tasks.length && active.size === 0) return resolve()
70
- while (active.size < concurrency && i < tasks.length) {
71
- const t = tasks[i++]
72
- const p = fn(t).then(() => { active.delete(p); next() }, reject)
73
- active.add(p)
74
- }
75
- }
76
- next()
77
- })
78
- }
79
-
80
70
  export const inspect = async (tars, env = process.env) => {
81
71
  const parcels = []
82
72
  const skipped = []
@@ -1,7 +1,6 @@
1
1
  import {fs, path} from 'zx-extra'
2
- import {channels as channelRegistry} from '../courier/index.js'
3
2
 
4
- const CONTEXT_FILE = '.zbr-context.json'
3
+ export const CONTEXT_FILE = '.zbr-context.json'
5
4
 
6
5
  export const writeContext = async (cwd, context) => {
7
6
  const filePath = path.resolve(cwd, CONTEXT_FILE)
@@ -9,22 +8,24 @@ export const writeContext = async (cwd, context) => {
9
8
  return filePath
10
9
  }
11
10
 
12
- export const readContext = async (cwd) => {
13
- const filePath = path.resolve(cwd, CONTEXT_FILE)
14
- try {
15
- return await fs.readJson(filePath)
16
- } catch {
17
- return null
11
+ export const readContext = async (filePath) => {
12
+ let data
13
+ try { data = await fs.readJson(filePath) } catch {
14
+ throw new Error(`context not found: ${filePath}`)
18
15
  }
19
- }
20
16
 
21
- const getActiveChannels = (pkg, channelNames, snapshot) =>
22
- channelNames.filter(n => {
23
- const ch = channelRegistry[n]
24
- return ch && ch.transport !== false && (!snapshot || ch.snapshot) && ch.when(pkg)
25
- })
17
+ if (!data || typeof data !== 'object') throw new Error(`context is not an object: ${filePath}`)
18
+ if (!data.status) throw new Error(`context missing status: ${filePath}`)
19
+ if (data.status === 'proceed') {
20
+ if (!data.sha || !data.sha7) throw new Error(`context missing sha: ${filePath}`)
21
+ if (!data.packages || typeof data.packages !== 'object')
22
+ throw new Error(`context missing packages: ${filePath}`)
23
+ }
24
+
25
+ return data
26
+ }
26
27
 
27
- export const buildContext = (packages, queue, sha, {channelNames = [], snapshot = false} = {}) => {
28
+ export const buildContext = (packages, queue, sha, {getChannels} = {}) => {
28
29
  const pkgs = {}
29
30
  for (const name of queue) {
30
31
  const pkg = packages[name]
@@ -32,7 +33,7 @@ export const buildContext = (packages, queue, sha, {channelNames = [], snapshot
32
33
  pkgs[name] = {
33
34
  version: pkg.version,
34
35
  tag: pkg.tag,
35
- channels: getActiveChannels(pkg, channelNames, snapshot),
36
+ channels: getChannels ? getChannels(pkg) : [],
36
37
  }
37
38
  }
38
39
 
@@ -4,9 +4,6 @@ import {getRemoteTagSha, clearTagsCache} from '../api/git.js'
4
4
  import {formatTag} from './generators/tag.js'
5
5
  import {resolvePkgVersion} from './steps/analyze.js'
6
6
 
7
- export const isTagConflict = (e) =>
8
- /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
9
-
10
7
  export const preflight = async (pkg, ctx) => {
11
8
  if (!pkg.tag) return 'ok'
12
9
 
@@ -1,28 +1,22 @@
1
1
  import {$, tempy, fs, path} from 'zx-extra'
2
2
  import {memoizeBy, asTuple} from '../../../util.js'
3
- import {channels, prepare, buildParcels} from '../../courier/index.js'
3
+ import {prepare, getActiveChannels} from '../../courier/index.js'
4
+ import {buildParcels, PARCELS_DIR} from '../../parcel/index.js'
4
5
  import {npmPersist} from '../../api/npm.js'
5
6
  import {getRepo} from '../../api/git.js'
6
7
  import {formatReleaseNotes} from '../generators/notes.js'
7
8
  import {ghPrepareAssets} from '../../api/gh.js'
8
9
  import {packTar, hashFile} from '../../tar.js'
9
10
 
10
-
11
- const filterActive = (names, pkg, {snapshot = false} = {}) =>
12
- names.filter(n => {
13
- const ch = channels[n]
14
- return ch && ch.transport !== false && (!snapshot || ch.snapshot) && ch.when(pkg)
15
- })
16
-
17
11
  export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
18
12
  const {channels: channelNames = [], flags} = ctx
19
13
  const snapshot = !!flags.snapshot
20
- const active = filterActive(channelNames, pkg, {snapshot})
14
+ const active = getActiveChannels(pkg, channelNames, snapshot)
21
15
 
22
16
  await prepare(active, pkg)
23
17
  await npmPersist(pkg)
24
18
 
25
- const outputDir = flags.pack ? path.resolve(ctx.git.root, typeof flags.pack === 'string' ? flags.pack : 'parcels') : null
19
+ const outputDir = flags.pack ? path.resolve(ctx.git.root, typeof flags.pack === 'string' ? flags.pack : PARCELS_DIR) : null
26
20
  const stageDir = outputDir || tempy.temporaryDirectory()
27
21
  if (outputDir) await fs.ensureDir(outputDir)
28
22
  const {repoName, repoHost, originUrl} = await getRepo(pkg.absPath, {basicAuth: pkg.config.ghBasicAuth})
@@ -1,8 +1,7 @@
1
1
  import {$, glob, path} from 'zx-extra'
2
2
  import {createReport, log} from '../log.js'
3
3
  import {deliver} from '../courier/index.js'
4
-
5
- const PARCELS_DIR = 'parcels'
4
+ import {PARCELS_DIR} from '../parcel/index.js'
6
5
 
7
6
  export const runDeliver = async ({env, flags}) => {
8
7
  const dir = typeof flags.deliver === 'string' ? flags.deliver : PARCELS_DIR
@@ -10,9 +10,7 @@ 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} from '../courier/directive.js'
14
-
15
- const PARCELS_DIR = 'parcels'
13
+ import {buildDirective, PARCELS_DIR} from '../parcel/index.js'
16
14
 
17
15
  export const runPack = async ({cwd, env, flags}, ctx) => {
18
16
  const {report, packages, queue, prev} = ctx
@@ -7,6 +7,7 @@ import {analyze} from '../depot/steps/analyze.js'
7
7
  import {clean} from '../depot/steps/clean.js'
8
8
  import {preflight} from '../depot/reconcile.js'
9
9
  import {consumeRebuildSignal} from '../courier/semaphore.js'
10
+ import {getActiveChannels} from '../courier/index.js'
10
11
  import {writeContext, buildContext} from '../depot/context.js'
11
12
  import {getSha} from '../api/git.js'
12
13
  import {setOutput, isRebuildTrigger} from '../api/gh.js'
@@ -17,7 +18,7 @@ export const runReceive = async ({cwd, env, flags}, ctx) => {
17
18
  const sha = await getSha(cwd)
18
19
  const sha7 = sha.slice(0, 7)
19
20
 
20
- if (isRebuildTrigger(env)) {
21
+ if (isRebuildTrigger(env) && !flags.dryRun) {
21
22
  const result = await consumeRebuildSignal(cwd, sha)
22
23
  if (result?.exitCode !== 0 && result?.stderr?.includes('remote ref does not exist')) {
23
24
  log.info(`rebuild signal already consumed by another process`)
@@ -45,9 +46,9 @@ export const runReceive = async ({cwd, env, flags}, ctx) => {
45
46
  if (await preflight(pkg, pkg.ctx) === 'skip') { pkg.skipped = true; return }
46
47
  })
47
48
 
49
+ const snapshot = !!flags.snapshot
48
50
  const context = buildContext(packages, queue, sha, {
49
- channelNames: ctx.channels,
50
- snapshot: !!flags.snapshot,
51
+ getChannels: (pkg) => getActiveChannels(pkg, ctx.channels, snapshot),
51
52
  })
52
53
  await writeContext(cwd, context)
53
54
 
@@ -1,20 +1,19 @@
1
1
  import {glob, path, fs} from 'zx-extra'
2
2
  import {log} from '../log.js'
3
- import {parcelChannel} from '../courier/directive.js'
4
-
5
- const PARCELS_DIR = 'parcels'
3
+ import {PARCELS_DIR, verifyParcels} from '../parcel/index.js'
4
+ import {CONTEXT_FILE, readContext} from '../depot/context.js'
6
5
 
7
6
  export const runVerify = async ({cwd, flags}) => {
8
7
  const inputDir = typeof flags.verify === 'string' ? flags.verify : PARCELS_DIR
9
- const contextPath = typeof flags.context === 'string' ? flags.context : path.resolve(cwd, '.zbr-context.json')
8
+ const contextPath = typeof flags.context === 'string' ? flags.context : path.resolve(cwd, CONTEXT_FILE)
10
9
  const outputDir = path.resolve(cwd, PARCELS_DIR)
11
10
 
12
11
  log.info(`verifying parcels in ${inputDir} against ${contextPath}`)
13
12
 
14
- let context
15
- try { context = await fs.readJson(contextPath) } catch { context = null }
16
- if (!context || context.status !== 'proceed') {
17
- throw new Error(`no valid context at ${contextPath}`)
13
+ const context = await readContext(contextPath)
14
+ if (context.status !== 'proceed') {
15
+ log.info(`context status is '${context.status}', nothing to verify`)
16
+ return
18
17
  }
19
18
 
20
19
  const tars = await glob(path.join(inputDir, 'parcel.*.tar'))
@@ -23,54 +22,13 @@ export const runVerify = async ({cwd, flags}) => {
23
22
  return
24
23
  }
25
24
 
26
- const {sha7, packages: expected} = context
27
- const errors = []
28
- const verified = []
29
-
30
- for (const tarPath of tars) {
31
- const name = path.basename(tarPath)
32
-
33
- // sha7 prefix must match
34
- if (!name.startsWith(`parcel.${sha7}.`)) {
35
- errors.push(`sha mismatch: ${name}`)
36
- continue
37
- }
38
-
39
- const channel = parcelChannel(name)
40
- if (!channel) {
41
- errors.push(`malformed name: ${name}`)
42
- continue
43
- }
44
-
45
- if (channel === 'directive') {
46
- verified.push(tarPath)
47
- continue
48
- }
49
-
50
- // match to an expected package by tag
51
- const belongsTo = Object.entries(expected).find(([, pkg]) =>
52
- pkg.tag && name.includes(`.${pkg.tag}.`)
53
- )
54
- if (!belongsTo) {
55
- errors.push(`unexpected parcel (no matching package): ${name}`)
56
- continue
57
- }
58
-
59
- const [pkgName, pkg] = belongsTo
60
- if (!pkg.channels.includes(channel)) {
61
- errors.push(`unexpected channel '${channel}' for ${pkgName}: ${name}`)
62
- continue
63
- }
64
-
65
- verified.push(tarPath)
66
- }
25
+ const {verified, errors} = verifyParcels(tars, context)
67
26
 
68
27
  if (errors.length) {
69
28
  for (const e of errors) log.error(`verify: ${e}`)
70
29
  throw new Error(`parcel verification failed: ${errors.length} error(s)`)
71
30
  }
72
31
 
73
- // copy verified parcels to output
74
32
  if (path.resolve(inputDir) !== outputDir) {
75
33
  await fs.ensureDir(outputDir)
76
34
  for (const tarPath of verified) {
@@ -0,0 +1,4 @@
1
+ export {buildParcels} from './build.js'
2
+ export {buildDirective, parseDirective, scanDirectives, invalidateOrphans, parcelChannel} from './directive.js'
3
+ export {verifyParcels} from './verify.js'
4
+ export const PARCELS_DIR = 'parcels'
@@ -0,0 +1,46 @@
1
+ import {path} from 'zx-extra'
2
+ import {parcelChannel} from './directive.js'
3
+
4
+ export const verifyParcels = (tars, context) => {
5
+ const {sha7, packages: expected} = context
6
+ const errors = []
7
+ const verified = []
8
+
9
+ for (const tarPath of tars) {
10
+ const name = path.basename(tarPath)
11
+
12
+ if (!name.startsWith(`parcel.${sha7}.`)) {
13
+ errors.push(`sha mismatch: ${name}`)
14
+ continue
15
+ }
16
+
17
+ const channel = parcelChannel(name)
18
+ if (!channel) {
19
+ errors.push(`malformed name: ${name}`)
20
+ continue
21
+ }
22
+
23
+ if (channel === 'directive') {
24
+ verified.push(tarPath)
25
+ continue
26
+ }
27
+
28
+ const belongsTo = Object.entries(expected).find(([, pkg]) =>
29
+ pkg.tag && name.includes(`.${pkg.tag}.`)
30
+ )
31
+ if (!belongsTo) {
32
+ errors.push(`unexpected parcel (no matching package): ${name}`)
33
+ continue
34
+ }
35
+
36
+ const [pkgName, pkg] = belongsTo
37
+ if (!pkg.channels.includes(channel)) {
38
+ errors.push(`unexpected channel '${channel}' for ${pkgName}: ${name}`)
39
+ continue
40
+ }
41
+
42
+ verified.push(tarPath)
43
+ }
44
+
45
+ return {verified, errors}
46
+ }
@@ -14,7 +14,39 @@ import {runPack} from './modes/pack.js'
14
14
 
15
15
  const ZBR_VERSION = createRequire(import.meta.url)('../../../../package.json').version
16
16
 
17
+ const HELP = `
18
+ zx-bulk-release v${ZBR_VERSION}
19
+
20
+ Usage: npx zx-bulk-release [options]
21
+
22
+ Modes:
23
+ (no flags) All-in-one: analyze, build, test, pack, deliver
24
+ --receive Analyze & preflight. Writes .zbr-context.json. Run BEFORE deps install.
25
+ --pack [dir] Build, test, pack tars to dir. [default: parcels]
26
+ --verify [dir] Validate parcels against context, copy to parcels/. [default: parcels]
27
+ --deliver [dir] Deliver parcels through channels. [default: parcels]
28
+
29
+ Options:
30
+ --context <path> Path to .zbr-context.json (with --verify). [default: .zbr-context.json]
31
+ --dry-run, --no-publish Disable any publish / remote-mutating logic.
32
+ --no-build Skip buildCmd.
33
+ --no-test Skip testCmd.
34
+ --snapshot Publish snapshot versions to npm only.
35
+ --ignore <a,b> Packages to ignore.
36
+ --include-private Include private packages.
37
+ --concurrency <n> Build/publish thread limit. [default: os.cpus().length]
38
+ --only-workspace-deps Recognize only workspace: deps as graph edges.
39
+ --no-npm-fetch Disable npm artifact fetching.
40
+ --report <path> Persist release state to file.
41
+ --debug Enable verbose mode.
42
+ -v, --version Print version.
43
+ -h, --help Show this help.
44
+ `.trim()
45
+
17
46
  export const run = async ({cwd = process.cwd(), env: _env, flags = {}} = {}) => within(async () => {
47
+ if (flags.h || flags.help)
48
+ return console.log(HELP)
49
+
18
50
  if (flags.v || flags.version)
19
51
  return console.log(ZBR_VERSION)
20
52
 
@@ -72,3 +72,19 @@ export const memoizeBy = (fn, getKey = v => v) => {
72
72
  export const camelize = s => s.replace(/-./g, x => x[1].toUpperCase())
73
73
 
74
74
  export const asArray = v => Array.isArray(v) ? v : [v]
75
+
76
+ export const pool = async (tasks, concurrency, fn) => {
77
+ const active = new Set()
78
+ let i = 0
79
+ await new Promise((resolve, reject) => {
80
+ const next = () => {
81
+ if (i >= tasks.length && active.size === 0) return resolve()
82
+ while (active.size < concurrency && i < tasks.length) {
83
+ const t = tasks[i++]
84
+ const p = fn(t).then(() => { active.delete(p); next() }, reject)
85
+ active.add(p)
86
+ }
87
+ }
88
+ next()
89
+ })
90
+ }