zx-bulk-release 3.0.5 → 3.1.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +146 -24
  3. package/package.json +2 -2
  4. package/src/main/js/config.js +3 -2
  5. package/src/main/js/post/api/gh.js +11 -0
  6. package/src/main/js/post/api/git.js +19 -0
  7. package/src/main/js/post/api/npm.js +5 -5
  8. package/src/main/js/post/courier/channels/changelog.js +12 -3
  9. package/src/main/js/post/courier/channels/gh-pages.js +8 -1
  10. package/src/main/js/post/courier/channels/gh-release.js +11 -1
  11. package/src/main/js/post/courier/channels/git-tag.js +24 -0
  12. package/src/main/js/post/courier/channels/meta.js +9 -3
  13. package/src/main/js/post/courier/channels/npm.js +27 -14
  14. package/src/main/js/post/courier/index.js +137 -24
  15. package/src/main/js/post/courier/semaphore.js +31 -0
  16. package/src/main/js/post/courier/seniority.js +19 -0
  17. package/src/main/js/post/depot/context.js +46 -0
  18. package/src/main/js/post/depot/reconcile.js +47 -0
  19. package/src/main/js/post/depot/steps/contextify.js +2 -1
  20. package/src/main/js/post/depot/steps/pack.js +6 -10
  21. package/src/main/js/post/depot/steps/publish.js +1 -11
  22. package/src/main/js/post/modes/deliver.js +23 -0
  23. package/src/main/js/post/modes/pack.js +69 -0
  24. package/src/main/js/post/modes/receive.js +72 -0
  25. package/src/main/js/post/modes/verify.js +41 -0
  26. package/src/main/js/post/{courier/parcel.js → parcel/build.js} +15 -2
  27. package/src/main/js/post/parcel/directive.js +81 -0
  28. package/src/main/js/post/parcel/index.js +4 -0
  29. package/src/main/js/post/parcel/verify.js +46 -0
  30. package/src/main/js/post/release.js +44 -86
  31. package/src/main/js/post/tar.js +1 -1
  32. package/src/main/js/util.js +16 -0
  33. package/src/test/js/utils/mock.js +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [3.1.1](https://github.com/semrel-extra/zx-bulk-release/compare/v3.1.0...v3.1.1) (2026-04-12)
2
+
3
+ ### Fixes & improvements
4
+ * perf: add `--help` ([99fcfde](https://github.com/semrel-extra/zx-bulk-release/commit/99fcfde332c7730e335e46b4c2a8c844d53f8986))
5
+ * refactor: separate parcel subdomain ([b168044](https://github.com/semrel-extra/zx-bulk-release/commit/b16804497693b42998cea2638ff6d11bd29eb84f))
6
+
7
+ ## [3.1.0](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.5...v3.1.0) (2026-04-12)
8
+
9
+ ### Features
10
+ * feat: coordinated delivery with supply chain security ([772be14](https://github.com/semrel-extra/zx-bulk-release/commit/772be14525e751cf9ca805380d63330c446cd965))
11
+
12
+ ### Fixes & improvements
13
+ * fix: make git-tag a courier channel, remove pushTag from publish ([a34ceb9](https://github.com/semrel-extra/zx-bulk-release/commit/a34ceb9d6ca40b8e8f57d29c80f6f4f055927711))
14
+ * fix: improve logging ([09461c7](https://github.com/semrel-extra/zx-bulk-release/commit/09461c70ee35843fdc106db9a151869657467951))
15
+
1
16
  ## [3.0.5](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.4...v3.0.5) (2026-04-11)
2
17
 
3
18
  ### Fixes & improvements
package/README.md CHANGED
@@ -14,17 +14,17 @@
14
14
  * Pkg changelogs go to `changelog` branch (configurable).
15
15
  * Docs are published to `gh-pages` branch (configurable).
16
16
  * No extra builds. The required deps are fetched from the pkg registry (`npmFetch` config opt).
17
- * **Two-phase pipeline**: build and delivery can run as separate jobs with isolated credentials.
18
-
17
+ * **Flexible pipeline**: run all phases in one command, split into two (pack/deliver), or use all four (receive/pack/verify/deliver) for maximum supply chain security.
18
+ * **Coordinated delivery**: multiple release agents can safely serve the same monorepo concurrently via git-tag-based semaphores.
19
19
 
20
20
  > [!NOTE]
21
- > **[Migration guide v2 → v3](./MIGRATION_V2_V3.md)**
21
+ > **[Migration guide v2 → v3](./MIGRATION_V2_V3.md)** | **[Delivery specification](./DELIVER_SPEC.md)** | **[Security model](./SECURITY.md)**
22
22
 
23
23
  ## Roadmap
24
24
  * [x] Store release metrics to `meta`.
25
25
  * [x] Two-phase pipeline (pack / deliver).
26
+ * [x] Semaphore. Let several release agents serve the monorepo at the same time.
26
27
  * [ ] Multistack. Add support for java/kt/py.
27
- * [ ] Semaphore. Let several release agents to serve the monorepo at the same time.
28
28
 
29
29
  ## Requirements
30
30
  * macOS / linux
@@ -44,7 +44,10 @@ GH_TOKEN=ghtoken GH_USER=username NPM_TOKEN=npmtoken npx zx-bulk-release [opts]
44
44
  ```
45
45
  | Flag | Description | Default |
46
46
  |------------------------------|---------------------------------------------------------------------------------------------------|------------------|
47
+ | `--receive` | Consume rebuild signal, analyze, preflight. Writes `.zbr-context.json`. Run before deps install. | |
47
48
  | `--pack [dir]` | Pack only: build, test, and write delivery tars to `dir`. No credentials needed. | `parcels` |
49
+ | `--verify [dir]` | Verify untrusted parcels against context, copy valid ones to `parcels/`. Use `--context` for path. | `parcels` |
50
+ | `--context <path>` | Path to trusted `.zbr-context.json` (used with `--verify`). | `.zbr-context.json` |
48
51
  | `--deliver [dir]` | Deliver only: read tars from `dir` and run delivery channels. No source code needed. | `parcels` |
49
52
  | `--ignore` | Packages to ignore: `a, b` | |
50
53
  | `--include-private` | Include `private` packages | `false` |
@@ -59,8 +62,29 @@ GH_TOKEN=ghtoken GH_USER=username NPM_TOKEN=npmtoken npx zx-bulk-release [opts]
59
62
  | `--debug` | Enable [zx](https://github.com/google/zx#verbose) verbose mode | |
60
63
  | `--version` / `-v` | Print own version | |
61
64
 
62
- ### Two-phase pipeline
63
- By default, zbr runs the full pipeline in a single process. For better security isolation, split build and delivery into separate CI jobs:
65
+ ### Pipeline modes
66
+
67
+ zbr supports three deployment schemes — pick the one that matches your security requirements.
68
+
69
+ #### All-in-one
70
+ A single command runs all phases in one process. Simple but requires all credentials at build time.
71
+
72
+ ```yaml
73
+ jobs:
74
+ release:
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ with: { fetch-depth: 0 }
79
+ - run: yarn install
80
+ - run: npx zx-bulk-release
81
+ env:
82
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
83
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
84
+ ```
85
+
86
+ #### Two phases: pack + deliver
87
+ Build and delivery run as separate jobs. The pack phase needs no credentials — tars contain `${{ENV_VAR}}` templates resolved at delivery time.
64
88
 
65
89
  ```yaml
66
90
  jobs:
@@ -69,11 +93,12 @@ jobs:
69
93
  steps:
70
94
  - uses: actions/checkout@v4
71
95
  with: { fetch-depth: 0 }
96
+ - run: yarn install
72
97
  - run: npx zx-bulk-release --pack
73
98
  - uses: actions/upload-artifact@v4
74
99
  with:
75
- name: parcels
76
- path: parcels
100
+ name: parcels-${{ github.run_id }}
101
+ path: parcels/
77
102
  retention-days: 1
78
103
  if-no-files-found: ignore
79
104
 
@@ -83,19 +108,91 @@ jobs:
83
108
  steps:
84
109
  - uses: actions/download-artifact@v4
85
110
  id: download
86
- with: { name: parcels, path: parcels }
111
+ with:
112
+ name: parcels-${{ github.run_id }}
113
+ path: parcels/
87
114
  continue-on-error: true
88
115
  - if: steps.download.outcome == 'success'
89
116
  run: npx zx-bulk-release --deliver
90
117
  env:
91
118
  GH_TOKEN: ${{ secrets.GH_TOKEN }}
92
119
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
93
- - if: steps.download.outcome == 'success'
120
+ GIT_COMMITTER_NAME: Semrel Extra Bot
121
+ GIT_COMMITTER_EMAIL: semrel-extra-bot@hotmail.com
122
+ ```
123
+
124
+ #### Four phases: receive + pack + verify + deliver
125
+ Maximum supply chain security. Each phase has strict trust boundaries. See [SECURITY.md](./SECURITY.md) for the threat model.
126
+
127
+ - **receive** — runs *before* `yarn install`, consumes rebuild signals, analyzes, writes trusted context
128
+ - **pack** — runs *after* deps install with zero credentials, builds and packs tars
129
+ - **verify** — validates untrusted parcels against the trusted context
130
+ - **deliver** — delivers only verified parcels
131
+
132
+ ```yaml
133
+ on:
134
+ push:
135
+ branches: [master]
136
+ tags: ['zbr-rebuild.*']
137
+
138
+ jobs:
139
+ build:
140
+ runs-on: ubuntu-latest
141
+ steps:
142
+ - uses: actions/checkout@v4
143
+ with: { fetch-depth: 0 }
144
+
145
+ # receive — runs BEFORE deps install, safe to use GH_TOKEN
146
+ - run: npx zx-bulk-release --receive
147
+ id: receive
148
+ env:
149
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
150
+
151
+ # Trust anchor: context uploaded before any third-party code
152
+ - uses: actions/upload-artifact@v4
153
+ with:
154
+ name: context-${{ github.run_id }}
155
+ path: .zbr-context.json
156
+
157
+ # pack — deps installed, hostile code may run, zero credentials
158
+ - if: steps.receive.outputs.status == 'proceed'
159
+ run: |
160
+ yarn install
161
+ npx zx-bulk-release --pack
162
+
163
+ - if: steps.receive.outputs.status == 'proceed'
94
164
  uses: actions/upload-artifact@v4
95
- with: { name: parcels, path: parcels, overwrite: true, retention-days: 1 }
165
+ with:
166
+ name: parcels-${{ github.run_id }}
167
+ path: parcels/
168
+
169
+ deliver:
170
+ needs: build
171
+ runs-on: ubuntu-latest
172
+ steps:
173
+ # Download trusted context and untrusted parcels separately
174
+ - uses: actions/download-artifact@v4
175
+ with:
176
+ name: context-${{ github.run_id }}
177
+ path: .
178
+ - uses: actions/download-artifact@v4
179
+ with:
180
+ name: parcels-${{ github.run_id }}
181
+ path: parcels-unverified/
182
+
183
+ # verify — validate untrusted parcels against trusted context
184
+ - run: npx zx-bulk-release --verify parcels-unverified/
185
+
186
+ # deliver — only verified parcels
187
+ - run: npx zx-bulk-release --deliver
188
+ env:
189
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
190
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
191
+ GIT_COMMITTER_NAME: Semrel Extra Bot
192
+ GIT_COMMITTER_EMAIL: semrel-extra-bot@hotmail.com
96
193
  ```
97
194
 
98
- After delivery, each tar is replaced with a marker (`released` or `skip`). The final `upload-artifact` syncs these markers back to CI storage, so a re-run of the deliver job will skip already-delivered parcels. Artifacts expire naturally via retention policy.
195
+ After delivery, each tar is replaced with a marker (`released`, `skip`, `conflict`, or `orphan`). In the four-phase mode, parcels are verified against the trusted context (uploaded before deps install) before any delivery begins.
99
196
 
100
197
  **Recovery** is simply re-running the deliver job. Only undelivered tars (not yet replaced with markers) will be processed. No rebuild required.
101
198
 
@@ -254,6 +351,7 @@ The release pipeline is split into three subsystems under `post/`:
254
351
 
255
352
  ```
256
353
  post/
354
+ modes/ — pipeline entry points: receive, pack, verify, deliver
257
355
  depot/ — preparation: analysis, versioning, building, testing, tar packing
258
356
  courier/ — sealed delivery: receives self-describing tars and delivers through channels
259
357
  api/ — shared infrastructure wrappers (git, npm, gh)
@@ -263,21 +361,28 @@ This separation ensures that courier never touches the project directory — it
263
361
 
264
362
  ### Flow
265
363
  ```
266
- topo ─► contextify ─► analyze ──► build ──► test ──► pack ──► publish ─► clean
267
- (per pkg) (per pkg) (per pkg) (per pkg) (per pkg) (per pkg)
364
+ receive: topo -> contextify -> analyze -> preflight -> context.json
365
+ pack: build -> test -> pack -> directive
366
+ verify: validate parcels against context -> copy to output
367
+ deliver: deliver parcels
368
+
369
+ all-in-one: topo -> contextify -> analyze -> preflight -> build -> test -> pack -> publish -> clean
268
370
  ```
269
371
  [`@semrel-extra/topo`](https://github.com/semrel-extra/topo) resolves the release queue respecting dependency graphs. The graph allows parallel execution where the dependency tree permits; `memoizeBy` prevents duplicate work when a package is reached by multiple paths.
270
372
 
271
373
  By default, packages marked as `private` are omitted. Override with `--include-private`.
272
374
 
375
+ **Preflight** runs between `analyze` and `build`. It checks tag availability on git remote and eliminates version conflicts before wasting build/test time. If a tag is already taken by a newer commit, preflight re-resolves the version. If taken by the same or an older commit, the package is skipped.
376
+
273
377
  ### Steps
274
378
  Each step has a uniform signature `(pkg, ctx)`:
275
379
  - **`contextify`** — resolves per-package config, latest release metadata, and git context.
276
380
  - **`analyze`** — determines semantic changes, release type, and next version.
381
+ - **`preflight`** — checks tag availability on remote, re-resolves version on conflict, skips duplicates.
277
382
  - **`build`** — runs `buildCmd` (with dep traversal and optional npm artifact fetch).
278
383
  - **`test`** — runs `testCmd`.
279
- - **`pack`** — stages delivery artifacts into self-describing tar containers (`npm pack`, docs copy, assets, release notes). Each tar is named `{tag}.{channel}.{hash8}.tar` and contains a `manifest.json` with channel name, delivery instructions, and template credentials (`${{ENV_VAR}}`). After this step, everything the courier needs is outside the project dir.
280
- - **`publish`** — pushes the release tag (commitment point), hands tars to courier's `deliver()`, runs `cmd` channel separately.
384
+ - **`pack`** — stages delivery artifacts into self-describing tar containers (`npm pack`, docs copy, assets, release notes). Each tar is named `parcel.{sha7}.{channel}.{tag}.{hash6}.tar` and contains a `manifest.json` with channel name, delivery instructions, and template credentials (`${{ENV_VAR}}`). A directive meta-parcel is also generated, listing all parcels and their delivery steps. After this step, everything the courier needs is outside the project dir.
385
+ - **`publish`** — hands tars to courier's `deliver()`, runs `cmd` channel separately. Tag push is handled by the `git-tag` channel.
281
386
  - **`clean`** — restores `package.json` files and unsets git user config.
282
387
 
283
388
  Set `config.releaseRules` to override the default rules preset:
@@ -292,25 +397,42 @@ Set `config.releaseRules` to override the default rules preset:
292
397
  ### Tar containers
293
398
  Each delivery artifact is a self-describing tar archive:
294
399
  ```
295
- {tag}.{channel}.{hash8}.tar
400
+ parcel.{sha7}.{channel}.{tag}.{hash6}.tar
296
401
  manifest.json — channel name, delivery params, template credentials
297
402
  package.tgz — (npm channel) npm tarball
298
403
  assets/ — (gh-release channel) release assets
299
404
  docs/ — (gh-pages channel) documentation files
300
405
  ```
406
+ The `sha7` prefix groups all parcels of one commit. The `hash6` suffix is a content hash for deduplication — two builds of the same commit producing identical content yield the same filename (last-writer-wins), while different content gets a different hash.
407
+
408
+ A **directive** meta-parcel (`parcel.{sha7}.directive.{ts}.tar`) is generated alongside regular parcels. It contains the complete delivery map: package queue, per-package channel steps, and an authoritative list of parcel filenames. The directive enables coordinated delivery — see [DELIVER_SPEC.md](./DELIVER_SPEC.md).
301
409
 
302
410
  The manifest contains `${{ENV_VAR}}` placeholders that are resolved by the courier at delivery time via `resolveManifest()`. This ensures credentials never touch the build phase.
303
411
 
304
412
  ### Channels
305
413
  Delivery channels are a registry of `{name, when, prepare?, run, requires?, snapshot?, transport?}` objects:
306
- - **meta** — pushes release metadata to the `meta` branch (or as a GH release asset).
307
- - **npm** — publishes to the npm registry.
308
- - **gh-release** — creates a GitHub release with optional file assets.
309
- - **gh-pages** — pushes docs to a `gh-pages` branch.
310
- - **changelog** — pushes a changelog entry to a `changelog` branch.
414
+ - **git-tag** — pushes the release tag to git remote. Runs before other channels. Returns `conflict` if the tag already exists. Skipped in snapshot mode.
415
+ - **meta** — pushes release metadata to the `meta` branch (or as a GH release asset). Checks docs seniority before committing.
416
+ - **npm** — publishes to the npm registry. Returns `duplicate` on `EPUBLISHCONFLICT` / 403.
417
+ - **gh-release** — creates a GitHub release with optional file assets (requires tag to exist). Returns `duplicate` on 422.
418
+ - **gh-pages** — pushes docs to a `gh-pages` branch. Checks docs seniority before committing.
419
+ - **changelog** — pushes a changelog entry to a `changelog` branch. Checks docs seniority before committing.
311
420
  - **cmd** — runs a custom `publishCmd` (depot-side, not through courier; `transport: false`).
312
421
 
313
- Each channel declares `requires` — a list of manifest fields that must be present after credential resolution. Courier validates all parcels before running any channel (fail-fast).
422
+ Each channel declares `requires` — a list of manifest fields that must be present after credential resolution. Courier validates before delivery; missing credentials produce a warning and `skip` marker.
423
+
424
+ Channels return one of: `ok` (success), `duplicate` (already published, goal achieved), or `conflict` (version collision, needs rebuild). Unrecoverable errors are thrown.
425
+
426
+ ### Coordinated delivery
427
+ When multiple zbr processes target the same monorepo concurrently, delivery is coordinated via git-tag-based semaphores:
428
+
429
+ 1. **Directive** — each build produces a meta-parcel listing all packages and their delivery steps.
430
+ 2. **Semaphore lock** — before delivering, a process pushes `zbr-deliver.{sha7}` as an annotated tag. Atomic push = ownership. Failure = another process is working.
431
+ 3. **Orphan cleanup** — the directive invalidates parcels not in its authoritative list (stale artifacts from previous builds).
432
+ 4. **Conflict resolution** — if `git-tag` returns `conflict`, all parcels of that package are marked, and a `zbr-rebuild.{sha7}` tag signals CI to rebuild with fresh versions.
433
+ 5. **Partial delivery** — skipped parcels (missing credentials) remain valid tarballs. Another process with the right credentials can pick them up later.
434
+
435
+ See [DELIVER_SPEC.md](./DELIVER_SPEC.md) for the full protocol specification.
314
436
 
315
437
  ### Credential flow
316
438
  ```
@@ -318,7 +440,7 @@ depot (build phase) courier (deliver phase)
318
440
  manifest: { token: '${{NPM_TOKEN}}' } → resolveManifest(manifest, env)
319
441
  → { token: 'actual-secret' }
320
442
  ```
321
- Template credentials (`${{ENV_VAR}}`) are written into manifests at pack time. Courier resolves them from `process.env` at delivery time. This means the build phase never sees real secrets.
443
+ Template credentials (`${{ENV_VAR}}`) are written into manifests at pack time. Courier resolves them from `process.env` at delivery time. This means the build phase never sees real secrets — including git push credentials (`GH_TOKEN`, `GIT_COMMITTER_NAME`, `GIT_COMMITTER_EMAIL`) which are now resolved by the `git-tag` channel at delivery time.
322
444
 
323
445
  ### Tags
324
446
  [Lerna](https://github.com/lerna/lerna) tags (like `@pkg/name@v1.0.0-beta.0`) are suitable for monorepos, but they don't follow [semver spec](https://semver.org/). Therefore, we propose another contract:
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "zx-bulk-release",
3
3
  "alias": "bulk-release",
4
- "version": "3.0.5",
4
+ "version": "3.1.1",
5
5
  "description": "zx-based alternative for multi-semantic-release",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/main/js/index.js",
9
9
  "./test-utils": "./src/test/js/utils/repo.js",
10
- "./meta": "./src/main/js/processor/generators/meta.js"
10
+ "./meta": "./src/main/js/post/depot/generators/meta.js"
11
11
  },
12
12
  "bin": "./src/main/js/cli.js",
13
13
  "files": [
@@ -1,6 +1,7 @@
1
1
  import { cosmiconfig } from 'cosmiconfig'
2
2
  import { asArray, camelize, memoizeBy } from './util.js'
3
3
  import { GH_URL, resolveGhApiUrl } from './post/api/gh.js'
4
+ import { DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL } from './post/api/git.js'
4
5
 
5
6
  const CONFIG_NAME = 'release'
6
7
  const CONFIG_FILES = [
@@ -72,8 +73,8 @@ export const parseEnv = ({GH_USER, GH_USERNAME, GH_META, GH_URL: _GH_URL, GITHUB
72
73
  npmProvenance: NPM_PROVENANCE,
73
74
  npmOidc: NPM_OIDC || (!NPM_TOKEN && ACTIONS_ID_TOKEN_REQUEST_URL),
74
75
  npmRegistry: NPM_REGISTRY || 'https://registry.npmjs.org',
75
- gitCommitterName: GIT_COMMITTER_NAME || 'Semrel Extra Bot',
76
- gitCommitterEmail: GIT_COMMITTER_EMAIL || 'semrel-extra-bot@hotmail.com',
76
+ gitCommitterName: GIT_COMMITTER_NAME || DEFAULT_GIT_COMMITTER_NAME,
77
+ gitCommitterEmail: GIT_COMMITTER_EMAIL || DEFAULT_GIT_COMMITTER_EMAIL,
77
78
  }
78
79
  }
79
80
 
@@ -1,5 +1,6 @@
1
1
  // Low-level GitHub API primitives. No domain knowledge, no imports from processor/ or steps/.
2
2
 
3
+ import nodeFs from 'node:fs'
3
4
  import {$, path, tempy, glob, fs, fetch} from 'zx-extra'
4
5
  import {asArray, attempt2} from '../../util.js'
5
6
 
@@ -88,3 +89,13 @@ export const ghGetAsset = async ({repoName, tag, name, ghUrl}) => {
88
89
  }
89
90
  return res.text()
90
91
  }
92
+
93
+ export const setOutput = (name, value) => {
94
+ const outputFile = process.env.GITHUB_OUTPUT
95
+ if (outputFile) {
96
+ try { nodeFs.appendFileSync(outputFile, `${name}=${value}\n`) } catch { /* not in CI */ }
97
+ }
98
+ }
99
+
100
+ export const isRebuildTrigger = (env) =>
101
+ !!(env.GITHUB_REF_TYPE === 'tag' && env.GITHUB_REF_NAME?.startsWith('zbr-rebuild.'))
@@ -1,3 +1,6 @@
1
+ export const DEFAULT_GIT_COMMITTER_NAME = 'Semrel Extra Bot'
2
+ export const DEFAULT_GIT_COMMITTER_EMAIL = 'semrel-extra-bot@hotmail.com'
3
+
1
4
  import {$, fs, path, tempy, copy} from 'zx-extra'
2
5
  import {log} from '../log.js'
3
6
  import {attempt2, attempt3, memoizeBy} from '../../util.js'
@@ -51,6 +54,8 @@ export const getRoot = memoizeBy(async (cwd) => (await $({cwd})`git rev-parse --
51
54
 
52
55
  export const getSha = memoizeBy(async (cwd) => (await $({cwd})`git rev-parse HEAD`).toString().trim(), getRoot)
53
56
 
57
+ export const getCommitTimestamp = memoizeBy(async (cwd) => (await $({cwd})`git log -1 --format=%ct`).toString().trim(), getRoot)
58
+
54
59
  export const parseOrigin = (originUrl) => {
55
60
  const [, , repoHost, repoName] = originUrl.replace(':', '/').replace(/\.git/, '').match(/.+(@|\/\/)([^/]+)\/(.+)$/) || []
56
61
 
@@ -117,3 +122,17 @@ export const setUserConfig = memoizeBy(async(cwd, gitCommitterName, gitCommitter
117
122
  export const unsetUserConfig = async(cwd) => $({cwd, nothrow: true})`
118
123
  git config --unset user.name &&
119
124
  git config --unset user.email`
125
+
126
+ export const getRemoteTagSha = async (cwd, tag) =>
127
+ (await $({cwd, nothrow: true})`git ls-remote --tags origin refs/tags/${tag}`)
128
+ .toString().split('\t')[0]?.trim() || null
129
+
130
+ export const deleteRemoteTag = async (cwd, tag) =>
131
+ $({cwd, nothrow: true})`git push origin :refs/tags/${tag}`
132
+
133
+ export const pushAnnotatedTag = async (cwd, tag, message) => {
134
+ await $({cwd})`git tag -a ${tag} -m ${message}`
135
+ await $({cwd})`git push origin ${tag}`
136
+ }
137
+
138
+ export const clearTagsCache = () => { $.memo?.delete?.(getTags) }
@@ -146,18 +146,18 @@ const unzip = (stream, {pick, omit, cwd = process.cwd(), strip = 0} = {}) => new
146
146
  extract.on('entry', ({name, type}, stream, cb) => {
147
147
  const _name = safePath(strip ? name.split('/').slice(strip).join('/') : name)
148
148
  const fp = _path.join(cwd, _name)
149
+ const skip = type !== 'file' || omit?.includes(_name) || (pick && !pick.includes(_name))
149
150
 
150
- let data = ''
151
+ const chunks = []
151
152
  stream.on('data', (chunk) => {
152
- if (type !== 'file' || omit?.includes(_name) || (pick && !pick.includes(_name))) return
153
- data += chunk
153
+ if (!skip) chunks.push(chunk)
154
154
  })
155
155
 
156
156
  stream.on('end', () => {
157
- if (data) {
157
+ if (chunks.length) {
158
158
  results.push(
159
159
  _fs.mkdir(_path.dirname(fp), {recursive: true})
160
- .then(() => _fs.writeFile(fp, data, 'utf8'))
160
+ .then(() => _fs.writeFile(fp, Buffer.concat(chunks)))
161
161
  )
162
162
  }
163
163
  cb()
@@ -1,16 +1,24 @@
1
- import {$} from 'zx-extra'
1
+ import {fs, path} from 'zx-extra'
2
2
  import {queuefy} from 'queuefy'
3
3
  import {fetchRepo, pushCommit} from '../../api/git.js'
4
4
  import {log} from '../../log.js'
5
+ import {hasHigherVersion} from '../seniority.js'
5
6
 
6
7
  const run = queuefy(async (manifest, dir) => {
7
- const {releaseNotes, branch, file, msg, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
8
+ const {name, version, releaseNotes, branch, file, msg, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
9
+
10
+ if (await hasHigherVersion(dir, name, version)) {
11
+ log.warn(`skipping changelog for ${name}@${version}: higher version already released`)
12
+ return 'ok'
13
+ }
8
14
 
9
15
  log.info('push changelog')
10
16
 
11
17
  const _cwd = await fetchRepo({branch, origin: repoAuthedUrl, basicAuth: ghBasicAuth})
18
+ const filePath = path.resolve(_cwd, file)
19
+ const prev = await fs.readFile(filePath, 'utf8').catch(() => '')
20
+ await fs.outputFile(filePath, releaseNotes + '\n' + prev)
12
21
 
13
- await $({cwd: _cwd})`echo ${releaseNotes}"\n$(cat ./${file})" > ./${file}`
14
22
  await pushCommit({
15
23
  branch,
16
24
  msg,
@@ -19,6 +27,7 @@ const run = queuefy(async (manifest, dir) => {
19
27
  gitCommitterName,
20
28
  basicAuth: ghBasicAuth,
21
29
  })
30
+ return 'ok'
22
31
  })
23
32
 
24
33
  export default {
@@ -2,11 +2,17 @@ import {path} from 'zx-extra'
2
2
  import {queuefy} from 'queuefy'
3
3
  import {log} from '../../log.js'
4
4
  import {pushCommit} from '../../api/git.js'
5
+ import {hasHigherVersion} from '../seniority.js'
5
6
 
6
7
  const run = queuefy(async (manifest, dir) => {
7
- const {branch, to, msg, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
8
+ const {name, version, branch, to, msg, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
8
9
  const docsDir = path.join(dir, 'docs')
9
10
 
11
+ if (await hasHigherVersion(docsDir, name, version)) {
12
+ log.warn(`skipping gh-pages for ${name}@${version}: higher version already released`)
13
+ return 'ok'
14
+ }
15
+
10
16
  log.info(`publish docs to ${branch}`)
11
17
 
12
18
  await pushCommit({
@@ -20,6 +26,7 @@ const run = queuefy(async (manifest, dir) => {
20
26
  gitCommitterName,
21
27
  basicAuth: ghBasicAuth,
22
28
  })
29
+ return 'ok'
23
30
  })
24
31
 
25
32
  export default {
@@ -2,6 +2,9 @@ import {fs, path} from 'zx-extra'
2
2
  import {log} from '../../log.js'
3
3
  import {ghCreateRelease, ghFetch} from '../../api/gh.js'
4
4
 
5
+ const isDuplicate = (e) =>
6
+ /already_exists|Validation Failed|422/i.test(e?.message || e?.stderr || '')
7
+
5
8
  const run = async (manifest, dir) => {
6
9
  const {tag, token, apiUrl, repoName, releaseNotes, assets} = manifest
7
10
  if (!token) return null
@@ -9,7 +12,13 @@ const run = async (manifest, dir) => {
9
12
  log.info('create gh release')
10
13
  const now = Date.now()
11
14
 
12
- const res = await ghCreateRelease({ghApiUrl: apiUrl, ghToken: token, repoName, tag, body: releaseNotes})
15
+ let res
16
+ try {
17
+ res = await ghCreateRelease({ghApiUrl: apiUrl, ghToken: token, repoName, tag, body: releaseNotes})
18
+ } catch (e) {
19
+ if (isDuplicate(e)) return 'duplicate'
20
+ throw e
21
+ }
13
22
 
14
23
  if (assets?.length) {
15
24
  const uploadUrl = res.upload_url.slice(0, res.upload_url.indexOf('{'))
@@ -25,6 +34,7 @@ const run = async (manifest, dir) => {
25
34
  }
26
35
 
27
36
  log.info(`duration gh release: ${Date.now() - now}`)
37
+ return 'ok'
28
38
  }
29
39
 
30
40
  export default {
@@ -0,0 +1,24 @@
1
+ import {pushTag, DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
2
+
3
+ export const isTagConflict = (e) =>
4
+ /already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
5
+
6
+ const run = async (manifest) => {
7
+ const {tag, cwd} = manifest
8
+ const gitCommitterName = manifest.gitCommitterName || DEFAULT_GIT_COMMITTER_NAME
9
+ const gitCommitterEmail = manifest.gitCommitterEmail || DEFAULT_GIT_COMMITTER_EMAIL
10
+ try {
11
+ await pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
12
+ return 'ok'
13
+ } catch (e) {
14
+ if (isTagConflict(e)) return 'conflict'
15
+ throw e
16
+ }
17
+ }
18
+
19
+ export default {
20
+ name: 'git-tag',
21
+ requires: ['tag', 'cwd'],
22
+ when: (pkg) => !pkg.ctx?.flags?.snapshot,
23
+ run,
24
+ }
@@ -1,12 +1,17 @@
1
1
  import {queuefy} from 'queuefy'
2
2
  import {log} from '../../log.js'
3
3
  import {pushCommit} from '../../api/git.js'
4
- import {getArtifactPath, isAssetMode} from '../../depot/generators/meta.js'
5
- import {prepareMeta} from '../../depot/generators/meta.js'
4
+ import {getArtifactPath, isAssetMode, prepareMeta} from '../../depot/generators/meta.js'
5
+ import {hasHigherVersion} from '../seniority.js'
6
6
 
7
7
  const pushMetaBranch = queuefy(async (manifest, dir) => {
8
8
  const {name, version, tag, type, data: meta, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
9
- if (type === null || isAssetMode(type)) return
9
+ if (type === null || isAssetMode(type)) return 'ok'
10
+
11
+ if (await hasHigherVersion(dir, name, version)) {
12
+ log.warn(`skipping meta for ${name}@${version}: higher version already released`)
13
+ return 'ok'
14
+ }
10
15
 
11
16
  log.info('push artifact to branch \'meta\'')
12
17
 
@@ -23,6 +28,7 @@ const pushMetaBranch = queuefy(async (manifest, dir) => {
23
28
  gitCommitterName,
24
29
  basicAuth: ghBasicAuth,
25
30
  })
31
+ return 'ok'
26
32
  })
27
33
 
28
34
  export default {
@@ -4,23 +4,36 @@ import {npmPublish} from '../../api/npm.js'
4
4
  export const isNpmPublished = (pkg) =>
5
5
  !pkg.manifest.private && pkg.config.npmPublish !== false
6
6
 
7
+ const isDuplicate = (e) =>
8
+ /EPUBLISHCONFLICT|cannot publish over|403/i.test(e?.message || e?.stderr || '')
9
+
10
+ const run = async (manifest, dir) => {
11
+ try {
12
+ await npmPublish({
13
+ name: manifest.name,
14
+ version: manifest.version,
15
+ npmTarball: path.join(dir, 'package.tgz'),
16
+ preversion: manifest.preversion,
17
+ manifest: {},
18
+ config: {
19
+ npmRegistry: manifest.registry,
20
+ npmToken: manifest.token,
21
+ npmConfig: manifest.config,
22
+ npmProvenance: manifest.provenance,
23
+ npmOidc: manifest.oidc,
24
+ },
25
+ })
26
+ return 'ok'
27
+ } catch (e) {
28
+ if (isDuplicate(e)) return 'duplicate'
29
+ throw e
30
+ }
31
+ }
32
+
7
33
  export default {
8
34
  name: 'npm',
9
35
  requires: (manifest) => manifest.oidc ? [] : ['token'],
10
36
  when: isNpmPublished,
11
- run: (manifest, dir) => npmPublish({
12
- name: manifest.name,
13
- version: manifest.version,
14
- npmTarball: path.join(dir, 'package.tgz'),
15
- preversion: manifest.preversion,
16
- manifest: {},
17
- config: {
18
- npmRegistry: manifest.registry,
19
- npmToken: manifest.token,
20
- npmConfig: manifest.config,
21
- npmProvenance: manifest.provenance,
22
- npmOidc: manifest.oidc,
23
- },
24
- }),
37
+ run,
25
38
  snapshot: true,
26
39
  }