bulk-release 2.21.0 → 3.0.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +140 -28
  3. package/package.json +12 -5
  4. package/src/main/js/index.js +1 -1
  5. package/src/main/js/{processor → post}/api/gh.js +0 -27
  6. package/src/main/js/{processor → post}/api/git.js +2 -10
  7. package/src/main/js/{processor → post}/api/npm.js +4 -2
  8. package/src/main/js/post/courier/channels/changelog.js +29 -0
  9. package/src/main/js/{processor/publishers → post/courier/channels}/cmd.js +1 -0
  10. package/src/main/js/post/courier/channels/gh-pages.js +30 -0
  11. package/src/main/js/post/courier/channels/gh-release.js +35 -0
  12. package/src/main/js/post/courier/channels/meta.js +34 -0
  13. package/src/main/js/post/courier/channels/npm.js +26 -0
  14. package/src/main/js/post/courier/index.js +113 -0
  15. package/src/main/js/post/courier/parcel.js +77 -0
  16. package/src/main/js/{processor → post/depot}/exec.js +2 -2
  17. package/src/main/js/{processor → post/depot}/generators/meta.js +3 -3
  18. package/src/main/js/{processor → post/depot}/generators/notes.js +1 -1
  19. package/src/main/js/{processor → post/depot}/steps/analyze.js +2 -2
  20. package/src/main/js/{processor → post/depot}/steps/build.js +2 -2
  21. package/src/main/js/{processor → post/depot}/steps/clean.js +2 -2
  22. package/src/main/js/{processor → post/depot}/steps/contextify.js +3 -3
  23. package/src/main/js/post/depot/steps/pack.js +59 -0
  24. package/src/main/js/post/depot/steps/publish.js +29 -0
  25. package/src/main/js/{processor → post/depot}/steps/test.js +1 -1
  26. package/src/main/js/{processor → post}/release.js +44 -37
  27. package/src/main/js/post/tar.js +73 -0
  28. package/src/test/js/utils/gh-server.js +33 -0
  29. package/src/test/js/utils/mock.js +132 -0
  30. package/src/test/js/{test-utils.js → utils/repo.js} +3 -3
  31. package/src/main/js/processor/publishers/changelog.js +0 -26
  32. package/src/main/js/processor/publishers/gh-pages.js +0 -32
  33. package/src/main/js/processor/publishers/gh-release.js +0 -41
  34. package/src/main/js/processor/publishers/meta.js +0 -58
  35. package/src/main/js/processor/publishers/npm.js +0 -15
  36. package/src/main/js/processor/steps/publish.js +0 -39
  37. package/src/main/js/processor/steps/teardown.js +0 -58
  38. /package/src/main/js/{processor → post/depot}/deps.js +0 -0
  39. /package/src/main/js/{processor → post/depot}/generators/tag.js +0 -0
  40. /package/src/main/js/{processor → post}/log.js +0 -0
@@ -0,0 +1,132 @@
1
+ import {PassThrough} from 'node:stream'
2
+ import {EventEmitter} from 'node:events'
3
+ import {tempy} from 'zx-extra'
4
+
5
+ export const tmpDir = tempy.temporaryDirectory()
6
+
7
+ function proc(stdout = '', code = 0) {
8
+ const p = new EventEmitter()
9
+ p.stdout = new PassThrough()
10
+ p.stderr = new PassThrough()
11
+ p.stdin = new PassThrough()
12
+ p.pid = 1
13
+ process.nextTick(() => {
14
+ p.stdout.end(stdout)
15
+ p.stderr.end('')
16
+ p.emit('close', code, null)
17
+ p.emit('exit', code, null)
18
+ })
19
+ return p
20
+ }
21
+
22
+ export function createMock(responses = []) {
23
+ const calls = []
24
+ const spawn = (cmd, args) => {
25
+ const command = args?.[args.length - 1] || cmd
26
+ calls.push(command)
27
+ for (const [pattern, output, code] of responses) {
28
+ if (typeof pattern === 'string' ? command.includes(pattern) : pattern.test(command))
29
+ return proc(output, code ?? 0)
30
+ }
31
+ return proc('')
32
+ }
33
+ return {spawn, calls}
34
+ }
35
+
36
+ export const has = (calls, pattern) =>
37
+ calls.some(c => typeof pattern === 'string' ? c.includes(pattern) : pattern.test(c))
38
+
39
+ export const gitResponses = (overrides = {}) => [
40
+ ['git rev-parse --show-toplevel', overrides.root ?? tmpDir],
41
+ ['git rev-parse HEAD', overrides.sha ?? 'abc1234567890'],
42
+ ['git config --get remote.origin.url', overrides.origin ?? 'https://github.com/test-org/test-repo.git'],
43
+ ['git tag -l', overrides.tags ?? ''],
44
+ [/git log .+--format/, overrides.log ?? ''],
45
+ ['git config user.name', ''],
46
+ ['git config user.email', ''],
47
+ [/git tag -m/, ''],
48
+ ['git push', ''],
49
+ ['git fetch', ''],
50
+ ['git clone', ''],
51
+ ['git init', ''],
52
+ ['git add', ''],
53
+ ['git commit', ''],
54
+ [/git remote/, ''],
55
+ [/git config --unset/, ''],
56
+ ]
57
+
58
+ export const npmResponses = (overrides = {}) => [
59
+ ['npm --version', overrides.npmVersion ?? '10.0.0'],
60
+ ['npm publish', ''],
61
+ ['npm view', overrides.view ?? ''],
62
+ ]
63
+
64
+ export const defaultResponses = (overrides = {}) => [
65
+ ...gitResponses(overrides),
66
+ ...npmResponses(overrides),
67
+ [/curl/, overrides.curl ?? '{}'],
68
+ [/echo/, ''],
69
+ ]
70
+
71
+ export const makePkg = (overrides = {}) => ({
72
+ name: overrides.name ?? 'test-pkg',
73
+ version: overrides.version ?? '1.0.1',
74
+ absPath: overrides.absPath ?? `${tmpDir}/packages/test-pkg`,
75
+ relPath: overrides.relPath ?? 'packages/test-pkg',
76
+ manifest: {
77
+ name: overrides.name ?? 'test-pkg',
78
+ version: overrides.version ?? '1.0.1',
79
+ private: overrides.private ?? false,
80
+ ...overrides.manifest,
81
+ },
82
+ config: {
83
+ buildCmd: 'echo build',
84
+ testCmd: 'echo test',
85
+ gitCommitterName: 'Bot',
86
+ gitCommitterEmail: 'bot@test.com',
87
+ ghBasicAuth: 'x-access-token:ghp_test',
88
+ ghToken: 'ghp_test',
89
+ npmPublish: true,
90
+ npmFetch: false,
91
+ changelog: 'changelog',
92
+ ...overrides.config,
93
+ },
94
+ changes: overrides.changes ?? [{group: 'Fixes', releaseType: 'patch', change: 'fix: test'}],
95
+ releaseType: overrides.releaseType ?? 'patch',
96
+ tag: overrides.tag ?? '2026.1.1-test-pkg.1.0.1-f0',
97
+ latest: overrides.latest ?? {tag: null, meta: null},
98
+ ctx: overrides.ctx ?? null,
99
+ ...overrides.extra,
100
+ })
101
+
102
+ export const makeCtx = (overrides = {}) => ({
103
+ cwd: overrides.cwd ?? tmpDir,
104
+ env: {PATH: process.env.PATH, HOME: process.env.HOME, GH_TOKEN: 'ghp_test', NPM_TOKEN: 'npm_test', ...overrides.env},
105
+ flags: {build: true, test: true, publish: true, ...overrides.flags},
106
+ root: {absPath: tmpDir, ...overrides.root},
107
+ packages: overrides.packages ?? {},
108
+ queue: overrides.queue ?? [],
109
+ prev: overrides.prev ?? {},
110
+ graphs: overrides.graphs ?? {},
111
+ report: overrides.report ?? makeReport(),
112
+ channels: overrides.channels ?? [],
113
+ run: overrides.run ?? (async () => {}),
114
+ git: {sha: 'abc1234567890', root: tmpDir, ...overrides.git},
115
+ })
116
+
117
+ export const makeReport = () => {
118
+ const events = []
119
+ return {
120
+ status: 'initial',
121
+ events,
122
+ packages: {},
123
+ log(...chunks) { events.push({level: 'info', msg: chunks}); return this },
124
+ warn(...chunks) { events.push({level: 'warn', msg: chunks}); return this },
125
+ error(...chunks) { events.push({level: 'error', msg: chunks}); return this },
126
+ set() { return this },
127
+ get() { return this },
128
+ setStatus() { return this },
129
+ getStatus() { return this },
130
+ save() { return this },
131
+ }
132
+ }
@@ -2,7 +2,7 @@ import {fs, path, tempy, $, sleep} from 'zx-extra'
2
2
  import {fileURLToPath} from 'node:url'
3
3
 
4
4
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
5
- export const fixtures = path.resolve(__dirname, '../fixtures')
5
+ export const fixtures = path.resolve(__dirname, '../../fixtures')
6
6
 
7
7
  export const createNpmRegistry = () => {
8
8
  let p
@@ -10,8 +10,8 @@ export const createNpmRegistry = () => {
10
10
  return {
11
11
  address: $.env.NPM_REGISTRY || 'http://localhost:4873',
12
12
  async start() {
13
- fs.removeSync(path.resolve(__dirname, '../../../storage'))
14
- const config = path.resolve(__dirname, '../../../verdaccio.config.yaml')
13
+ fs.removeSync(path.resolve(__dirname, '../../../../storage'))
14
+ const config = path.resolve(__dirname, '../../../../verdaccio.config.yaml')
15
15
  p = $({preferLocal: true})`verdaccio --config ${config}`
16
16
 
17
17
  return sleep(1000)
@@ -1,26 +0,0 @@
1
- import {$} from 'zx-extra'
2
- import {queuefy} from 'queuefy'
3
- import {fetchRepo, pushCommit} from '../api/git.js'
4
- import {formatReleaseNotes} from '../generators/notes.js'
5
- import {log} from '../log.js'
6
- import {asTuple, msgJoin} from '../../util.js'
7
-
8
- const run = queuefy(async (pkg) => {
9
- const {absPath: cwd, config: {changelog: opts, gitCommitterEmail, gitCommitterName, ghBasicAuth: basicAuth}} = pkg
10
- if (!opts) return
11
-
12
- log.info('push changelog')
13
- const [branch = 'changelog', file = `${pkg.name.replace(/[^a-z0-9-]/ig, '')}-changelog.md`, ..._msg] = asTuple(opts, ['branch', 'file', 'msg'])
14
- const _cwd = await fetchRepo({cwd, branch, basicAuth})
15
- const msg = msgJoin(_msg, pkg, 'chore: update changelog ${{name}}')
16
- const releaseNotes = await formatReleaseNotes(pkg)
17
-
18
- await $({cwd: _cwd})`echo ${releaseNotes}"\n$(cat ./${file})" > ./${file}`
19
- await pushCommit({cwd, branch, msg, gitCommitterEmail, gitCommitterName, basicAuth})
20
- })
21
-
22
- export default {
23
- name: 'changelog',
24
- when: (pkg) => !!pkg.config.changelog,
25
- run,
26
- }
@@ -1,32 +0,0 @@
1
- import {queuefy} from 'queuefy'
2
- import {path} from 'zx-extra'
3
- import {log} from '../log.js'
4
- import {pushCommit} from '../api/git.js'
5
- import {asTuple, msgJoin} from '../../util.js'
6
-
7
- const run = queuefy(async (pkg) => {
8
- const {config: {ghPages: opts, gitCommitterEmail, gitCommitterName, ghBasicAuth: basicAuth}} = pkg
9
- if (!opts) return
10
-
11
- const [branch = 'gh-pages', from = 'docs', to = '.', ..._msg] = asTuple(opts, ['branch', 'from', 'to', 'msg'])
12
- const msg = msgJoin(_msg, pkg, 'docs: update docs ${{name}} ${{version}}')
13
-
14
- log.info(`publish docs to ${branch}`)
15
-
16
- await pushCommit({
17
- cwd: path.join(pkg.absPath, from),
18
- from: '.',
19
- to,
20
- branch,
21
- msg,
22
- gitCommitterEmail,
23
- gitCommitterName,
24
- basicAuth,
25
- })
26
- })
27
-
28
- export default {
29
- name: 'gh-pages',
30
- when: (pkg) => !!pkg.config.ghPages,
31
- run,
32
- }
@@ -1,41 +0,0 @@
1
- import {log} from '../log.js'
2
- import {getRepo, getRoot} from '../api/git.js'
3
- import {ghCreateRelease, ghDeleteReleaseByTag, ghUploadAssets} from '../api/gh.js'
4
- import {formatTag} from '../generators/tag.js'
5
- import {formatReleaseNotes} from '../generators/notes.js'
6
-
7
- const run = async (pkg) => {
8
- const {ghBasicAuth: basicAuth, ghToken, ghAssets, ghApiUrl} = pkg.config
9
- if (!ghToken) return null
10
-
11
- log.info('create gh release')
12
-
13
- const now = Date.now()
14
- const {name, version, absPath: cwd, tag = formatTag({name, version})} = pkg
15
- const {repoName} = await getRepo(cwd, {basicAuth})
16
- const body = await formatReleaseNotes(pkg)
17
- const res = await ghCreateRelease({ghApiUrl, ghToken, repoName, tag, body})
18
-
19
- if (ghAssets?.length) {
20
- // GH API returns a pseudo-url `...releases/110103594/assets{?name,label}` — strip the template.
21
- const uploadUrl = res.upload_url.slice(0, res.upload_url.indexOf('{'))
22
- await ghUploadAssets({ghToken, ghAssets, uploadUrl, cwd})
23
- }
24
-
25
- log.info(`duration gh release: ${Date.now() - now}`)
26
- }
27
-
28
- const undo = async (pkg, {tag}) => {
29
- const {ghBasicAuth: basicAuth, ghToken, ghApiUrl} = pkg.config
30
- if (!ghToken) return
31
- const cwd = await getRoot(pkg.absPath)
32
- const {repoName} = await getRepo(cwd, {basicAuth})
33
- await ghDeleteReleaseByTag({ghApiUrl, ghToken, repoName, tag})
34
- }
35
-
36
- export default {
37
- name: 'gh-release',
38
- when: (pkg) => !!pkg.config.ghToken,
39
- run,
40
- undo,
41
- }
@@ -1,58 +0,0 @@
1
- // Meta publisher: pushes meta artifacts to the `meta` branch and undoes them on rollback.
2
- // The meta payload itself is built by the generator (../generators/meta.js).
3
-
4
- import {queuefy} from 'queuefy'
5
- import {fs, path} from 'zx-extra'
6
- import {log} from '../log.js'
7
- import {fetchRepo, pushCommit} from '../api/git.js'
8
- import {formatTag} from '../generators/tag.js'
9
- import {prepareMeta, getArtifactPath, isAssetMode} from '../generators/meta.js'
10
-
11
- // Push the meta artifact to the `meta` branch. No-op in asset mode (handled by gh-release).
12
- const pushMetaBranch = queuefy(async (pkg) => {
13
- const {type} = pkg.config.meta
14
- if (type === null || isAssetMode(type)) return
15
-
16
- log.info('push artifact to branch \'meta\'')
17
-
18
- const {name, version, meta, tag = formatTag({name, version}), absPath: cwd, config: {gitCommitterEmail, gitCommitterName, ghBasicAuth: basicAuth}} = pkg
19
- const msg = `chore: release meta ${name} ${version}`
20
- const files = [{relpath: `${getArtifactPath(tag)}.json`, contents: meta}]
21
-
22
- await pushCommit({cwd, to: '.', branch: 'meta', msg, files, gitCommitterEmail, gitCommitterName, basicAuth})
23
- })
24
-
25
- // Remove a meta artifact for a given tag from the meta branch.
26
- // Returns true if something was removed and pushed.
27
- const removeMetaArtifact = async (pkg, {tag, version, reason}) => {
28
- const {config: {meta: {type}, ghBasicAuth: basicAuth, gitCommitterName, gitCommitterEmail}} = pkg
29
- // Asset-mode meta lives inside the GH release; deleting the release already removes it.
30
- if (type === null || isAssetMode(type)) return false
31
-
32
- const cwd = pkg.ctx?.git?.root || pkg.absPath
33
- const metaCwd = await fetchRepo({cwd, branch: 'meta', basicAuth})
34
- const artifactPath = getArtifactPath(tag)
35
- const candidates = [
36
- path.resolve(metaCwd, `${artifactPath}.json`),
37
- path.resolve(metaCwd, artifactPath),
38
- ]
39
- let removed = false
40
- for (const p of candidates) {
41
- if (fs.existsSync(p)) {
42
- await fs.remove(p)
43
- removed = true
44
- }
45
- }
46
- if (removed) {
47
- await pushCommit({cwd, branch: 'meta', msg: `chore: ${reason} ${pkg.name} ${version}`, gitCommitterName, gitCommitterEmail, basicAuth})
48
- }
49
- return removed
50
- }
51
-
52
- export default {
53
- name: 'meta',
54
- when: (pkg) => pkg.config.meta.type !== null,
55
- prepare: prepareMeta,
56
- run: pushMetaBranch,
57
- undo: removeMetaArtifact,
58
- }
@@ -1,15 +0,0 @@
1
- import {npmPublish} from '../api/npm.js'
2
-
3
- // Domain predicate: does this package get published to npm?
4
- // Private packages and those with `npmPublish: false` are git-tag-only —
5
- // the tag itself IS the release.
6
- export const isNpmPublished = (pkg) =>
7
- !pkg.manifest.private && pkg.config.npmPublish !== false
8
-
9
- export default {
10
- name: 'npm',
11
- when: isNpmPublished,
12
- run: (pkg) => npmPublish(pkg),
13
- snapshot: true,
14
- // No undo: npm unpublish is unreliable (time-limited, cached by mirrors).
15
- }
@@ -1,39 +0,0 @@
1
- import {memoizeBy} from '../../util.js'
2
- import {exec} from '../exec.js'
3
- import {log} from '../log.js'
4
- import {npmPersist} from '../api/npm.js'
5
- import {pushTag} from '../api/git.js'
6
- import {formatTag} from '../generators/tag.js'
7
- import {isNpmPublished} from '../publishers/npm.js'
8
- import {rollbackRelease} from './teardown.js'
9
-
10
- const pushReleaseTag = async (pkg, ctx) => {
11
- const {name, version, tag = formatTag({name, version}), config: {gitCommitterEmail, gitCommitterName}} = pkg
12
- ctx.git.tag = tag
13
- log.info(`push release tag ${tag}`)
14
- await pushTag({cwd: ctx.git.root, tag, gitCommitterEmail, gitCommitterName})
15
- }
16
-
17
- export const publish = memoizeBy(async (pkg, ctx = pkg.ctx) => {
18
- if (pkg.version !== pkg.manifest.version)
19
- throw new Error('package.json version not synced')
20
-
21
- const {run = exec, publishers = [], flags} = ctx
22
- const snapshot = !!flags.snapshot
23
- const active = publishers.filter(p => (!snapshot || p.snapshot) && p.when(pkg))
24
-
25
- await npmPersist(pkg)
26
-
27
- // Prepare phase: serial pkg mutations (e.g. meta injects into ghAssets) — must finish before any run().
28
- for (const p of active) await p.prepare?.(pkg)
29
-
30
- if (!snapshot) await pushReleaseTag(pkg, ctx)
31
- try {
32
- await Promise.all(active.map(p => p.run(pkg, run)))
33
- } catch (e) {
34
- // Roll back full release for npm-published packages; git-tag-only packages keep their tag — it IS the release.
35
- if (!snapshot && isNpmPublished(pkg)) await rollbackRelease(pkg, ctx)
36
- throw e
37
- }
38
- pkg.published = true
39
- })
@@ -1,58 +0,0 @@
1
- // Release teardown: undo a published (or half-published) release.
2
- //
3
- // Two entry points share the same core:
4
- // - rollbackRelease: called inline from publish.js on mid-publish failure (tag known from pkg.ctx).
5
- // - recover: standalone --recover mode — detect orphan tags (tagged but missing on npm) and tear them down.
6
- //
7
- // Teardown walks the publishers registry in reverse and calls undo() on each that applies.
8
-
9
- import {log} from '../log.js'
10
- import {deleteRemoteTag} from '../api/git.js'
11
- import {fetchManifest} from '../api/npm.js'
12
- import {isNpmPublished} from '../publishers/npm.js'
13
-
14
- // Tear down a release: undo every applicable publisher, then delete the git tag.
15
- // Failures in individual undo steps are warned, not thrown — teardown is best-effort.
16
- const teardownRelease = async (pkg, ctx, {tag, version, reason}) => {
17
- if (!pkg.config.ghBasicAuth) throw new Error(`${reason} requires git credentials (GH_TOKEN)`)
18
-
19
- for (const p of [...ctx.publishers].reverse()) {
20
- if (!p.undo || !p.when(pkg)) continue
21
- try {
22
- const result = await p.undo(pkg, {tag, version, reason})
23
- if (result !== false) log.info(`${reason}: ${p.name} undone for '${tag}'`)
24
- } catch (e) {
25
- log.warn(`${reason}: ${p.name} undo failed`, e)
26
- }
27
- }
28
-
29
- await deleteRemoteTag({cwd: ctx.git.root, tag})
30
- }
31
-
32
- // Rollback a release that failed mid-publish (called inline from publish.js).
33
- // Uses the current release tag; skips the npm existence check — we already know it failed.
34
- export const rollbackRelease = async (pkg, ctx = pkg.ctx) => {
35
- const tag = ctx.git.tag
36
- if (!tag) return
37
- log.info(`rollback: cleaning up failed release for tag '${tag}'`)
38
- await teardownRelease(pkg, ctx, {tag, version: pkg.version, reason: 'rollback'})
39
- }
40
-
41
- // Standalone recovery: if a tag exists but the package is missing from npm, treat it as an orphan and tear down.
42
- export const recover = async (pkg, ctx = pkg.ctx) => {
43
- if (!isNpmPublished(pkg)) return false
44
-
45
- const {tag} = pkg.latest
46
- if (!tag) return false
47
-
48
- const manifest = await fetchManifest({
49
- name: pkg.name,
50
- version: tag.version,
51
- config: pkg.config,
52
- }, {nothrow: true})
53
- if (manifest) return false
54
-
55
- log.info(`recover: tag '${tag.ref}' exists but ${pkg.name}@${tag.version} not found on npm, rolling back failed release`)
56
- await teardownRelease(pkg, ctx, {tag: tag.ref, version: tag.version, reason: 'recover'})
57
- return true
58
- }
File without changes
File without changes