bulk-release 2.17.0 → 2.18.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,13 @@
1
+ ## [2.18.1](https://github.com/semrel-extra/zx-bulk-release/compare/v2.18.0...v2.18.1) (2026-04-05)
2
+
3
+ ### Fixes & improvements
4
+ * fix: enhance rollback ([3338298](https://github.com/semrel-extra/zx-bulk-release/commit/3338298a0f7685b809e7ac1daae43c6d02a19557))
5
+
6
+ ## [2.18.0](https://github.com/semrel-extra/zx-bulk-release/compare/v2.17.0...v2.18.0) (2026-04-05)
7
+
8
+ ### Features
9
+ * feat: introduce recovery mode ([2f15fd8](https://github.com/semrel-extra/zx-bulk-release/commit/2f15fd83cfb41a4a55a4ab12d880c00e24c5c717))
10
+
1
11
  ## [2.17.0](https://github.com/semrel-extra/zx-bulk-release/compare/v2.16.2...v2.17.0) (2026-04-05)
2
12
 
3
13
  ### Features
package/README.md CHANGED
@@ -50,6 +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 exists but npm publish failed) and exit. Re-run without this flag to release. | |
53
54
  | `--debug` | Enable [zx](https://github.com/google/zx#verbose) verbose mode | |
54
55
  | `--version` / `-v` | Print own version | |
55
56
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bulk-release",
3
3
  "alias": "bulk-release",
4
- "version": "2.17.0",
4
+ "version": "2.18.1",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
@@ -5,7 +5,7 @@ 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
9
  import {analyze} from '../steps/analyze.js'
10
10
  import {build} from '../steps/build.js'
11
11
  import {publish} from '../steps/publish.js'
@@ -28,6 +28,19 @@ export const run = async ({cwd = process.cwd(), env, flags = {}} = {}) => within
28
28
  .log()('queue:', queue)
29
29
  .log()('graphs', graphs)
30
30
 
31
+ // --recover: standalone mode — clean orphan tags and exit.
32
+ // Run the full pipeline again after this to rebuild and publish affected packages.
33
+ if (flags.recover) {
34
+ let recovered = 0
35
+ for (const name of queue) {
36
+ const pkg = packages[name]
37
+ await contextify(pkg, context)
38
+ if (await recover(pkg)) recovered++
39
+ }
40
+ report.log()(`recover: cleaned ${recovered} orphan tag(s)`)
41
+ return
42
+ }
43
+
31
44
  try {
32
45
  await traverseQueue({queue, prev, async cb(name) {
33
46
  report.setStatus('analyzing', name)
@@ -1,7 +1,9 @@
1
1
  import {getPkgConfig} from '../config.js'
2
- import {getLatest} from '../processor/meta.js'
3
- import {getRoot, getSha} from '../api/git.js'
4
- import {$} from 'zx-extra'
2
+ import {getLatest, getArtifactPath} from '../processor/meta.js'
3
+ import {getRoot, getSha, getRepo, deleteRemoteTag, fetchRepo, pushCommit} from '../api/git.js'
4
+ import {fetchManifest} from '../api/npm.js'
5
+ import {log} from '../log.js'
6
+ import {$, fs, path, fetch} from 'zx-extra'
5
7
 
6
8
  // Inspired by https://docs.github.com/en/actions/learn-github-actions/contexts
7
9
  export const contextify = async (pkg, {packages, root, flags, env}) => {
@@ -17,3 +19,134 @@ export const contextify = async (pkg, {packages, root, flags, env}) => {
17
19
  packages
18
20
  }
19
21
  }
22
+
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) => {
85
+ const needsNpm = !pkg.manifest.private && pkg.config.npmPublish !== false
86
+ if (!needsNpm) return false
87
+
88
+ const {tag} = pkg.latest
89
+ if (!tag) return false
90
+
91
+ const manifest = await fetchManifest({
92
+ name: pkg.name,
93
+ version: tag.version,
94
+ config: pkg.config,
95
+ }, {nothrow: true})
96
+
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)
146
+ }
147
+
148
+ // 3. Delete orphan git tag
149
+ await deleteRemoteTag({cwd, tag: tag.ref})
150
+
151
+ return true
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
  }