zx-bulk-release 3.0.4 → 3.1.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.
- package/CHANGELOG.md +14 -0
- package/README.md +146 -24
- package/package.json +1 -1
- package/src/main/js/config.js +3 -2
- package/src/main/js/post/api/gh.js +11 -0
- package/src/main/js/post/api/git.js +19 -0
- package/src/main/js/post/courier/channels/changelog.js +8 -1
- package/src/main/js/post/courier/channels/gh-pages.js +8 -1
- package/src/main/js/post/courier/channels/gh-release.js +11 -1
- package/src/main/js/post/courier/channels/git-tag.js +25 -0
- package/src/main/js/post/courier/channels/meta.js +8 -1
- package/src/main/js/post/courier/channels/npm.js +27 -14
- package/src/main/js/post/courier/directive.js +81 -0
- package/src/main/js/post/courier/index.js +164 -16
- package/src/main/js/post/courier/parcel.js +15 -2
- package/src/main/js/post/courier/semaphore.js +31 -0
- package/src/main/js/post/courier/seniority.js +19 -0
- package/src/main/js/post/depot/context.js +45 -0
- package/src/main/js/post/depot/reconcile.js +50 -0
- package/src/main/js/post/depot/steps/contextify.js +2 -1
- package/src/main/js/post/depot/steps/pack.js +3 -1
- package/src/main/js/post/depot/steps/publish.js +1 -11
- package/src/main/js/post/modes/deliver.js +24 -0
- package/src/main/js/post/modes/pack.js +71 -0
- package/src/main/js/post/modes/receive.js +71 -0
- package/src/main/js/post/modes/verify.js +83 -0
- package/src/main/js/post/release.js +23 -112
- package/src/main/js/post/tar.js +1 -1
- package/src/test/js/utils/mock.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [3.1.0](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.5...v3.1.0) (2026-04-12)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
* feat: coordinated delivery with supply chain security ([772be14](https://github.com/semrel-extra/zx-bulk-release/commit/772be14525e751cf9ca805380d63330c446cd965))
|
|
5
|
+
|
|
6
|
+
### Fixes & improvements
|
|
7
|
+
* fix: make git-tag a courier channel, remove pushTag from publish ([a34ceb9](https://github.com/semrel-extra/zx-bulk-release/commit/a34ceb9d6ca40b8e8f57d29c80f6f4f055927711))
|
|
8
|
+
* fix: improve logging ([09461c7](https://github.com/semrel-extra/zx-bulk-release/commit/09461c70ee35843fdc106db9a151869657467951))
|
|
9
|
+
|
|
10
|
+
## [3.0.5](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.4...v3.0.5) (2026-04-11)
|
|
11
|
+
|
|
12
|
+
### Fixes & improvements
|
|
13
|
+
* perf: minor code imprs ([2564f3f](https://github.com/semrel-extra/zx-bulk-release/commit/2564f3fd8fdd42c08a2aa32e1e9f2cfee7d3199d))
|
|
14
|
+
|
|
1
15
|
## [3.0.4](https://github.com/semrel-extra/zx-bulk-release/compare/v3.0.3...v3.0.4) (2026-04-11)
|
|
2
16
|
|
|
3
17
|
### 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
|
-
* **
|
|
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
|
-
###
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 `
|
|
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
|
|
267
|
-
|
|
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 `{
|
|
280
|
-
- **`publish`** —
|
|
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
|
-
{
|
|
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
|
-
- **
|
|
307
|
-
- **
|
|
308
|
-
- **
|
|
309
|
-
- **gh-
|
|
310
|
-
- **
|
|
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
|
|
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
package/src/main/js/config.js
CHANGED
|
@@ -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 ||
|
|
76
|
-
gitCommitterEmail: GIT_COMMITTER_EMAIL ||
|
|
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) }
|
|
@@ -2,9 +2,15 @@ import {$} 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
|
|
|
@@ -19,6 +25,7 @@ const run = queuefy(async (manifest, dir) => {
|
|
|
19
25
|
gitCommitterName,
|
|
20
26
|
basicAuth: ghBasicAuth,
|
|
21
27
|
})
|
|
28
|
+
return 'ok'
|
|
22
29
|
})
|
|
23
30
|
|
|
24
31
|
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
|
-
|
|
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,25 @@
|
|
|
1
|
+
import {pushTag} from '../../api/git.js'
|
|
2
|
+
import {DEFAULT_GIT_COMMITTER_NAME, DEFAULT_GIT_COMMITTER_EMAIL} from '../../api/git.js'
|
|
3
|
+
|
|
4
|
+
export const isTagConflict = (e) =>
|
|
5
|
+
/already exists|updates were rejected|failed to push/i.test(e?.message || e?.stderr || '')
|
|
6
|
+
|
|
7
|
+
const run = async (manifest) => {
|
|
8
|
+
const {tag, cwd} = manifest
|
|
9
|
+
const gitCommitterName = manifest.gitCommitterName || DEFAULT_GIT_COMMITTER_NAME
|
|
10
|
+
const gitCommitterEmail = manifest.gitCommitterEmail || DEFAULT_GIT_COMMITTER_EMAIL
|
|
11
|
+
try {
|
|
12
|
+
await pushTag({cwd, tag, gitCommitterName, gitCommitterEmail})
|
|
13
|
+
return 'ok'
|
|
14
|
+
} catch (e) {
|
|
15
|
+
if (isTagConflict(e)) return 'conflict'
|
|
16
|
+
throw e
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
name: 'git-tag',
|
|
22
|
+
requires: ['tag', 'cwd'],
|
|
23
|
+
when: (pkg) => !pkg.ctx?.flags?.snapshot,
|
|
24
|
+
run,
|
|
25
|
+
}
|
|
@@ -3,10 +3,16 @@ import {log} from '../../log.js'
|
|
|
3
3
|
import {pushCommit} from '../../api/git.js'
|
|
4
4
|
import {getArtifactPath, isAssetMode} from '../../depot/generators/meta.js'
|
|
5
5
|
import {prepareMeta} from '../../depot/generators/meta.js'
|
|
6
|
+
import {hasHigherVersion} from '../seniority.js'
|
|
6
7
|
|
|
7
8
|
const pushMetaBranch = queuefy(async (manifest, dir) => {
|
|
8
9
|
const {name, version, tag, type, data: meta, repoAuthedUrl, gitCommitterEmail, gitCommitterName, ghBasicAuth} = manifest
|
|
9
|
-
if (type === null || isAssetMode(type)) return
|
|
10
|
+
if (type === null || isAssetMode(type)) return 'ok'
|
|
11
|
+
|
|
12
|
+
if (await hasHigherVersion(dir, name, version)) {
|
|
13
|
+
log.warn(`skipping meta for ${name}@${version}: higher version already released`)
|
|
14
|
+
return 'ok'
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
log.info('push artifact to branch \'meta\'')
|
|
12
18
|
|
|
@@ -23,6 +29,7 @@ const pushMetaBranch = queuefy(async (manifest, dir) => {
|
|
|
23
29
|
gitCommitterName,
|
|
24
30
|
basicAuth: ghBasicAuth,
|
|
25
31
|
})
|
|
32
|
+
return 'ok'
|
|
26
33
|
})
|
|
27
34
|
|
|
28
35
|
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
|
|
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
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {fs, path, glob} from 'zx-extra'
|
|
2
|
+
import {packTar, unpackTar} from '../tar.js'
|
|
3
|
+
import {log} from '../log.js'
|
|
4
|
+
|
|
5
|
+
const GIT_CHANNELS = new Set(['git-tag', 'gh-release', 'changelog', 'gh-pages', 'meta'])
|
|
6
|
+
|
|
7
|
+
// parcel.{sha7}.{channel}.{...}.tar — channel is the 3rd dot-segment
|
|
8
|
+
export const parcelChannel = (name) => name.replace(/\.tar$/, '').split('.')[2]
|
|
9
|
+
|
|
10
|
+
const splitSteps = (channelNames) => {
|
|
11
|
+
const git = channelNames.filter(n => GIT_CHANNELS.has(n))
|
|
12
|
+
const ext = channelNames.filter(n => !GIT_CHANNELS.has(n))
|
|
13
|
+
return [git, ext].filter(s => s.length)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const buildDirective = async (ctx, packedPkgs, outputDir) => {
|
|
17
|
+
const {sha, timestamp} = packedPkgs[0].ctx.git
|
|
18
|
+
const packages = {}
|
|
19
|
+
const parcels = []
|
|
20
|
+
|
|
21
|
+
for (const pkg of packedPkgs) {
|
|
22
|
+
const pkgParcels = (pkg.tars || []).map(t => path.basename(t))
|
|
23
|
+
packages[pkg.name] = {
|
|
24
|
+
version: pkg.version,
|
|
25
|
+
tag: pkg.tag,
|
|
26
|
+
deliver: splitSteps(pkg.activeTransport || []),
|
|
27
|
+
parcels: pkgParcels,
|
|
28
|
+
}
|
|
29
|
+
parcels.push(...pkgParcels)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sha7 = sha.slice(0, 7)
|
|
33
|
+
const manifest = {
|
|
34
|
+
channel: 'directive',
|
|
35
|
+
sha, timestamp: Number(timestamp),
|
|
36
|
+
queue: packedPkgs.map(p => p.name),
|
|
37
|
+
packages, parcels,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tarPath = path.join(outputDir, `parcel.${sha7}.directive.${timestamp}.tar`)
|
|
41
|
+
await packTar(tarPath, manifest, [])
|
|
42
|
+
return tarPath
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const parseDirective = async (tarPath) => {
|
|
46
|
+
const content = await fs.readFile(tarPath, 'utf8').catch(() => null)
|
|
47
|
+
if (content === 'released' || content === 'conflict') return null
|
|
48
|
+
|
|
49
|
+
const {manifest} = await unpackTar(tarPath, tarPath + '.d')
|
|
50
|
+
if (manifest.channel !== 'directive') return null
|
|
51
|
+
return manifest
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const scanDirectives = async (dir) => {
|
|
55
|
+
const tars = await glob(path.join(dir, 'parcel.*.directive.*.tar'))
|
|
56
|
+
const result = []
|
|
57
|
+
|
|
58
|
+
for (const tarPath of tars) {
|
|
59
|
+
const d = await parseDirective(tarPath)
|
|
60
|
+
if (d) result.push({...d, tarPath})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result.sort((a, b) => a.timestamp - b.timestamp)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const invalidateOrphans = async (dir, directive) => {
|
|
67
|
+
const sha7 = directive.sha.slice(0, 7)
|
|
68
|
+
const owned = new Set(directive.parcels || [])
|
|
69
|
+
const all = await glob(path.join(dir, `parcel.${sha7}.*.tar`))
|
|
70
|
+
let count = 0
|
|
71
|
+
|
|
72
|
+
for (const tarPath of all) {
|
|
73
|
+
const name = path.basename(tarPath)
|
|
74
|
+
if (parcelChannel(name) === 'directive') continue
|
|
75
|
+
if (owned.has(name)) continue
|
|
76
|
+
await fs.writeFile(tarPath, 'orphan')
|
|
77
|
+
count++
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (count) log.info(`invalidated ${count} orphan parcel(s) for ${sha7}`)
|
|
81
|
+
}
|