zx-bulk-release 3.1.2 → 3.1.4

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.4](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.3...v3.1.4) (2026-04-13)
2
+
3
+ ### Fixes & improvements
4
+ * fix: sync receive/pack via context-driven pack filter ([5b539ff](https://github.com/semrel-extra/zx-bulk-release/commit/5b539ff327b07c171c523b36f28cc2e05c4346d6))
5
+ * refactor: introduce internal di layer ([a32773d](https://github.com/semrel-extra/zx-bulk-release/commit/a32773d37e9454953029f18e0af7de33486c14e3))
6
+
7
+ ## [3.1.3](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.2...v3.1.3) (2026-04-13)
8
+
9
+ ### Fixes & improvements
10
+ * fix: ensure temp tar dirs ([ffc0b25](https://github.com/semrel-extra/zx-bulk-release/commit/ffc0b2512c16540a4d260ffc6e02488a96b68194))
11
+
1
12
  ## [3.1.2](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.1...v3.1.2) (2026-04-13)
2
13
 
3
14
  ### 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.2",
4
+ "version": "3.1.4",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
@@ -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,15 @@
1
- import {pushTag, DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
1
+ import {api} from '../../api/index.js'
2
+ import {DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
2
3
 
3
4
  export const isTagConflict = (e) =>
4
5
  /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
5
6
 
6
- const run = async (manifest) => {
7
+ const run = async (manifest, dir) => {
7
8
  const {tag, cwd} = manifest
8
9
  const gitCommitterName = manifest.gitCommitterName || DEFAULT_GIT_COMMITTER_NAME
9
10
  const gitCommitterEmail = manifest.gitCommitterEmail || DEFAULT_GIT_COMMITTER_EMAIL
10
11
  try {
11
- await pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
12
+ await api.git.pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
12
13
  return 'ok'
13
14
  } catch (e) {
14
15
  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'),
@@ -1,4 +1,4 @@
1
- import {pushAnnotatedTag, deleteRemoteTag} from '../api/git.js'
1
+ import {api} from '../api/index.js'
2
2
 
3
3
  const tagName = ({sha}) =>
4
4
  `zbr-deliver.${sha.slice(0, 7)}`
@@ -11,7 +11,7 @@ export const tryLock = async (cwd, directive) => {
11
11
  packages: directive.queue,
12
12
  })
13
13
  try {
14
- await pushAnnotatedTag(cwd, tag, body)
14
+ await api.git.pushAnnotatedTag(cwd, tag, body)
15
15
  return true
16
16
  } catch {
17
17
  return false
@@ -19,13 +19,13 @@ export const tryLock = async (cwd, directive) => {
19
19
  }
20
20
 
21
21
  export const unlock = async (cwd, directive) => {
22
- await deleteRemoteTag(cwd, tagName(directive))
22
+ await api.git.deleteRemoteTag(cwd, tagName(directive))
23
23
  }
24
24
 
25
25
  export const signalRebuild = async (cwd, sha) => {
26
26
  const tag = `zbr-rebuild.${sha.slice(0, 7)}`
27
- try { await pushAnnotatedTag(cwd, tag, 'rebuild') } catch { /* already signaled */ }
27
+ try { await api.git.pushAnnotatedTag(cwd, tag, 'rebuild') } catch { /* already signaled */ }
28
28
  }
29
29
 
30
30
  export const consumeRebuildSignal = async (cwd, sha) =>
31
- deleteRemoteTag(cwd, `zbr-rebuild.${sha.slice(0, 7)}`)
31
+ 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,11 +1,9 @@
1
1
  import {$, tempy, fs, path} from 'zx-extra'
2
2
  import {memoizeBy, asTuple} from '../../../util.js'
3
+ import {api} from '../../api/index.js'
3
4
  import {prepare, getActiveChannels} from '../../courier/index.js'
4
5
  import {buildParcels, PARCELS_DIR} from '../../parcel/index.js'
5
- import {npmPersist} from '../../api/npm.js'
6
- import {getRepo} from '../../api/git.js'
7
6
  import {formatReleaseNotes} from '../generators/notes.js'
8
- import {ghPrepareAssets} from '../../api/gh.js'
9
7
  import {packTar, hashFile} from '../../tar.js'
10
8
 
11
9
  export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
@@ -14,12 +12,12 @@ export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
14
12
  const active = getActiveChannels(pkg, channelNames, snapshot)
15
13
 
16
14
  await prepare(active, pkg)
17
- await npmPersist(pkg)
15
+ await api.npm.npmPersist(pkg)
18
16
 
19
17
  const outputDir = flags.pack ? path.resolve(ctx.git.root, typeof flags.pack === 'string' ? flags.pack : PARCELS_DIR) : null
20
18
  const stageDir = outputDir || tempy.temporaryDirectory()
21
19
  if (outputDir) await fs.ensureDir(outputDir)
22
- const {repoName, repoHost, originUrl} = await getRepo(pkg.absPath, {basicAuth: pkg.config.ghBasicAuth})
20
+ const {repoName, repoHost, originUrl} = await api.git.getRepo(pkg.absPath, {basicAuth: pkg.config.ghBasicAuth})
23
21
  const artifacts = {repoName, repoHost, originUrl}
24
22
 
25
23
  if (active.includes('npm')) {
@@ -34,19 +32,19 @@ export const pack = memoizeBy(async (pkg, ctx = pkg.ctx) => {
34
32
  await fs.copy(path.join(pkg.absPath, from), artifacts.docsDir)
35
33
  }
36
34
  if (active.includes('gh-release') && pkg.config.ghAssets?.length)
37
- artifacts.assetsDir = await ghPrepareAssets(pkg.config.ghAssets, pkg.absPath)
35
+ artifacts.assetsDir = await api.gh.ghPrepareAssets(pkg.config.ghAssets, pkg.absPath)
38
36
 
39
37
  const parcels = buildParcels(pkg, ctx, {channels: active, ...artifacts})
40
38
 
41
39
  const tars = []
42
40
  for (const {channel, manifest, files} of parcels) {
43
41
  // Two-pass: pack to temp, hash, rename to final name.
44
- const tmpPath = path.join(stageDir, `_tmp.${channel}.tar`)
42
+ const tmpPath = tempy.temporaryFile({extension: 'tar'})
45
43
  await packTar(tmpPath, manifest, files)
46
44
  const hash = await hashFile(tmpPath)
47
45
  const sha7 = ctx.git.sha.slice(0, 7)
48
46
  const finalPath = path.join(stageDir, `parcel.${sha7}.${channel}.${pkg.tag}.${hash}.tar`)
49
- await fs.rename(tmpPath, finalPath)
47
+ await fs.move(tmpPath, finalPath)
50
48
  tars.push(finalPath)
51
49
  }
52
50
 
@@ -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')
@@ -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
 
@@ -5,6 +5,7 @@ import {pipeline} from 'node:stream/promises'
5
5
  import {createWriteStream, createReadStream} from 'node:fs'
6
6
 
7
7
  export const packTar = async (tarPath, manifest, files = []) => {
8
+ await fs.ensureDir(path.dirname(tarPath))
8
9
  const pack = tar.pack()
9
10
  const json = JSON.stringify(manifest, null, 2)
10
11
  pack.entry({name: 'manifest.json', size: Buffer.byteLength(json)}, json)
@@ -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
  })