zx-bulk-release 3.1.4 → 3.1.6

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,15 @@
1
+ ## [3.1.6](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.5...v3.1.6) (2026-04-13)
2
+
3
+ ### Fixes & improvements
4
+ * fix: pack uses channel list from context instead of re-evaluating ([0fecefa](https://github.com/semrel-extra/zx-bulk-release/commit/0fecefab3549da98e24d42a2e1b4c7bb1b24b4c9))
5
+
6
+ ## [3.1.5](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.4...v3.1.5) (2026-04-13)
7
+
8
+ ### Fixes & improvements
9
+ * fix: make git-tag channel work with both local and remote repos ([0102cdd](https://github.com/semrel-extra/zx-bulk-release/commit/0102cddaf6c091e8cc68533be8034f95776c64b4))
10
+ * fix: use package name and version in parcel names ([6b5f1e7](https://github.com/semrel-extra/zx-bulk-release/commit/6b5f1e761c9821460267c1464db01af1029dbaa7))
11
+ * fix: enable git-tag and semaphore in bare deliver repo ([abe82d9](https://github.com/semrel-extra/zx-bulk-release/commit/abe82d9b299a489ce16c0a9f3867894657b4e2a4))
12
+
1
13
  ## [3.1.4](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.3...v3.1.4) (2026-04-13)
2
14
 
3
15
  ### Fixes & improvements
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.4",
4
+ "version": "3.1.6",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
@@ -1,6 +1,5 @@
1
1
  // Low-level GitHub API primitives. No domain knowledge, no imports from processor/ or steps/.
2
2
 
3
- import nodeFs from 'node:fs'
4
3
  import {$, path, tempy, glob, fs, fetch} from 'zx-extra'
5
4
  import {asArray, attempt2} from '../../util.js'
6
5
 
@@ -93,7 +92,7 @@ export const ghGetAsset = async ({repoName, tag, name, ghUrl}) => {
93
92
  export const setOutput = (name, value) => {
94
93
  const outputFile = process.env.GITHUB_OUTPUT
95
94
  if (outputFile) {
96
- try { nodeFs.appendFileSync(outputFile, `${name}=${value}\n`) } catch { /* not in CI */ }
95
+ try { fs.appendFileSync(outputFile, `${name}=${value}\n`) } catch { /* not in CI */ }
97
96
  }
98
97
  }
99
98
 
@@ -104,12 +104,12 @@ export const getTags = memoizeBy(
104
104
  async (cwd, ref = '*') => `${await getRoot(cwd)}:${ref}`,
105
105
  )
106
106
 
107
- export const pushTag = async ({cwd, tag, gitCommitterName, gitCommitterEmail}) => {
107
+ export const pushTag = async ({cwd, tag, sha, gitCommitterName, gitCommitterEmail}) => {
108
108
  await setUserConfig(cwd, gitCommitterName, gitCommitterEmail)
109
109
 
110
- await $({cwd})`
111
- git tag -m ${tag} ${tag} &&
112
- git push origin ${tag}`
110
+ const target = sha ? [sha] : []
111
+ await $({cwd})`git tag -m ${tag} ${tag} ${target}`
112
+ await $({cwd})`git push origin ${tag}`
113
113
  }
114
114
 
115
115
  // Memoize prevents .git/config lock
@@ -1,15 +1,25 @@
1
+ import {$} from 'zx-extra'
1
2
  import {api} from '../../api/index.js'
2
3
  import {DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
3
4
 
4
5
  export const isTagConflict = (e) =>
5
6
  /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
6
7
 
8
+ const ensureSha = async (cwd, sha) => {
9
+ if (!sha) return
10
+ const check = await $({cwd, nothrow: true, quiet: true})`git cat-file -e ${sha}`
11
+ if (check.exitCode !== 0) {
12
+ await $({cwd, quiet: true})`git fetch origin --depth 1`
13
+ }
14
+ }
15
+
7
16
  const run = async (manifest, dir) => {
8
- const {tag, cwd} = manifest
17
+ const {tag, sha, cwd} = manifest
9
18
  const gitCommitterName = manifest.gitCommitterName || DEFAULT_GIT_COMMITTER_NAME
10
19
  const gitCommitterEmail = manifest.gitCommitterEmail || DEFAULT_GIT_COMMITTER_EMAIL
11
20
  try {
12
- await api.git.pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
21
+ await ensureSha(cwd, sha)
22
+ await api.git.pushTag({cwd, tag, sha, gitCommitterName, gitCommitterEmail})
13
23
  return 'ok'
14
24
  } catch (e) {
15
25
  if (isTagConflict(e)) return 'conflict'
@@ -49,13 +49,14 @@ export const resolveManifest = (manifest, env = process.env) => {
49
49
 
50
50
  const MARKERS = new Set(['released', 'skip', 'conflict', 'orphan'])
51
51
 
52
- const openParcel = async (tarPath, env) => {
52
+ const openParcel = async (tarPath, env, {cwd} = {}) => {
53
53
  const content = await fs.readFile(tarPath, 'utf8').catch(() => null)
54
54
  if (MARKERS.has(content)) return null
55
55
 
56
56
  const destDir = tempy.temporaryDirectory()
57
57
  const {manifest} = await unpackTar(tarPath, destDir)
58
58
  const resolved = resolveManifest(manifest, env)
59
+ if (cwd) resolved.cwd = cwd
59
60
  const ch = channels[resolved.channel]
60
61
 
61
62
  if (!ch) return {warn: `unknown channel '${resolved.channel || '<none>'}'`}
@@ -67,14 +68,14 @@ const openParcel = async (tarPath, env) => {
67
68
  return {ch, resolved, destDir, tarPath}
68
69
  }
69
70
 
70
- export const inspect = async (tars, env = process.env) => {
71
+ export const inspect = async (tars, env = process.env, {cwd} = {}) => {
71
72
  const parcels = []
72
73
  const skipped = []
73
74
 
74
75
  for (const tarPath of tars) {
75
76
  const fname = path.basename(tarPath)
76
77
  try {
77
- const p = await openParcel(tarPath, env)
78
+ const p = await openParcel(tarPath, env, {cwd})
78
79
  if (!p) continue
79
80
  if (p.warn) {
80
81
  skipped.push({file: fname, reason: p.warn, tarPath: p.tarPath})
@@ -100,8 +101,8 @@ export const inspect = async (tars, env = process.env) => {
100
101
 
101
102
  // --- Legacy deliver (no directive) ---
102
103
 
103
- const deliverLegacy = async (tars, env, {concurrency, dryRun}) => {
104
- const {groups, skipped, total, pending} = await inspect(tars, env)
104
+ const deliverLegacy = async (tars, env, {concurrency, dryRun, cwd}) => {
105
+ const {groups, skipped, total, pending} = await inspect(tars, env, {cwd})
105
106
 
106
107
  for (const {file, reason, tarPath} of skipped) {
107
108
  log.warn(`skipping ${file}: ${reason}`)
@@ -135,8 +136,8 @@ const deliverLegacy = async (tars, env, {concurrency, dryRun}) => {
135
136
 
136
137
  // --- Directive-aware deliver ---
137
138
 
138
- const deliverParcel = async (tarPath, channelName, pkgName, version, env, {dryRun}) => {
139
- const p = await openParcel(tarPath, env)
139
+ const deliverParcel = async (tarPath, channelName, pkgName, version, env, {dryRun, cwd}) => {
140
+ const p = await openParcel(tarPath, env, {cwd})
140
141
  if (!p) return 'already'
141
142
  if (p.warn) {
142
143
  log.warn(`skipping ${p.warn}`)
@@ -159,7 +160,7 @@ const deliverParcel = async (tarPath, channelName, pkgName, version, env, {dryRu
159
160
  return res === 'duplicate' ? 'duplicate' : 'ok'
160
161
  }
161
162
 
162
- const deliverDirective = async (directive, tarMap, env, {dryRun}) => {
163
+ const deliverDirective = async (directive, tarMap, env, {dryRun, cwd}) => {
163
164
  const entries = []
164
165
  const conflicts = []
165
166
  const skipped = []
@@ -178,7 +179,7 @@ const deliverDirective = async (directive, tarMap, env, {dryRun}) => {
178
179
  const tarPath = parcelName && tarMap.get(parcelName)
179
180
  if (!tarPath) return 'missing'
180
181
 
181
- return deliverParcel(tarPath, channelName, pkgName, pkg.version, env, {dryRun})
182
+ return deliverParcel(tarPath, channelName, pkgName, pkg.version, env, {dryRun, cwd})
182
183
  }))
183
184
 
184
185
  for (let i = 0; i < step.length; i++) {
@@ -209,7 +210,7 @@ export const deliver = async (tars, env = process.env, {concurrency = 4, dryRun
209
210
  const dir = tars.length ? path.dirname(tars[0]) : null
210
211
  const directives = dir ? await scanDirectives(dir) : []
211
212
 
212
- if (!directives.length) return deliverLegacy(tars, env, {concurrency, dryRun})
213
+ if (!directives.length) return deliverLegacy(tars, env, {concurrency, dryRun, cwd})
213
214
 
214
215
  const tarMap = new Map(tars.map(t => [path.basename(t), t]))
215
216
  const allEntries = []
@@ -225,7 +226,7 @@ export const deliver = async (tars, env = process.env, {concurrency = 4, dryRun
225
226
 
226
227
  try {
227
228
  await invalidateOrphans(dir, directive)
228
- const {entries, conflicts, skipped} = await deliverDirective(directive, tarMap, env, {dryRun})
229
+ const {entries, conflicts, skipped} = await deliverDirective(directive, tarMap, env, {dryRun, cwd: gitRoot})
229
230
  allEntries.push(...entries)
230
231
  allConflicts.push(...conflicts)
231
232
  allSkipped.push(...skipped)
@@ -1,8 +1,17 @@
1
+ import {$} from 'zx-extra'
1
2
  import {api} from '../api/index.js'
3
+ import {DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../api/git.js'
2
4
 
3
5
  const tagName = ({sha}) =>
4
6
  `zbr-deliver.${sha.slice(0, 7)}`
5
7
 
8
+ const ensureHead = async (cwd) => {
9
+ try { await $({cwd, quiet: true})`git rev-parse HEAD` } catch {
10
+ await api.git.setUserConfig(cwd, DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL)
11
+ await $({cwd, quiet: true})`git commit --allow-empty -m init`
12
+ }
13
+ }
14
+
6
15
  export const tryLock = async (cwd, directive) => {
7
16
  const tag = tagName(directive)
8
17
  const body = JSON.stringify({
@@ -11,6 +20,7 @@ export const tryLock = async (cwd, directive) => {
11
20
  packages: directive.queue,
12
21
  })
13
22
  try {
23
+ await ensureHead(cwd)
14
24
  await api.git.pushAnnotatedTag(cwd, tag, body)
15
25
  return true
16
26
  } catch {
@@ -3,13 +3,15 @@ import {memoizeBy, asTuple} from '../../../util.js'
3
3
  import {api} from '../../api/index.js'
4
4
  import {prepare, getActiveChannels} from '../../courier/index.js'
5
5
  import {buildParcels, PARCELS_DIR} from '../../parcel/index.js'
6
+ import {sanitizePkgName} from '../../parcel/build.js'
6
7
  import {formatReleaseNotes} from '../generators/notes.js'
7
8
  import {packTar, hashFile} from '../../tar.js'
8
9
 
9
10
  export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
10
11
  const {channels: channelNames = [], flags} = ctx
11
12
  const snapshot = !!flags.snapshot
12
- const active = getActiveChannels(pkg, channelNames, snapshot)
13
+ // In split pipeline, receive already decided which channels are active
14
+ const active = pkg.contextChannels || getActiveChannels(pkg, channelNames, snapshot)
13
15
 
14
16
  await prepare(active, pkg)
15
17
  await api.npm.npmPersist(pkg)
@@ -43,7 +45,8 @@ export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
43
45
  await packTar(tmpPath, manifest, files)
44
46
  const hash = await hashFile(tmpPath)
45
47
  const sha7 = ctx.git.sha.slice(0, 7)
46
- const finalPath = path.join(stageDir, `parcel.${sha7}.${channel}.${pkg.tag}.${hash}.tar`)
48
+ const safeName = sanitizePkgName(pkg.name)
49
+ const finalPath = path.join(stageDir, `parcel.${sha7}.${channel}.${safeName}.${pkg.version}.${hash}.tar`)
47
50
  await fs.move(tmpPath, finalPath)
48
51
  tars.push(finalPath)
49
52
  }
@@ -1,4 +1,4 @@
1
- import {$, glob, path} from 'zx-extra'
1
+ import {$, glob, path, tempy} from 'zx-extra'
2
2
  import {createReport, log} from '../log.js'
3
3
  import {deliver} from '../courier/index.js'
4
4
  import {PARCELS_DIR} from '../parcel/index.js'
@@ -15,9 +15,33 @@ export const runDeliver = async ({env, flags}) => {
15
15
  const tars = await glob(path.join(dir, 'parcel.*.tar'))
16
16
  if (!tars.length) return report.setStatus('success').log(`no parcels in ${dir}`)
17
17
 
18
+ const cwd = await ensureGitRepo(env)
19
+
18
20
  report.setStatus('delivering').log(`parcels: ${tars.length}`)
19
- const result = await deliver(tars, env, {dryRun: flags.dryRun})
21
+ const result = await deliver(tars, env, {dryRun: flags.dryRun, cwd})
20
22
  report.set('delivery', result).setStatus('success')
21
23
 
22
24
  log.info(`done: ${result.delivered} delivered, ${result.skipped} skipped`)
23
25
  }
26
+
27
+ const ensureGitRepo = async (env) => {
28
+ try {
29
+ return (await $({quiet: true})`git rev-parse --show-toplevel`).toString().trim()
30
+ } catch {
31
+ // Deliver runs in a clean runner with no checkout — init a temp repo for git push operations.
32
+ const tmp = tempy.temporaryDirectory()
33
+ const repo = env.GITHUB_REPOSITORY || ''
34
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN || ''
35
+ const user = env.GH_USER || env.GH_USERNAME || 'x-access-token'
36
+ const server = env.GITHUB_SERVER_URL || 'https://github.com'
37
+ const host = new URL(server).host
38
+ const origin = token
39
+ ? `https://${user}:${token}@${host}/${repo}.git`
40
+ : `${server}/${repo}.git`
41
+
42
+ await $({quiet: true})`git init ${tmp}`
43
+ await $({quiet: true, cwd: tmp})`git remote add origin ${origin}`
44
+ log.info(`initialized temp git repo for delivery: ${repo}`)
45
+ return tmp
46
+ }
47
+ }
@@ -54,6 +54,7 @@ export const runPack = async ({cwd, env, flags}, ctx) => {
54
54
  pkg.version = ctxPkg.version
55
55
  pkg.tag = ctxPkg.tag
56
56
  pkg.manifest.version = ctxPkg.version
57
+ pkg.contextChannels = ctxPkg.channels
57
58
  if (!pkg.releaseType) pkg.releaseType = 'patch'
58
59
  } else {
59
60
  // Standalone: own analysis decides
@@ -1,5 +1,7 @@
1
1
  import {asTuple, msgJoin} from '../../util.js'
2
2
 
3
+ export const sanitizePkgName = (name) => name.replace(/[^a-z0-9-]/ig, '-').replace(/^-+|-+$/g, '')
4
+
3
5
  const gitFields = (a, pkg) => ({
4
6
  repoHost: a.repoHost,
5
7
  repoName: a.repoName,
@@ -14,7 +16,7 @@ const entry = {
14
16
  manifest: {
15
17
  channel: 'git-tag',
16
18
  name: pkg.name, version: pkg.version, tag: pkg.tag,
17
- cwd: ctx.git.root,
19
+ cwd: ctx.git.root, sha: ctx.git.sha,
18
20
  gitCommitterName: '${{GIT_COMMITTER_NAME}}',
19
21
  gitCommitterEmail: '${{GIT_COMMITTER_EMAIL}}',
20
22
  },
@@ -1,4 +1,4 @@
1
- export {buildParcels} from './build.js'
1
+ export {buildParcels, sanitizePkgName} from './build.js'
2
2
  export {buildDirective, parseDirective, scanDirectives, invalidateOrphans, parcelChannel} from './directive.js'
3
3
  export {verifyParcels} from './verify.js'
4
4
  export const PARCELS_DIR = 'parcels'
@@ -1,5 +1,6 @@
1
1
  import {path} from 'zx-extra'
2
2
  import {parcelChannel} from './directive.js'
3
+ import {sanitizePkgName} from './build.js'
3
4
 
4
5
  export const verifyParcels = (tars, context) => {
5
6
  const {sha7, packages: expected} = context
@@ -25,8 +26,8 @@ export const verifyParcels = (tars, context) => {
25
26
  continue
26
27
  }
27
28
 
28
- const belongsTo = Object.entries(expected).find(([, pkg]) =>
29
- pkg.tag && name.includes(`.${pkg.tag}.`)
29
+ const belongsTo = Object.entries(expected).find(([pkgName, pkg]) =>
30
+ name.includes(`.${sanitizePkgName(pkgName)}.${pkg.version}.`)
30
31
  )
31
32
  if (!belongsTo) {
32
33
  errors.push(`unexpected parcel (no matching package): ${name}`)