bulk-release 2.18.0 → 2.18.2

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,13 @@
1
+ ## [2.18.2](https://github.com/semrel-extra/zx-bulk-release/compare/v2.18.1...v2.18.2) (2026-04-05)
2
+
3
+ ### Fixes & improvements
4
+ * fix: enhance recovery ([2bb5c4d](https://github.com/semrel-extra/zx-bulk-release/commit/2bb5c4dbd9ccf9a54ecc26065f5693517b815472))
5
+
6
+ ## [2.18.1](https://github.com/semrel-extra/zx-bulk-release/compare/v2.18.0...v2.18.1) (2026-04-05)
7
+
8
+ ### Fixes & improvements
9
+ * fix: enhance rollback ([3338298](https://github.com/semrel-extra/zx-bulk-release/commit/3338298a0f7685b809e7ac1daae43c6d02a19557))
10
+
1
11
  ## [2.18.0](https://github.com/semrel-extra/zx-bulk-release/compare/v2.17.0...v2.18.0) (2026-04-05)
2
12
 
3
13
  ### Features
package/README.md CHANGED
@@ -50,7 +50,7 @@ GH_TOKEN=ghtoken GH_USER=username NPM_TOKEN=npmtoken npx zx-bulk-release [opts]
50
50
  | `--dry-run` / `--no-publish` | Disable any publish logic | |
51
51
  | `--report` | Persist release state to file | |
52
52
  | `--snapshot` | Disable any publishing steps except of `npm` and `publishCmd` (if defined), then push packages to the `snapshot` channel | |
53
- | `--recover` | Remove orphan git tags (tag pushed but npm publish failed) and retry release | |
53
+ | `--recover` | Remove orphan git tags (tag exists but npm publish failed) and exit. Re-run without this flag to release. | |
54
54
  | `--debug` | Enable [zx](https://github.com/google/zx#verbose) verbose mode | |
55
55
  | `--version` / `-v` | Print own version | |
56
56
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bulk-release",
3
3
  "alias": "bulk-release",
4
- "version": "2.18.0",
4
+ "version": "2.18.2",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
@@ -115,6 +115,9 @@ export const pushTag = async ({cwd, tag, gitCommitterName, gitCommitterEmail}) =
115
115
  git push origin ${tag}`
116
116
  }
117
117
 
118
+ export const fetchTags = async (cwd) =>
119
+ $({cwd})`git fetch --tags`
120
+
118
121
  export const deleteRemoteTag = async ({cwd, tag}) => {
119
122
  log()(`rolling back remote tag '${tag}'`)
120
123
  await $({cwd, nothrow: true})`git push origin :refs/tags/${tag}`
@@ -5,7 +5,8 @@ import {queuefy} from 'queuefy'
5
5
  import {topo, traverseQueue} from './deps.js'
6
6
  import {createReport} from '../log.js'
7
7
  import {exec} from './exec.js'
8
- import {contextify} from '../steps/contextify.js'
8
+ import {contextify, recover} from '../steps/contextify.js'
9
+ import {fetchTags} from '../api/git.js'
9
10
  import {analyze} from '../steps/analyze.js'
10
11
  import {build} from '../steps/build.js'
11
12
  import {publish} from '../steps/publish.js'
@@ -28,6 +29,20 @@ export const run = async ({cwd = process.cwd(), env, flags = {}} = {}) => within
28
29
  .log()('queue:', queue)
29
30
  .log()('graphs', graphs)
30
31
 
32
+ // --recover: standalone mode — clean orphan tags and exit.
33
+ // Run the full pipeline again after this to rebuild and publish affected packages.
34
+ if (flags.recover) {
35
+ await fetchTags(cwd)
36
+ let recovered = 0
37
+ for (const name of queue) {
38
+ const pkg = packages[name]
39
+ await contextify(pkg, context)
40
+ if (await recover(pkg)) recovered++
41
+ }
42
+ report.log()(`recover: cleaned ${recovered} orphan tag(s)`)
43
+ return
44
+ }
45
+
31
46
  try {
32
47
  await traverseQueue({queue, prev, async cb(name) {
33
48
  report.setStatus('analyzing', name)
@@ -1,19 +1,14 @@
1
1
  import {getPkgConfig} from '../config.js'
2
- import {getLatest} from '../processor/meta.js'
3
- import {getRoot, getSha, deleteRemoteTag} from '../api/git.js'
2
+ import {getLatest, getArtifactPath} from '../processor/meta.js'
3
+ import {getRoot, getSha, getRepo, deleteRemoteTag, fetchRepo, pushCommit} from '../api/git.js'
4
4
  import {fetchManifest} from '../api/npm.js'
5
5
  import {log} from '../log.js'
6
- import {$} from 'zx-extra'
6
+ import {$, fs, path, fetch} from 'zx-extra'
7
7
 
8
8
  // Inspired by https://docs.github.com/en/actions/learn-github-actions/contexts
9
9
  export const contextify = async (pkg, {packages, root, flags, env}) => {
10
10
  pkg.config = await getPkgConfig([pkg.absPath, root.absPath], env)
11
11
  pkg.latest = await getLatest(pkg)
12
-
13
- if (flags.recover && pkg.latest.tag) {
14
- await recover(pkg)
15
- }
16
-
17
12
  pkg.context = {
18
13
  git: {
19
14
  sha: await getSha(pkg.absPath),
@@ -25,24 +20,133 @@ export const contextify = async (pkg, {packages, root, flags, env}) => {
25
20
  }
26
21
  }
27
22
 
28
- // Verify that the latest tagged version actually exists on npm.
29
- // If not (tag was pushed but npm publish failed), delete the orphan tag
30
- // so the next analyze phase can re-detect changes and retry the release.
31
- const recover = async (pkg) => {
23
+ // Rollback a release that failed mid-publish (called inline from publish.js).
24
+ // Unlike `recover`, this uses the current release tag (pkg.context.git.tag)
25
+ // and skips the npm existence check we already know the release failed.
26
+ export const rollbackRelease = async (pkg) => {
27
+ const tag = pkg.context.git.tag
28
+ if (!tag) return
29
+
30
+ const cwd = pkg.context.git.root
31
+ const {ghBasicAuth: basicAuth, ghToken, gitCommitterName, gitCommitterEmail} = pkg.config
32
+ const {repoName} = await getRepo(cwd, {basicAuth})
33
+
34
+ log({pkg})(`rollback: cleaning up failed release for tag '${tag}'`)
35
+
36
+ // 1. Delete GitHub release
37
+ if (ghToken) {
38
+ try {
39
+ const res = await fetch(`https://api.github.com/repos/${repoName}/releases/tags/${tag}`, {
40
+ headers: {Authorization: `token ${ghToken}`, 'X-GitHub-Api-Version': '2022-11-28'}
41
+ })
42
+ if (res.ok) {
43
+ const {id} = await res.json()
44
+ await fetch(`https://api.github.com/repos/${repoName}/releases/${id}`, {
45
+ method: 'DELETE',
46
+ headers: {Authorization: `token ${ghToken}`, 'X-GitHub-Api-Version': '2022-11-28'}
47
+ })
48
+ log({pkg})(`rollback: deleted gh release for '${tag}'`)
49
+ }
50
+ } catch (e) {
51
+ log({pkg, level: 'warn'})('rollback: failed to delete gh release', e)
52
+ }
53
+ }
54
+
55
+ // 2. Remove meta entry from meta branch
56
+ try {
57
+ const metaBranch = 'meta'
58
+ const metaCwd = await fetchRepo({cwd, branch: metaBranch, basicAuth})
59
+ const artifactPath = getArtifactPath(tag)
60
+ const candidates = [
61
+ path.resolve(metaCwd, `${artifactPath}.json`),
62
+ path.resolve(metaCwd, artifactPath)
63
+ ]
64
+ let removed = false
65
+ for (const p of candidates) {
66
+ if (fs.existsSync(p)) {
67
+ await fs.remove(p)
68
+ removed = true
69
+ }
70
+ }
71
+ if (removed) {
72
+ await pushCommit({cwd, branch: metaBranch, msg: `chore: rollback ${pkg.name} ${pkg.version}`, gitCommitterName, gitCommitterEmail, basicAuth})
73
+ log({pkg})(`rollback: removed meta for '${tag}'`)
74
+ }
75
+ } catch (e) {
76
+ log({pkg, level: 'warn'})('rollback: failed to clean meta branch', e)
77
+ }
78
+
79
+ // 3. Delete git tag
80
+ await deleteRemoteTag({cwd, tag})
81
+ }
82
+
83
+ // Rollback a partially failed release: delete orphan tag, meta, changelog, gh release.
84
+ export const recover = async (pkg) => {
32
85
  const needsNpm = !pkg.manifest.private && pkg.config.npmPublish !== false
33
- if (!needsNpm) return
86
+ if (!needsNpm) return false
34
87
 
35
88
  const {tag} = pkg.latest
89
+ if (!tag) return false
90
+
36
91
  const manifest = await fetchManifest({
37
92
  name: pkg.name,
38
93
  version: tag.version,
39
94
  config: pkg.config,
40
95
  }, {nothrow: true})
41
96
 
42
- if (!manifest) {
43
- const cwd = await getRoot(pkg.absPath)
44
- log({pkg})(`recover: tag '${tag.ref}' exists but ${pkg.name}@${tag.version} not found on npm, removing orphan tag`)
45
- await deleteRemoteTag({cwd, tag: tag.ref})
46
- pkg.latest = await getLatest(pkg)
97
+ if (manifest) return false
98
+
99
+ const cwd = await getRoot(pkg.absPath)
100
+ const {ghBasicAuth: basicAuth, ghToken, gitCommitterName, gitCommitterEmail} = pkg.config
101
+ const {repoName} = await getRepo(cwd, {basicAuth})
102
+
103
+ log({pkg})(`recover: tag '${tag.ref}' exists but ${pkg.name}@${tag.version} not found on npm, rolling back failed release`)
104
+
105
+ // 1. Delete GitHub release (also removes attached meta assets)
106
+ if (ghToken) {
107
+ try {
108
+ const res = await fetch(`https://api.github.com/repos/${repoName}/releases/tags/${tag.ref}`, {
109
+ headers: {Authorization: `token ${ghToken}`, 'X-GitHub-Api-Version': '2022-11-28'}
110
+ })
111
+ if (res.ok) {
112
+ const {id} = await res.json()
113
+ await fetch(`https://api.github.com/repos/${repoName}/releases/${id}`, {
114
+ method: 'DELETE',
115
+ headers: {Authorization: `token ${ghToken}`, 'X-GitHub-Api-Version': '2022-11-28'}
116
+ })
117
+ log({pkg})(`recover: deleted gh release for '${tag.ref}'`)
118
+ }
119
+ } catch (e) {
120
+ log({pkg, level: 'warn'})('recover: failed to delete gh release', e)
121
+ }
122
+ }
123
+
124
+ // 2. Remove meta entry from meta branch
125
+ try {
126
+ const metaBranch = 'meta'
127
+ const metaCwd = await fetchRepo({cwd, branch: metaBranch, basicAuth})
128
+ const artifactPath = getArtifactPath(tag.ref)
129
+ const candidates = [
130
+ path.resolve(metaCwd, `${artifactPath}.json`),
131
+ path.resolve(metaCwd, artifactPath)
132
+ ]
133
+ let removed = false
134
+ for (const p of candidates) {
135
+ if (fs.existsSync(p)) {
136
+ await fs.remove(p)
137
+ removed = true
138
+ }
139
+ }
140
+ if (removed) {
141
+ await pushCommit({cwd, branch: metaBranch, msg: `chore: recover ${pkg.name} ${tag.version}`, gitCommitterName, gitCommitterEmail, basicAuth})
142
+ log({pkg})(`recover: removed meta for '${tag.ref}'`)
143
+ }
144
+ } catch (e) {
145
+ log({pkg, level: 'warn'})('recover: failed to clean meta branch', e)
47
146
  }
147
+
148
+ // 3. Delete orphan git tag
149
+ await deleteRemoteTag({cwd, tag: tag.ref})
150
+
151
+ return true
48
152
  }
@@ -5,7 +5,7 @@ import {npmPersist, npmPublish} from '../api/npm.js'
5
5
  import {prepareMeta, pushMeta, pushReleaseTag} from '../processor/meta.js'
6
6
  import {pushChangelog} from '../api/changelog.js'
7
7
  import {ghPages, ghRelease} from '../api/gh.js'
8
- import {deleteRemoteTag} from '../api/git.js'
8
+ import {rollbackRelease} from './contextify.js'
9
9
 
10
10
  export const publish = memoizeBy(async (pkg, run = exec) => within(async () => {
11
11
  $.scope = pkg.name
@@ -34,13 +34,11 @@ export const publish = memoizeBy(async (pkg, run = exec) => within(async () => {
34
34
  run(pkg, 'publishCmd')
35
35
  ])
36
36
  } catch (e) {
37
- // Rollback the tag only for npm-published packages so the next run can retry.
37
+ // Rollback the entire failed release for npm-published packages.
38
38
  // Git-tag-only packages (private or npmPublish: false) keep their tag — it IS the release.
39
39
  const needsNpm = !pkg.manifest.private && pkg.config.npmPublish !== false
40
- const cwd = pkg.context.git.root
41
- const tag = pkg.context.git.tag
42
- if (tag && needsNpm) {
43
- await deleteRemoteTag({cwd, tag})
40
+ if (needsNpm) {
41
+ await rollbackRelease(pkg)
44
42
  }
45
43
  throw e
46
44
  }