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 +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/main/js/api/git.js +3 -0
- package/src/main/js/processor/release.js +16 -1
- package/src/main/js/steps/contextify.js +122 -18
- package/src/main/js/steps/publish.js +4 -6
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
|
|
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
package/src/main/js/api/git.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
const
|
|
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 (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|
|
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
|
}
|