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 +10 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/main/js/processor/release.js +14 -1
- package/src/main/js/steps/contextify.js +136 -3
- package/src/main/js/steps/publish.js +4 -6
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
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
if (tag && needsNpm) {
|
|
43
|
-
await deleteRemoteTag({cwd, tag})
|
|
40
|
+
if (needsNpm) {
|
|
41
|
+
await rollbackRelease(pkg)
|
|
44
42
|
}
|
|
45
43
|
throw e
|
|
46
44
|
}
|