zx-bulk-release 3.1.3 → 3.1.5

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,16 @@
1
+ ## [3.1.5](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.4...v3.1.5) (2026-04-13)
2
+
3
+ ### Fixes & improvements
4
+ * fix: make git-tag channel work with both local and remote repos ([0102cdd](https://github.com/semrel-extra/zx-bulk-release/commit/0102cddaf6c091e8cc68533be8034f95776c64b4))
5
+ * fix: use package name and version in parcel names ([6b5f1e7](https://github.com/semrel-extra/zx-bulk-release/commit/6b5f1e761c9821460267c1464db01af1029dbaa7))
6
+ * fix: enable git-tag and semaphore in bare deliver repo ([abe82d9](https://github.com/semrel-extra/zx-bulk-release/commit/abe82d9b299a489ce16c0a9f3867894657b4e2a4))
7
+
8
+ ## [3.1.4](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.3...v3.1.4) (2026-04-13)
9
+
10
+ ### Fixes & improvements
11
+ * fix: sync receive/pack via context-driven pack filter ([5b539ff](https://github.com/semrel-extra/zx-bulk-release/commit/5b539ff327b07c171c523b36f28cc2e05c4346d6))
12
+ * refactor: introduce internal di layer ([a32773d](https://github.com/semrel-extra/zx-bulk-release/commit/a32773d37e9454953029f18e0af7de33486c14e3))
13
+
1
14
  ## [3.1.3](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.2...v3.1.3) (2026-04-13)
2
15
 
3
16
  ### 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.3",
4
+ "version": "3.1.5",
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
@@ -0,0 +1,8 @@
1
+ import * as git from './git.js'
2
+ import * as gh from './gh.js'
3
+ import * as npm from './npm.js'
4
+
5
+ export const api = { git, gh, npm }
6
+
7
+ /** @deprecated use `api` */
8
+ export const defaultApi = api
@@ -1,6 +1,6 @@
1
1
  import {fs, path} from 'zx-extra'
2
2
  import {queuefy} from 'queuefy'
3
- import {fetchRepo, pushCommit} from '../../api/git.js'
3
+ import {api} from '../../api/index.js'
4
4
  import {log} from '../../log.js'
5
5
  import {hasHigherVersion} from '../seniority.js'
6
6
 
@@ -14,12 +14,12 @@ const run = queuefy(async (manifest, dir) => {
14
14
 
15
15
  log.info('push changelog')
16
16
 
17
- const _cwd = await fetchRepo({branch, origin: repoAuthedUrl, basicAuth: ghBasicAuth})
17
+ const _cwd = await api.git.fetchRepo({branch, origin: repoAuthedUrl, basicAuth: ghBasicAuth})
18
18
  const filePath = path.resolve(_cwd, file)
19
19
  const prev = await fs.readFile(filePath, 'utf8').catch(() => '')
20
20
  await fs.outputFile(filePath, releaseNotes + '\n' + prev)
21
21
 
22
- await pushCommit({
22
+ await api.git.pushCommit({
23
23
  branch,
24
24
  msg,
25
25
  origin: repoAuthedUrl,
@@ -1,7 +1,7 @@
1
1
  import {path} from 'zx-extra'
2
2
  import {queuefy} from 'queuefy'
3
3
  import {log} from '../../log.js'
4
- import {pushCommit} from '../../api/git.js'
4
+ import {api} from '../../api/index.js'
5
5
  import {hasHigherVersion} from '../seniority.js'
6
6
 
7
7
  const run = queuefy(async (manifest, dir) => {
@@ -15,7 +15,7 @@ const run = queuefy(async (manifest, dir) => {
15
15
 
16
16
  log.info(`publish docs to ${branch}`)
17
17
 
18
- await pushCommit({
18
+ await api.git.pushCommit({
19
19
  cwd: docsDir,
20
20
  from: '.',
21
21
  to,
@@ -1,6 +1,6 @@
1
1
  import {fs, path} from 'zx-extra'
2
2
  import {log} from '../../log.js'
3
- import {ghCreateRelease, ghFetch} from '../../api/gh.js'
3
+ import {api} from '../../api/index.js'
4
4
 
5
5
  const isDuplicate = (e) =>
6
6
  /already_exists|Validation Failed|422/i.test(e?.message || e?.stderr || '')
@@ -14,7 +14,7 @@ const run = async (manifest, dir) => {
14
14
 
15
15
  let res
16
16
  try {
17
- res = await ghCreateRelease({ghApiUrl: apiUrl, ghToken: token, repoName, tag, body: releaseNotes})
17
+ res = await api.gh.ghCreateRelease({ghApiUrl: apiUrl, ghToken: token, repoName, tag, body: releaseNotes})
18
18
  } catch (e) {
19
19
  if (isDuplicate(e)) return 'duplicate'
20
20
  throw e
@@ -27,7 +27,7 @@ const run = async (manifest, dir) => {
27
27
  await Promise.all(assets.map(async ({name}) => {
28
28
  const url = `${uploadUrl}?name=${name}`
29
29
  const body = await fs.readFile(path.join(assetsDir, name))
30
- const r = await ghFetch(url, {ghToken: token, method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body})
30
+ const r = await api.gh.ghFetch(url, {ghToken: token, method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body})
31
31
  if (!r.ok) throw new Error(`gh asset upload failed for '${name}': ${r.status}`)
32
32
  }))
33
33
  }
@@ -1,14 +1,25 @@
1
- import {pushTag, DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
1
+ import {$} from 'zx-extra'
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
  export const isTagConflict = (e) =>
4
6
  /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
5
7
 
6
- const run = async (manifest) => {
7
- const {tag, cwd} = manifest
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
+
16
+ const run = async (manifest, dir) => {
17
+ const {tag, sha, cwd} = manifest
8
18
  const gitCommitterName = manifest.gitCommitterName || DEFAULT_GIT_COMMITTER_NAME
9
19
  const gitCommitterEmail = manifest.gitCommitterEmail || DEFAULT_GIT_COMMITTER_EMAIL
10
20
  try {
11
- await pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
21
+ await ensureSha(cwd, sha)
22
+ await api.git.pushTag({cwd, tag, sha, gitCommitterName, gitCommitterEmail})
12
23
  return 'ok'
13
24
  } catch (e) {
14
25
  if (isTagConflict(e)) return 'conflict'
@@ -1,6 +1,6 @@
1
1
  import {queuefy} from 'queuefy'
2
2
  import {log} from '../../log.js'
3
- import {pushCommit} from '../../api/git.js'
3
+ import {api} from '../../api/index.js'
4
4
  import {getArtifactPath, isAssetMode, prepareMeta} from '../../depot/generators/meta.js'
5
5
  import {hasHigherVersion} from '../seniority.js'
6
6
 
@@ -18,7 +18,7 @@ const pushMetaBranch = queuefy(async (manifest, dir) => {
18
18
  const msg = `chore: release meta ${name} ${version}`
19
19
  const files = [{relpath: `${getArtifactPath(tag)}.json`, contents: meta}]
20
20
 
21
- await pushCommit({
21
+ await api.git.pushCommit({
22
22
  to: '.',
23
23
  branch: 'meta',
24
24
  msg,
@@ -1,5 +1,5 @@
1
1
  import {path} from 'zx-extra'
2
- import {npmPublish} from '../../api/npm.js'
2
+ import {api} from '../../api/index.js'
3
3
 
4
4
  export const isNpmPublished = (pkg) =>
5
5
  !pkg.manifest.private && pkg.config.npmPublish !== false
@@ -9,7 +9,7 @@ const isDuplicate = (e) =>
9
9
 
10
10
  const run = async (manifest, dir) => {
11
11
  try {
12
- await npmPublish({
12
+ await api.npm.npmPublish({
13
13
  name: manifest.name,
14
14
  version: manifest.version,
15
15
  npmTarball: path.join(dir, 'package.tgz'),
@@ -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 {pushAnnotatedTag, deleteRemoteTag} from '../api/git.js'
1
+ import {$} from 'zx-extra'
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,7 +20,8 @@ export const tryLock = async (cwd, directive) => {
11
20
  packages: directive.queue,
12
21
  })
13
22
  try {
14
- await pushAnnotatedTag(cwd, tag, body)
23
+ await ensureHead(cwd)
24
+ await api.git.pushAnnotatedTag(cwd, tag, body)
15
25
  return true
16
26
  } catch {
17
27
  return false
@@ -19,13 +29,13 @@ export const tryLock = async (cwd, directive) => {
19
29
  }
20
30
 
21
31
  export const unlock = async (cwd, directive) => {
22
- await deleteRemoteTag(cwd, tagName(directive))
32
+ await api.git.deleteRemoteTag(cwd, tagName(directive))
23
33
  }
24
34
 
25
35
  export const signalRebuild = async (cwd, sha) => {
26
36
  const tag = `zbr-rebuild.${sha.slice(0, 7)}`
27
- try { await pushAnnotatedTag(cwd, tag, 'rebuild') } catch { /* already signaled */ }
37
+ try { await api.git.pushAnnotatedTag(cwd, tag, 'rebuild') } catch { /* already signaled */ }
28
38
  }
29
39
 
30
40
  export const consumeRebuildSignal = async (cwd, sha) =>
31
- deleteRemoteTag(cwd, `zbr-rebuild.${sha.slice(0, 7)}`)
41
+ api.git.deleteRemoteTag(cwd, `zbr-rebuild.${sha.slice(0, 7)}`)
@@ -1,9 +1,7 @@
1
1
  // Meta generator: builds pkg.meta payload and resolves latest-release meta from git tags / gh assets / meta branch.
2
2
 
3
3
  import {semver, $, fs, path} from 'zx-extra'
4
- import {fetchRepo, getTags as getGitTags, getRepo, getSha} from '../../api/git.js'
5
- import {fetchManifest} from '../../api/npm.js'
6
- import {ghGetAsset} from '../../api/gh.js'
4
+ import {api} from '../../api/index.js'
7
5
  import {parseTag} from './tag.js'
8
6
 
9
7
  export const isAssetMode = (type) => type === 'asset' || type === 'assets'
@@ -16,7 +14,7 @@ export const prepareMeta = async (pkg) => {
16
14
  if (type === null) return
17
15
 
18
16
  const {absPath: cwd} = pkg
19
- const hash = await getSha(cwd)
17
+ const hash = await api.git.getSha(cwd)
20
18
  pkg.meta = {
21
19
  META_VERSION: '1',
22
20
  hash,
@@ -45,7 +43,7 @@ export const getLatest = async (pkg) => {
45
43
  }
46
44
 
47
45
  export const getTags = async (cwd, ref) =>
48
- (await getGitTags(cwd, ref))
46
+ (await api.git.getTags(cwd, ref))
49
47
  .map(tag => parseTag(tag.trim()))
50
48
  .filter(Boolean)
51
49
  .sort((a, b) => semver.rcompare(a.version, b.version))
@@ -61,14 +59,14 @@ export const getArtifactPath = (tag) => tag.toLowerCase().replace(/[^a-z0-9-]/g,
61
59
  export const getLatestMeta = async (pkg, tag) => {
62
60
  if (tag) {
63
61
  const {absPath: cwd, config: {ghBasicAuth: basicAuth, ghUrl}} = pkg
64
- const {repoName} = await getRepo(cwd, {basicAuth})
62
+ const {repoName} = await api.git.getRepo(cwd, {basicAuth})
65
63
 
66
64
  try {
67
- return JSON.parse(await ghGetAsset({repoName, tag, name: 'meta.json', ghUrl}))
65
+ return JSON.parse(await api.gh.ghGetAsset({repoName, tag, name: 'meta.json', ghUrl}))
68
66
  } catch {}
69
67
 
70
68
  try {
71
- const _cwd = await fetchRepo({cwd, branch: 'meta', basicAuth})
69
+ const _cwd = await api.git.fetchRepo({cwd, branch: 'meta', basicAuth})
72
70
  return await Promise.any([
73
71
  fs.readJson(path.resolve(_cwd, `${getArtifactPath(tag)}.json`)),
74
72
  fs.readJson(path.resolve(_cwd, getArtifactPath(tag), 'meta.json'))
@@ -76,5 +74,5 @@ export const getLatestMeta = async (pkg, tag) => {
76
74
  } catch {}
77
75
  }
78
76
 
79
- return fetchManifest(pkg, {nothrow: true})
77
+ return api.npm.fetchManifest(pkg, {nothrow: true})
80
78
  }
@@ -1,6 +1,6 @@
1
1
  // Release notes formatting. Pure except for a single getRepo() call to resolve repoPublicUrl.
2
2
 
3
- import {getRepo} from '../../api/git.js'
3
+ import {api} from '../../api/index.js'
4
4
  import {formatTag} from './tag.js'
5
5
 
6
6
  export const DIFF_TAG_URL = '${repoPublicUrl}/compare/${prevTag}...${newTag}'
@@ -14,7 +14,7 @@ export const interpolate = (template, vars) => {
14
14
 
15
15
  export const formatReleaseNotes = async (pkg) => {
16
16
  const {name, version, tag = formatTag({name, version}), absPath: cwd, config: {ghBasicAuth: basicAuth, diffTagUrl = DIFF_TAG_URL, diffCommitUrl = DIFF_COMMIT_URL}} = pkg
17
- const {repoPublicUrl, repoName} = await getRepo(cwd, {basicAuth})
17
+ const {repoPublicUrl, repoName} = await api.git.getRepo(cwd, {basicAuth})
18
18
  const prevTag = pkg.latest.tag?.ref
19
19
  const vars = {repoName, repoPublicUrl, prevTag, newTag: tag, name, version}
20
20
  const diffUrl = interpolate(diffTagUrl, vars)
@@ -1,6 +1,6 @@
1
1
  import {$} from 'zx-extra'
2
+ import {api} from '../api/index.js'
2
3
  import {log} from '../log.js'
3
- 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
 
@@ -8,7 +8,7 @@ export const preflight = async (pkg, ctx) => {
8
8
  if (!pkg.tag) return 'ok'
9
9
 
10
10
  const cwd = ctx.git.root
11
- const remoteSha = await getRemoteTagSha(cwd, pkg.tag)
11
+ const remoteSha = await api.git.getRemoteTagSha(cwd, pkg.tag)
12
12
  if (!remoteSha) return 'ok'
13
13
 
14
14
  // tag exists on remote
@@ -30,7 +30,7 @@ export const preflight = async (pkg, ctx) => {
30
30
  if (isRemoteOlder.exitCode === 0) {
31
31
  log.info(`preflight: ${pkg.tag} — we are newer, re-resolving version`)
32
32
  await $({cwd})`git fetch origin --tags --force`
33
- clearTagsCache()
33
+ api.git.clearTagsCache()
34
34
 
35
35
  const pre = ctx.flags.snapshot ? `-snap.${ctx.git.sha.slice(0, 7)}` : undefined
36
36
  // re-resolve: the fetched tags will give us a new latest version
@@ -38,7 +38,7 @@ export const preflight = async (pkg, ctx) => {
38
38
  pkg.version = resolvePkgVersion(pkg.releaseType, latestVersion, pkg.manifest.version, pre)
39
39
  pkg.manifest.version = pkg.version
40
40
  pkg.tag = formatTag({name: pkg.name, version: pkg.version, format: pkg.config.tagFormat})
41
- return 'ok'
41
+ return 'refetch'
42
42
  }
43
43
 
44
44
  // diverged — anomaly
@@ -1,6 +1,6 @@
1
1
  import {semver} from 'zx-extra'
2
+ import {api} from '../../api/index.js'
2
3
  import {log} from '../../log.js'
3
- import {getCommits} from '../../api/git.js'
4
4
  import {updateDeps} from '../deps.js'
5
5
  import {formatTag} from '../generators/tag.js'
6
6
 
@@ -41,7 +41,7 @@ export const semanticRules = [
41
41
  ]
42
42
 
43
43
  export const getSemanticChanges = async (cwd, from, to, rules = semanticRules) => {
44
- const commits = await getCommits(cwd, from, to)
44
+ const commits = await api.git.getCommits(cwd, from, to)
45
45
 
46
46
  return analyzeCommits(commits, rules)
47
47
  }
@@ -1,5 +1,5 @@
1
1
  import {memoizeBy} from '../../../util.js'
2
- import {fetchPkg} from '../../api/npm.js'
2
+ import {api} from '../../api/index.js'
3
3
  import {traverseDeps} from '../deps.js'
4
4
  import {exec} from '../exec.js'
5
5
 
@@ -8,7 +8,7 @@ export const build = memoizeBy(async (pkg, ctx = pkg.ctx) => {
8
8
  await Promise.all([
9
9
  traverseDeps({pkg, packages, cb: ({pkg}) => build(pkg, ctx)}),
10
10
  pkg.manifest.private !== true && pkg.changes.length === 0 && pkg.config.npmFetch && flags.npmFetch !== false
11
- ? fetchPkg(pkg)
11
+ ? api.npm.fetchPkg(pkg)
12
12
  : Promise.resolve()
13
13
  ])
14
14
 
@@ -1,7 +1,6 @@
1
- import {unsetUserConfig} from '../../api/git.js'
2
- import {npmRestore} from '../../api/npm.js'
1
+ import {api} from '../../api/index.js'
3
2
 
4
3
  export const clean = async ({cwd, packages}) => {
5
- await unsetUserConfig(cwd)
6
- await Promise.all(Object.values(packages).filter(p => !p.skipped).map(npmRestore))
4
+ await api.git.unsetUserConfig(cwd)
5
+ await Promise.all(Object.values(packages).filter(p => !p.skipped).map(api.npm.npmRestore))
7
6
  }
@@ -1,6 +1,6 @@
1
1
  import {getPkgConfig} from '../../../config.js'
2
+ import {api} from '../../api/index.js'
2
3
  import {getLatest} from '../generators/meta.js'
3
- import {getRoot, getSha, getCommitTimestamp} from '../../api/git.js'
4
4
 
5
5
  /**
6
6
  * Global release context — one per `run()` invocation.
@@ -42,9 +42,9 @@ export const contextify = async (pkg, ctx) => {
42
42
  pkg.ctx = {
43
43
  ...ctx,
44
44
  git: {
45
- sha: await getSha(ctx.cwd),
46
- root: await getRoot(ctx.cwd),
47
- timestamp: await getCommitTimestamp(ctx.cwd),
45
+ sha: await api.git.getSha(ctx.cwd),
46
+ root: await api.git.getRoot(ctx.cwd),
47
+ timestamp: await api.git.getCommitTimestamp(ctx.cwd),
48
48
  },
49
49
  }
50
50
  }
@@ -1,12 +1,10 @@
1
- import crypto from 'node:crypto'
2
1
  import {$, tempy, fs, path} from 'zx-extra'
3
2
  import {memoizeBy, asTuple} from '../../../util.js'
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 {npmPersist} from '../../api/npm.js'
7
- import {getRepo} from '../../api/git.js'
6
+ import {sanitizePkgName} from '../../parcel/build.js'
8
7
  import {formatReleaseNotes} from '../generators/notes.js'
9
- import {ghPrepareAssets} from '../../api/gh.js'
10
8
  import {packTar, hashFile} from '../../tar.js'
11
9
 
12
10
  export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
@@ -15,12 +13,12 @@ export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
15
13
  const active = getActiveChannels(pkg, channelNames, snapshot)
16
14
 
17
15
  await prepare(active, pkg)
18
- await npmPersist(pkg)
16
+ await api.npm.npmPersist(pkg)
19
17
 
20
18
  const outputDir = flags.pack ? path.resolve(ctx.git.root, typeof flags.pack === 'string' ? flags.pack : PARCELS_DIR) : null
21
19
  const stageDir = outputDir || tempy.temporaryDirectory()
22
20
  if (outputDir) await fs.ensureDir(outputDir)
23
- const {repoName, repoHost, originUrl} = await getRepo(pkg.absPath, {basicAuth: pkg.config.ghBasicAuth})
21
+ const {repoName, repoHost, originUrl} = await api.git.getRepo(pkg.absPath, {basicAuth: pkg.config.ghBasicAuth})
24
22
  const artifacts = {repoName, repoHost, originUrl}
25
23
 
26
24
  if (active.includes('npm')) {
@@ -35,19 +33,20 @@ export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
35
33
  await fs.copy(path.join(pkg.absPath, from), artifacts.docsDir)
36
34
  }
37
35
  if (active.includes('gh-release') && pkg.config.ghAssets?.length)
38
- artifacts.assetsDir = await ghPrepareAssets(pkg.config.ghAssets, pkg.absPath)
36
+ artifacts.assetsDir = await api.gh.ghPrepareAssets(pkg.config.ghAssets, pkg.absPath)
39
37
 
40
38
  const parcels = buildParcels(pkg, ctx, {channels: active, ...artifacts})
41
39
 
42
40
  const tars = []
43
41
  for (const {channel, manifest, files} of parcels) {
44
42
  // Two-pass: pack to temp, hash, rename to final name.
45
- const tmpPath = path.join(stageDir, `_tmp.${crypto.randomUUID()}.tar`)
43
+ const tmpPath = tempy.temporaryFile({extension: 'tar'})
46
44
  await packTar(tmpPath, manifest, files)
47
45
  const hash = await hashFile(tmpPath)
48
46
  const sha7 = ctx.git.sha.slice(0, 7)
49
- const finalPath = path.join(stageDir, `parcel.${sha7}.${channel}.${pkg.tag}.${hash}.tar`)
50
- await fs.rename(tmpPath, finalPath)
47
+ const safeName = sanitizePkgName(pkg.name)
48
+ const finalPath = path.join(stageDir, `parcel.${sha7}.${channel}.${safeName}.${pkg.version}.${hash}.tar`)
49
+ await fs.move(tmpPath, finalPath)
51
50
  tars.push(finalPath)
52
51
  }
53
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
+ }
@@ -11,10 +11,15 @@ 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
13
  import {buildDirective, PARCELS_DIR} from '../parcel/index.js'
14
+ import {CONTEXT_FILE, readContext} from '../depot/context.js'
14
15
 
15
16
  export const runPack = async ({cwd, env, flags}, ctx) => {
16
17
  const {report, packages, queue, prev} = ctx
17
18
 
19
+ // When running in split pipeline (receive → pack → deliver),
20
+ // the context file is the source of truth for what to release.
21
+ const contextFilter = await loadContextFilter(cwd, flags)
22
+
18
23
  const forEachPkg = (cb) => traverseQueue({queue, prev, cb: (name) => within(async () => {
19
24
  $.scope = name
20
25
  await contextify(packages[name], ctx)
@@ -42,8 +47,20 @@ export const runPack = async ({cwd, env, flags}, ctx) => {
42
47
 
43
48
  const packed = []
44
49
  await forEachPkg(async (pkg) => {
45
- if (!pkg.releaseType) { pkg.skipped = true; return report.setStatus('skipped', pkg.name) }
46
- if (await preflight(pkg, pkg.ctx) === 'skip') { pkg.skipped = true; return report.setStatus('skipped', pkg.name) }
50
+ if (contextFilter) {
51
+ // Split pipeline: context is the source of truth
52
+ const ctxPkg = contextFilter[pkg.name]
53
+ if (!ctxPkg) { pkg.skipped = true; return report.setStatus('skipped', pkg.name) }
54
+ pkg.version = ctxPkg.version
55
+ pkg.tag = ctxPkg.tag
56
+ pkg.manifest.version = ctxPkg.version
57
+ if (!pkg.releaseType) pkg.releaseType = 'patch'
58
+ } else {
59
+ // Standalone: own analysis decides
60
+ if (!pkg.releaseType) { pkg.skipped = true; return report.setStatus('skipped', pkg.name) }
61
+ if (await preflight(pkg, pkg.ctx) === 'skip') { pkg.skipped = true; return report.setStatus('skipped', pkg.name) }
62
+ }
63
+
47
64
  if (flags.build !== false) { report.setStatus('building', pkg.name); await build(pkg) }
48
65
  if (flags.test !== false) { report.setStatus('testing', pkg.name); await test(pkg) }
49
66
  if (flags.dryRun || flags.publish === false) return report.setStatus('success', pkg.name)
@@ -67,3 +84,15 @@ export const runPack = async ({cwd, env, flags}, ctx) => {
67
84
  }
68
85
  report.setStatus('success').log('Great success!')
69
86
  }
87
+
88
+ const loadContextFilter = async (cwd, flags) => {
89
+ if (!flags.pack) return null
90
+ try {
91
+ const context = await readContext(path.resolve(cwd, CONTEXT_FILE))
92
+ if (context.status === 'proceed') {
93
+ log.info(`using context as package filter (${Object.keys(context.packages).length} package(s))`)
94
+ return context.packages
95
+ }
96
+ } catch { /* no context file — standalone mode */ }
97
+ return null
98
+ }
@@ -1,5 +1,6 @@
1
1
  import {$, within} from 'zx-extra'
2
2
 
3
+ import {api} from '../api/index.js'
3
4
  import {log} from '../log.js'
4
5
  import {traverseQueue} from '../depot/deps.js'
5
6
  import {contextify} from '../depot/steps/contextify.js'
@@ -9,21 +10,20 @@ import {preflight} from '../depot/reconcile.js'
9
10
  import {consumeRebuildSignal} from '../courier/semaphore.js'
10
11
  import {getActiveChannels} from '../courier/index.js'
11
12
  import {writeContext, buildContext} from '../depot/context.js'
12
- import {getSha} from '../api/git.js'
13
- import {setOutput, isRebuildTrigger} from '../api/gh.js'
13
+ import {getLatest} from '../depot/generators/meta.js'
14
14
 
15
15
  export const runReceive = async ({cwd, env, flags}, ctx) => {
16
16
  const {report, packages, queue, prev} = ctx
17
17
 
18
- const sha = await getSha(cwd)
18
+ const sha = await api.git.getSha(cwd)
19
19
  const sha7 = sha.slice(0, 7)
20
20
 
21
- if (isRebuildTrigger(env) && !flags.dryRun) {
21
+ if (api.gh.isRebuildTrigger(env) && !flags.dryRun) {
22
22
  const result = await consumeRebuildSignal(cwd, sha)
23
23
  if (result?.exitCode !== 0 && result?.stderr?.includes('remote ref does not exist')) {
24
24
  log.info(`rebuild signal already consumed by another process`)
25
25
  await writeContext(cwd, {status: 'skip', reason: 'rebuild claimed by another process'})
26
- setOutput('status', 'skip')
26
+ api.gh.setOutput('status', 'skip')
27
27
  return report.setStatus('success')
28
28
  }
29
29
  log.info(`consumed rebuild signal for ${sha7}`)
@@ -36,16 +36,41 @@ export const runReceive = async ({cwd, env, flags}, ctx) => {
36
36
  })})
37
37
 
38
38
  try {
39
+ // Phase 1: analyze all packages
39
40
  await forEachPkg(async (pkg) => {
40
41
  report.setStatus('analyzing', pkg.name)
41
42
  await analyze(pkg)
42
43
  })
43
44
 
45
+ // Phase 2: preflight — may fetch remote tags and re-resolve versions
46
+ let tagsFetched = false
44
47
  await forEachPkg(async (pkg) => {
45
48
  if (!pkg.releaseType) { pkg.skipped = true; return }
46
- if (await preflight(pkg, pkg.ctx) === 'skip') { pkg.skipped = true; return }
49
+ const result = await preflight(pkg, pkg.ctx)
50
+ if (result === 'skip') { pkg.skipped = true; return }
51
+ if (result === 'refetch') tagsFetched = true
47
52
  })
48
53
 
54
+ // Phase 3: if preflight fetched tags, dependency cascade may have changed —
55
+ // re-analyze packages that had no changes on the first pass.
56
+ if (tagsFetched) {
57
+ log.info('tags fetched during preflight, re-analyzing for dependency cascades')
58
+ for (const name of queue) {
59
+ const pkg = packages[name]
60
+ if (pkg.releaseType) continue
61
+ pkg.latest = await getLatest(pkg)
62
+ }
63
+
64
+ await forEachPkg(async (pkg) => {
65
+ if (pkg.releaseType) return
66
+ pkg.skipped = false
67
+ report.setStatus('re-analyzing', pkg.name)
68
+ await analyze(pkg)
69
+ if (!pkg.releaseType) { pkg.skipped = true; return }
70
+ if (await preflight(pkg, pkg.ctx) === 'skip') { pkg.skipped = true }
71
+ })
72
+ }
73
+
49
74
  const snapshot = !!flags.snapshot
50
75
  const context = buildContext(packages, queue, sha, {
51
76
  getChannels: (pkg) => getActiveChannels(pkg, ctx.channels, snapshot),
@@ -56,10 +81,10 @@ export const runReceive = async ({cwd, env, flags}, ctx) => {
56
81
  if (count === 0) {
57
82
  log.info('nothing to release')
58
83
  await writeContext(cwd, {status: 'skip', reason: 'nothing to release'})
59
- setOutput('status', 'skip')
84
+ api.gh.setOutput('status', 'skip')
60
85
  } else {
61
86
  log.info(`${count} package(s) to release`)
62
- setOutput('status', 'proceed')
87
+ api.gh.setOutput('status', 'proceed')
63
88
  }
64
89
  } catch (e) {
65
90
  report.error(e, e.stack).setStatus('failure')
@@ -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}`)
@@ -38,6 +38,10 @@ export const run = async ({cwd = process.cwd(), env: _env, flags = {}} = {}) =>
38
38
  })
39
39
 
40
40
  export const createContext = async ({flags, env, cwd}) => {
41
+ // Ensure remote tags are up-to-date before analysis to avoid
42
+ // inconsistencies between receive (context) and pack (parcels).
43
+ try { await $({cwd, quiet: true})`git fetch origin --tags --force` } catch { /* offline / bare */ }
44
+
41
45
  const {packages, queue, root, prev, graphs} = await topo({cwd, flags})
42
46
  const report = createReport({packages, queue, flags})
43
47
 
@@ -111,6 +111,7 @@ export const makeCtx = (overrides = {}) => ({
111
111
  graphs: overrides.graphs ?? {},
112
112
  report: overrides.report ?? makeReport(),
113
113
  channels: overrides.channels ?? [],
114
+
114
115
  run: overrides.run ?? (async () => {}),
115
116
  git: {sha: 'abc1234567890', root: tmpDir, timestamp: '1700000000', ...overrides.git},
116
117
  })