@dbx-tools/projen 0.6.177 → 0.6.180

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/src/release.ts CHANGED
@@ -1,674 +1,362 @@
1
- /**
2
- * Release wiring: registers the `bump` task on a project (compute next version +
3
- * commit + tag + push), parameterized by the project's git tag prefix, and - when
4
- * the project has a GitHub component - authors the tag-driven npm publish
5
- * workflow that the pushed tag triggers.
6
- */
7
- import { existsSync, rmSync } from "node:fs";
8
- import { resolve } from "node:path";
9
- import { object } from "@dbx-tools/shared-core";
10
- import { Component, YamlFile } from "projen";
1
+ /** Unified tag-driven release workflow generation. */
2
+ import { Component } from "projen";
11
3
  import { GithubWorkflow } from "projen/lib/github";
12
- import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
4
+ import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
13
5
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
14
- import {
15
- orderRustBindings,
16
- type RustBindingMapping,
17
- type RustWorkspaceMapping,
18
- } from "./project-rs.ts";
19
- import { applyTasks, taskScript, type DBXToolsNodeProject } from "./project.ts";
20
- import {
21
- DOWNSTREAM_RELEASE_EVENT,
22
- RELEASE_SHA,
23
- RELEASE_TAG,
24
- RUST_RELEASE_EVENT,
25
- releaseSourceSteps,
26
- } from "./release-dispatch.ts";
6
+ import type { DBXToolsJavaScriptProject } from "./project-js.ts";
7
+ import { applyTasks, taskScript } from "./project.ts";
8
+ import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
27
9
 
28
10
  const NODE_VERSION = "lts/*";
29
11
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
12
+ const nodeReleaseProjects = new WeakSet<DBXToolsJavaScriptProject>();
13
+ const releaseTagPrefixes = new WeakMap<DBXToolsJavaScriptProject, string>();
14
+ const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>();
30
15
 
31
- /**
32
- * The `release` workflow's version-stamp + publish step, as a shell script.
33
- *
34
- * Bun has no `pnpm -r publish` equivalent, so this drives the engine's
35
- * `tasks/publish.ts` (shipped in the engine tarball, run via bun): it reads the
36
- * workspace members from the root `package.json`, ensures manifests and the Bun
37
- * lock carry the tag version so `workspace:*` siblings resolve to it, then
38
- * `bun publish`es each non-`private` package. The driver compiles publishable members once from the
39
- * root, then runs a bounded pool of `bun publish --ignore-scripts` calls so the
40
- * already-built packages upload concurrently. It also folds `publishConfig`'s
41
- * compiled `lib/` entry points into each packed manifest and honors
42
- * `NPM_CONFIG_PROVENANCE`.
43
- *
44
- * Two ways in: a pushed `<prefix>*` tag (the real release - `GITHUB_REF_NAME` is
45
- * the version) and a manual `workflow_dispatch` (no tag, so a throwaway
46
- * `0.0.0-dry.<run>` version is used and `--dry-run` is FORCED regardless of the
47
- * input, since a dispatch never has a tag to publish as). The `dry_run` input
48
- * (default true) is what lets a maintainer exercise the whole workflow - setup,
49
- * install, stamp, compile, pack, validate - with nothing reaching npm.
50
- */
51
- function BUN_PUBLISH_SCRIPT(tagPrefix: string, excludeDirs: readonly string[]): string {
52
- const script = "node_modules/@dbx-tools/projen/tasks/publish.ts";
53
- const excludes = excludeDirs.map((dir) => ` --exclude ${dir}`).join("");
54
- return [
55
- 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
56
- // A manual run has no tag: use a throwaway version and never really publish.
57
- ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
58
- " DRY_RUN=--dry-run",
59
- 'elif [ -n "${RELEASE_VERSION:-}" ]; then',
60
- ' VERSION="$RELEASE_VERSION"',
61
- " DRY_RUN=",
62
- "else",
63
- ` VERSION="\${GITHUB_REF_NAME#${tagPrefix}}"`,
64
- // A tag push honors the input too, so a dry-run tag can be tested if wanted.
65
- ' DRY_RUN="${DRY_RUN_INPUT}"',
66
- "fi",
67
- "chmod -R u+w . || true",
68
- `bun ${script} "$VERSION"${excludes} $DRY_RUN`,
69
- ].join("\n");
16
+ /** Independently recoverable portions of the release workflow. */
17
+ export type ReleaseStage = "all" | "node" | "python" | "docs";
18
+
19
+ /** GitHub Pages configuration included in the unified release workflow. */
20
+ export interface ReleaseDocsOptions {
21
+ readonly siteUrl: string;
22
+ readonly base?: string;
23
+ }
24
+
25
+ /** Options for {@link DBXToolsRelease}. */
26
+ export interface DBXToolsReleaseOptions {
27
+ /** Git tag prefix. Defaults to `v`. */
28
+ readonly tagPrefix?: string;
29
+ /** Omit normal npm workspace publication while retaining other release jobs. */
30
+ readonly nodeRelease?: boolean;
31
+ /** Build and deploy generated documentation through GitHub Pages. */
32
+ readonly docs?: ReleaseDocsOptions;
33
+ }
34
+
35
+ /** Locate the unified workflow so attached language workspaces can add jobs. */
36
+ export function releaseWorkflow(project: DBXToolsJavaScriptProject): GithubWorkflow {
37
+ const workflow = releaseWorkflows.get(project);
38
+ if (!workflow) throw new Error("Release workflow is not configured");
39
+ return workflow;
40
+ }
41
+
42
+ /** Whether the unified workflow publishes the normal npm workspace. */
43
+ export function hasNodeRelease(project: DBXToolsJavaScriptProject): boolean {
44
+ return nodeReleaseProjects.has(project);
45
+ }
46
+
47
+ /** Tag pattern accepted by release jobs and GitHub environments. */
48
+ export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
49
+ const prefix = releaseTagPrefixes.get(project);
50
+ if (!prefix) throw new Error("Release workflow is not configured");
51
+ return `${prefix}*`;
52
+ }
53
+
54
+ /** Run a release stage on tag pushes or when selected for manual recovery. */
55
+ export function releaseStageCondition(stage: Exclude<ReleaseStage, "all">): string {
56
+ return `\${{ github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == '${stage}' }}`;
70
57
  }
71
58
 
72
- interface PublishWorkflow {
73
- readonly name: string;
74
- readonly tagPrefix: string;
75
- readonly steps: readonly JobStep[];
76
- readonly workingDirectory?: string;
77
- readonly upstreamWorkflow?: string;
78
- readonly rustArtifacts?: boolean;
59
+ /** Publish a selected stage unless a manual run remains in dry-run mode. */
60
+ export function releasePublishCondition(stage: Exclude<ReleaseStage, "all">): string {
61
+ return `\${{ github.event_name == 'push' || (inputs.dry_run == false && (inputs.stage == 'all' || inputs.stage == '${stage}')) }}`;
79
62
  }
80
63
 
81
- /** Shared checkout and toolchain setup for every npm publish workflow. */
82
- function publishSetupSteps(project: DBXToolsNodeProject): JobStep[] {
64
+ /** Download release artifacts from this run or a verified earlier run. */
65
+ export function releaseArtifactSteps(options: {
66
+ readonly currentName: string;
67
+ readonly recoveredName: string;
68
+ readonly pattern: string;
69
+ readonly path: string;
70
+ }): readonly JobStep[] {
71
+ const shared = {
72
+ pattern: options.pattern,
73
+ path: options.path,
74
+ "merge-multiple": true,
75
+ };
83
76
  return [
84
- { name: "Checkout", uses: "actions/checkout@v6", with: { "fetch-depth": 0 } },
77
+ {
78
+ name: options.currentName,
79
+ if: "${{ inputs.source_run_id == '' }}",
80
+ uses: "actions/download-artifact@v8",
81
+ with: shared,
82
+ },
83
+ {
84
+ name: options.recoveredName,
85
+ if: "${{ inputs.source_run_id != '' }}",
86
+ uses: "actions/download-artifact@v8",
87
+ with: {
88
+ ...shared,
89
+ "run-id": "${{ inputs.source_run_id }}",
90
+ "github-token": "${{ github.token }}",
91
+ repository: "${{ github.repository }}",
92
+ },
93
+ },
94
+ ];
95
+ }
96
+
97
+ /** Shared Bun, Node, cache, and install setup for Node release jobs. */
98
+ export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): readonly JobStep[] {
99
+ return [
100
+ ...releaseSourceSteps(),
85
101
  ...bunCacheRestoreSteps(project),
86
102
  {
87
103
  name: "Setup Node.js",
88
104
  uses: "actions/setup-node@v6",
89
- // setup-node writes the temporary npmrc that maps NODE_AUTH_TOKEN onto
90
- // npmjs. Omitting this leaves the secret in the environment but gives npm
91
- // no registry-scoped auth entry, and every publish fails with ENEEDAUTH.
92
- // (Bun installs deps; publishing still goes through `npm publish`.)
93
105
  with: { "node-version": NODE_VERSION, "registry-url": NPM_REGISTRY_URL },
94
106
  },
95
- // Bun's install; the lockfile may be absent or stale in CI so it is not frozen.
96
107
  { name: "Install", run: "bun install" },
97
108
  bunCacheSaveStep(),
98
109
  ];
99
110
  }
100
111
 
101
- /**
102
- * A standalone project that lives in a repo SUBDIRECTORY but is NOT a member of
103
- * this pnpm workspace (e.g. the `@dbx-tools/projen` engine in `projen/`), yet
104
- * still needs a release workflow. GitHub Actions only runs workflows from the
105
- * REPO-ROOT `.github/`, so the workflow for such a project is authored here,
106
- * alongside the root's own `release` workflow, under a distinct name + tag
107
- * prefix so the two never collide. Tag-driven and pack-based: push
108
- * `<tagPrefix>1.2.3` and the single package in `directory` is published at 1.2.3
109
- * via `npm pack` + `npm publish`.
110
- *
111
- * Declaring one also enlists it in the root's `bump`, which cuts BOTH tags at one
112
- * shared version. The separate tag namespace still lets it be released alone
113
- * (`cd <directory> && bun run bump`) for a consumer who wants only this package.
114
- * Enlisting it is what keeps a routine root bump from leaving it behind and letting
115
- * its version drift away from the packages'.
116
- */
117
- export interface StandaloneRelease {
118
- /** Workflow name (and `.github/workflows/<name>.yml` file). E.g. `projen-release`. */
119
- readonly name: string;
120
- /** Repo-relative directory of the standalone project. E.g. `projen`. */
121
- readonly directory: string;
122
- /**
123
- * Git tag prefix that triggers this release, disjoint from the root's `v*`
124
- * (e.g. `projen-v`). The pushed tag IS the published version.
125
- */
126
- readonly tagPrefix: string;
112
+ /** Authentication, provenance, and dry-run values shared by npm publishers. */
113
+ export function npmPublishEnvironment(): Record<string, string> {
114
+ return {
115
+ NPM_CONFIG_PROVENANCE:
116
+ "${{ (github.event_name == 'push' || inputs.dry_run == false) && 'true' || 'false' }}",
117
+ NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
118
+ NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
119
+ DRY_RUN:
120
+ "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '--dry-run' || '' }}",
121
+ };
127
122
  }
128
123
 
129
- /** Options for {@link DBXToolsRelease}. */
130
- export interface DBXToolsReleaseOptions {
131
- /**
132
- * Git tag prefix for this project's releases (e.g. `v` or `projen-v`). The
133
- * `bump` task reads/writes `<prefix><version>` tags, keeping sibling projects
134
- * in the same repo on disjoint tag namespaces. Defaults to `v`.
135
- */
136
- readonly tagPrefix?: string;
137
- /**
138
- * Standalone in-repo projects (NOT workspace members) that each get their own
139
- * tag-driven release workflow authored alongside the root's - see
140
- * {@link StandaloneRelease}. Authored only when the project has a GitHub
141
- * component. Defaults to none.
142
- */
143
- readonly standaloneReleases?: readonly StandaloneRelease[];
144
- /** Run the main Node release after this workflow completes successfully. */
145
- readonly upstreamWorkflow?: string;
146
- /** Main Node release workflow name. Defaults to `node-release`. */
147
- readonly workflowName?: string | false;
148
- }
149
-
150
- /**
151
- * Adds a `bump` task: compute the next release version (from the higher of the
152
- * latest `<prefix>*` git tag and the local `package.json`), then commit, tag,
153
- * and push it - pushing the tag is what triggers the release workflow. Each step
154
- * is toggleable (`--no-version` / `--no-commit` / `--no-tag` / `--no-push` /
155
- * `--no-publish`); see `tasks/bump.ts`.
156
- *
157
- * When the project has a GitHub component (`github: true`), also authors the
158
- * `release` workflow: on a pushed `<prefix>*` tag it sets that version on every
159
- * publishable package and runs `pnpm -r publish` (which skips
160
- * `private` packages and honors each package's `publishConfig`).
161
- *
162
- * Provenance is opt-in. Each package's generated `publishConfig` omits
163
- * `provenance`, so LOCAL publishes (e.g. to a verdaccio) never try to attest -
164
- * npm has no CI OIDC provider off-CI and would fail with `provider: null`. This
165
- * CI workflow turns it on with `npm_config_provenance=true`, backed by the
166
- * `id-token: write` permission that lets npm mint the OIDC token.
167
- *
168
- * Registry AUTH is still `NPM_TOKEN`, deliberately. OIDC here mints the
169
- * provenance attestation only; full npm trusted publishing would additionally
170
- * replace the token, but it has to be registered per package on npmjs.com
171
- * against an exact repo + workflow filename, and this repo publishes 26 of
172
- * them. That is a considered deferral, not an oversight - do not "fix" it by
173
- * dropping the token without doing the registrations first, or every publish
174
- * fails.
175
- */
176
- export class DBXToolsRelease extends Component {
177
- private readonly tagPrefix: string;
178
- private readonly standaloneReleases: readonly StandaloneRelease[];
179
- private readonly upstreamWorkflow?: string;
180
- private readonly workflowName: string | false;
181
-
182
- constructor(project: DBXToolsNodeProject, options: DBXToolsReleaseOptions = {}) {
183
- super(project);
184
- this.tagPrefix = options.tagPrefix ?? "v";
185
- this.standaloneReleases = options.standaloneReleases ?? [];
186
- this.upstreamWorkflow = options.upstreamWorkflow;
187
- this.workflowName = options.workflowName ?? "node-release";
188
- if (project.github) this.authorReleaseDispatcher(project);
189
- }
190
-
191
- public override preSynthesize(): void {
192
- const project = this.project as DBXToolsNodeProject;
193
- const rust = project.dbxToolsConfig.rust;
194
- if (
195
- !this.workflowName &&
196
- this.standaloneReleases.length === 0 &&
197
- typeof project.dbxToolsConfig.pythonReleaseWorkflow !== "string" &&
198
- !(object.isRecord(rust) && typeof rust.releaseWorkflow === "string")
199
- ) {
200
- project.tryRemoveFile(".github/workflows/release-dispatch.yml");
201
- }
202
- const releaseEvent =
203
- object.isRecord(rust) && typeof rust.releaseWorkflow === "string"
204
- ? RUST_RELEASE_EVENT
205
- : DOWNSTREAM_RELEASE_EVENT;
206
- const dispatcher = project.tryFindObjectFile(".github/workflows/release-dispatch.yml");
207
- dispatcher?.addOverride("jobs.dispatch.steps.1.env.RELEASE_EVENT", releaseEvent);
208
- const releaseWorkflows = [
209
- ...(object.isRecord(rust) && typeof rust.releaseWorkflow === "string"
210
- ? [rust.releaseWorkflow]
211
- : []),
212
- ...(typeof project.dbxToolsConfig.pythonReleaseWorkflow === "string"
213
- ? [project.dbxToolsConfig.pythonReleaseWorkflow]
214
- : []),
215
- ...(this.workflowName ? [this.workflowName] : []),
216
- ...this.standaloneReleases.map(({ name }) => name),
217
- ...(existsSync(resolve(project.outdir, ".github/workflows/docs.yml")) ? ["docs"] : []),
218
- ];
219
- dispatcher?.addOverride(
220
- "jobs.dispatch.steps.1.env.RELEASE_WORKFLOWS",
221
- [...new Set(releaseWorkflows)].join(","),
222
- );
223
- // Release the standalone projects in the SAME run, at the same version. They
224
- // are not workspace members, so nothing else would ever bring them along.
225
- const siblingArgs = this.standaloneReleases
226
- .map(({ directory, tagPrefix }) => ` --sibling ${directory}:${tagPrefix}`)
227
- .join("");
228
- applyTasks(project, {
229
- bump: {
230
- exec: taskScript(project, "bump.ts", `--prefix ${this.tagPrefix}${siblingArgs}`),
231
- receiveArgs: true,
232
- description: "Bump the release version (default patch), then commit, tag, and push it",
233
- },
234
- });
235
-
236
- // Author the tag-driven publish workflows only when GitHub is enabled - they
237
- // live in `.github/`, which requires projen's GitHub component.
238
- if (project.github) {
239
- if (this.workflowName) this.authorReleaseWorkflow(project);
240
- for (const standalone of this.standaloneReleases) {
241
- this.authorStandaloneReleaseWorkflow(project, standalone);
242
- }
243
- }
244
- }
245
-
246
- public override postSynthesize(): void {
247
- rmSync(resolve(this.project.outdir, ".github/workflows/rust-release-dispatch.yml"), {
248
- force: true,
249
- });
250
- }
251
-
252
- private authorReleaseDispatcher(project: DBXToolsNodeProject): void {
253
- new YamlFile(project, ".github/workflows/release-dispatch.yml", {
254
- obj: {
255
- name: "release-dispatch",
256
- on: { push: { tags: [`${this.tagPrefix}*`] } },
257
- concurrency: {
258
- group: "release-dispatch",
259
- "cancel-in-progress": true,
260
- },
261
- permissions: { actions: "write", contents: "write" },
262
- jobs: {
263
- dispatch: {
264
- "runs-on": "ubuntu-latest",
265
- steps: [
266
- {
267
- name: "Checkout release tag",
268
- uses: "actions/checkout@v6",
269
- with: { "fetch-depth": 1 },
270
- },
271
- {
272
- name: "Dispatch release",
273
- shell: "bash",
274
- env: {
275
- GH_TOKEN: "${{ github.token }}",
276
- RELEASE_TAG: "${{ github.ref_name }}",
277
- RELEASE_EVENT: DOWNSTREAM_RELEASE_EVENT,
278
- RELEASE_WORKFLOWS: "",
279
- },
280
- run: [
281
- `case "$RELEASE_TAG" in ${this.tagPrefix}*) ;; *) exit 1 ;; esac`,
282
- 'EXPECTED_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
283
- 'IFS="," read -r -a workflows <<< "$RELEASE_WORKFLOWS"',
284
- 'for workflow in "${workflows[@]}"; do',
285
- " for status in in_progress queued requested waiting pending action_required; do",
286
- " while IFS= read -r run_id; do",
287
- ' if [ -n "$run_id" ]; then gh run cancel "$run_id"; fi',
288
- ' done < <(gh run list --workflow "$workflow.yml" --status "$status" --limit 100 --json databaseId --jq \'.[].databaseId\')',
289
- " done",
290
- "done",
291
- [
292
- 'gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches"',
293
- '--raw-field event_type="$RELEASE_EVENT"',
294
- '--raw-field "client_payload[release_tag]=$RELEASE_TAG"',
295
- '--raw-field "client_payload[expected_sha]=$EXPECTED_SHA"',
296
- ].join(" \\\n "),
297
- ].join("\n"),
298
- },
299
- ],
300
- },
301
- },
302
- },
303
- });
304
- }
305
-
306
- /** Author the common tag trigger, concurrency policy, permissions, and publish job. */
307
- private authorPublishWorkflow(
308
- project: DBXToolsNodeProject,
309
- { name, tagPrefix, steps, workingDirectory, upstreamWorkflow, rustArtifacts }: PublishWorkflow,
310
- ): void {
311
- const setupSteps = publishSetupSteps(project);
312
- const branchDispatch = upstreamWorkflow === undefined;
313
- const workflow = new GithubWorkflow(project.github!, name, {
314
- // A newer release supersedes an older run even when publication has
315
- // started; the dispatcher also cancels active ecosystem runs immediately.
316
- limitConcurrency: true,
317
- concurrencyOptions: { group: name, cancelInProgress: true },
318
- });
319
- // Read-only floor for any job that does not declare its own permissions;
320
- // the publish job below overrides it with the `id-token` it needs.
321
- workflow.file?.addOverride("permissions", {
322
- contents: "read",
323
- ...(upstreamWorkflow || rustArtifacts ? { actions: "read" } : {}),
324
- });
325
- if (upstreamWorkflow) {
326
- workflow.file?.addOverride("on.workflow_run", {
327
- workflows: [upstreamWorkflow],
328
- types: ["completed"],
329
- });
330
- } else {
331
- workflow.file?.addOverride("on.repository_dispatch", {
332
- types: [DOWNSTREAM_RELEASE_EVENT],
333
- });
334
- }
335
- // Manual trigger for testing the workflow WITHOUT reaching npm: a
336
- // `workflow_dispatch` run has no tag, so the publish script forces
337
- // `--dry-run` (pack + validate only). The `dry_run` input (default true)
338
- // additionally lets a tag push be dry-run on demand.
339
- workflow.file?.addOverride("on.workflow_dispatch", {
340
- inputs: {
341
- ...(branchDispatch
342
- ? {
343
- release_tag: {
344
- description: "Release tag to package during a dry run",
345
- type: "string",
346
- required: true,
347
- },
348
- expected_sha: {
349
- description: "Commit the release tag must reference",
350
- type: "string",
351
- required: true,
352
- },
353
- }
354
- : {}),
355
- dry_run: {
356
- description: "Pack and validate but do not upload to npm",
357
- type: "boolean",
358
- default: true,
124
+ function verifyContextJob(tagPrefix: string): Job {
125
+ return {
126
+ runsOn: ["ubuntu-latest"],
127
+ permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
128
+ outputs: {
129
+ release_tag: { stepId: "release", outputName: "release_tag" },
130
+ expected_sha: { stepId: "release", outputName: "expected_sha" },
131
+ release_version: { stepId: "release", outputName: "release_version" },
132
+ },
133
+ steps: [
134
+ {
135
+ name: "Checkout release tag",
136
+ uses: "actions/checkout@v6",
137
+ with: {
138
+ ref: "${{ github.event_name == 'push' && github.ref || inputs.expected_sha }}",
139
+ "fetch-depth": 1,
359
140
  },
360
141
  },
361
- });
362
- workflow.addJob("publish", {
363
- ...(upstreamWorkflow
364
- ? {
365
- if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.event == 'workflow_run' || github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'repository_dispatch')) }}",
366
- }
367
- : {}),
368
- runsOn: ["ubuntu-latest"],
369
- // `id-token: write` lets npm mint the OIDC token for provenance attestation.
370
- permissions: {
371
- ...(upstreamWorkflow || rustArtifacts ? { actions: JobPermission.READ } : {}),
372
- contents: JobPermission.READ,
373
- idToken: JobPermission.WRITE,
374
- },
375
- timeoutMinutes: 30,
376
- // `DRY_RUN_INPUT` is `--dry-run` when the dispatch input is true, else empty;
377
- // the publish script also FORCES it on any `workflow_dispatch` run.
378
- env: {
379
- BUN_VERSION,
380
- CI: "true",
381
- DRY_RUN_INPUT: "${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}",
382
- },
383
- steps: [
384
- ...(upstreamWorkflow
385
- ? [
386
- {
387
- name: "Download release metadata",
388
- uses: "actions/download-artifact@v8",
389
- with: {
390
- name: "release-metadata",
391
- path: ".release",
392
- "run-id": "${{ github.event.workflow_run.id }}",
393
- "github-token": "${{ github.token }}",
394
- },
395
- },
396
- {
397
- name: "Read release metadata",
398
- id: "release_metadata",
399
- shell: "bash",
400
- run: [
401
- 'RELEASE_TAG="$(cat .release/tag)"',
402
- 'EXPECTED_SHA="$(cat .release/sha)"',
403
- 'test -n "$RELEASE_TAG"',
404
- 'test -n "$EXPECTED_SHA"',
405
- 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
406
- 'echo "expected_sha=$EXPECTED_SHA" >> "$GITHUB_OUTPUT"',
407
- ].join("\n"),
408
- },
409
- ]
410
- : []),
411
- {
412
- ...setupSteps[0]!,
413
- ...(upstreamWorkflow
414
- ? {
415
- with: {
416
- ...setupSteps[0]!.with,
417
- ref: "${{ steps.release_metadata.outputs.expected_sha }}",
418
- "fetch-depth": 0,
419
- },
420
- }
421
- : {
422
- with: {
423
- ...setupSteps[0]!.with,
424
- ref: RELEASE_SHA,
425
- "fetch-depth": 1,
426
- },
427
- }),
428
- },
429
- ...(upstreamWorkflow
430
- ? [
431
- {
432
- name: "Verify release source",
433
- shell: "bash",
434
- env: {
435
- SOURCE_RELEASE_TAG: "${{ steps.release_metadata.outputs.release_tag }}",
436
- EXPECTED_SHA: "${{ steps.release_metadata.outputs.expected_sha }}",
437
- },
438
- run: [
439
- 'git fetch --force origin "+refs/tags/$SOURCE_RELEASE_TAG:refs/tags/$SOURCE_RELEASE_TAG"',
440
- 'test "$(git rev-parse "$SOURCE_RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
441
- 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"',
442
- `RELEASE_TAG="$(git tag --points-at HEAD --list "${tagPrefix}*" | sort -V | tail -1)"`,
443
- 'test -n "$RELEASE_TAG"',
444
- 'echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"',
445
- `echo "RELEASE_VERSION=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_ENV"`,
446
- ].join("\n"),
447
- } satisfies JobStep,
448
- ]
449
- : []),
450
- ...(branchDispatch ? releaseSourceSteps().slice(1) : []),
451
- ...(branchDispatch
452
- ? [
453
- {
454
- name: "Resolve package release tag",
455
- shell: "bash",
456
- env: {
457
- SOURCE_RELEASE_TAG: RELEASE_TAG,
458
- EXPECTED_SHA: RELEASE_SHA,
459
- },
460
- run: [
461
- `SOURCE_VERSION="\${SOURCE_RELEASE_TAG#${this.tagPrefix}}"`,
462
- `RELEASE_TAG="${tagPrefix}\${SOURCE_VERSION}"`,
463
- 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
464
- 'test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
465
- 'echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"',
466
- `echo "RELEASE_VERSION=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_ENV"`,
467
- "mkdir -p .release",
468
- 'printf "%s\\n" "$SOURCE_RELEASE_TAG" > .release/tag',
469
- 'printf "%s\\n" "$EXPECTED_SHA" > .release/sha',
470
- ].join("\n"),
471
- } satisfies JobStep,
472
- ]
473
- : []),
474
- ...setupSteps.slice(1),
475
- {
476
- name: "Upload release metadata",
477
- uses: "actions/upload-artifact@v7",
478
- with: { name: "release-metadata", path: ".release" },
479
- },
480
- ...steps,
481
- ],
482
- ...(workingDirectory ? { defaults: { run: { workingDirectory } } } : {}),
483
- });
484
- }
485
-
486
- /**
487
- * Emit the `release` GitHub workflow: push `<prefix>1.2.3` and every
488
- * publishable package is published to npm at 1.2.3. Setting the
489
- * version on every package first makes the pushed tag the published version
490
- * (no bump math).
491
- */
492
- private nodeBindingReleaseSteps(project: DBXToolsNodeProject): {
493
- before: JobStep[];
494
- after: JobStep[];
495
- } {
496
- const config = project.dbxToolsConfig.rust;
497
- if (!object.isRecord(config) || !Array.isArray(config.bindings)) {
498
- return { before: [], after: [] };
499
- }
500
- const bindings = orderRustBindings(config.bindings as RustWorkspaceMapping["bindings"]).filter(
501
- (binding): binding is RustBindingMapping & { node: string; nodePackage: string } =>
502
- Boolean(binding.node && binding.nodePackage),
503
- );
504
- if (bindings.length === 0) return { before: [], after: [] };
505
-
506
- const before: JobStep[] = [
507
142
  {
508
- name: "Require Rust artifact handoff",
509
- if: "${{ github.event_name == 'repository_dispatch' }}",
143
+ name: "Verify release context",
144
+ id: "release",
510
145
  shell: "bash",
511
146
  env: {
512
- RUST_RUN_ID: "${{ github.event.client_payload.rust_run_id }}",
513
- RUST_RUN_ATTEMPT: "${{ github.event.client_payload.rust_run_attempt }}",
147
+ RELEASE_TAG:
148
+ "${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}",
149
+ EXPECTED_SHA:
150
+ "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
151
+ DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
514
152
  },
515
- run: ['test -n "$RUST_RUN_ID"', 'test -n "$RUST_RUN_ATTEMPT"'].join("\n"),
153
+ run: [
154
+ `case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
155
+ 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
156
+ 'test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
157
+ 'RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
158
+ 'test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
+ 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
160
+ ' test "$GITHUB_REF_TYPE" = "tag"',
161
+ ' test "$GITHUB_REF_NAME" = "$RELEASE_TAG"',
162
+ ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
163
+ ' if [ -n "${{ inputs.source_run_id }}" ]; then',
164
+ ' case "${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac',
165
+ ' case "${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac',
166
+ " fi",
167
+ "fi",
168
+ 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
169
+ 'echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"',
170
+ `echo "release_version=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_OUTPUT"`,
171
+ ].join("\n"),
516
172
  },
517
173
  {
518
- name: "Download native npm packages",
519
- if: "${{ github.event_name == 'repository_dispatch' }}",
520
- uses: "actions/download-artifact@v8",
174
+ name: "Verify source artifact run",
175
+ if: "${{ inputs.source_run_id != '' }}",
176
+ uses: "actions/github-script@v8",
177
+ env: {
178
+ EXPECTED_SHA: "${{ steps.release.outputs.expected_sha }}",
179
+ SOURCE_RUN_ID: "${{ inputs.source_run_id }}",
180
+ },
521
181
  with: {
522
- pattern: "*-npm",
523
- path: "dist/uniffi/native",
524
- "merge-multiple": true,
525
- "run-id": "${{ github.event.client_payload.rust_run_id }}",
526
- "github-token": "${{ github.token }}",
182
+ script: [
183
+ "const run = await github.rest.actions.getWorkflowRun({",
184
+ " owner: context.repo.owner,",
185
+ " repo: context.repo.repo,",
186
+ " run_id: Number(process.env.SOURCE_RUN_ID),",
187
+ "});",
188
+ 'if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");',
189
+ 'if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");',
190
+ ].join("\n"),
527
191
  },
528
192
  },
193
+ ],
194
+ };
195
+ }
196
+
197
+ function nodePublishJob(project: DBXToolsJavaScriptProject): Job {
198
+ return {
199
+ if: releaseStageCondition("node"),
200
+ needs: ["verify-context"],
201
+ runsOn: ["ubuntu-latest"],
202
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
203
+ timeoutMinutes: 30,
204
+ env: { BUN_VERSION, CI: "true" },
205
+ steps: [
206
+ ...nodeReleaseSetupSteps(project),
529
207
  {
530
- name: "Publish native npm packages",
531
- if: "${{ github.event_name == 'repository_dispatch' }}",
532
- env: {
533
- NPM_CONFIG_PROVENANCE: "true",
534
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
535
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
536
- },
208
+ name: "Compile, package, and publish npm workspace",
209
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
537
210
  run: [
538
- "test \"$(find dist/uniffi/native -name '*.tgz' | wc -l | tr -d ' ')\" -gt 0",
539
- 'for package in dist/uniffi/native/*.tgz; do npm publish "$package" --access public; done',
211
+ "chmod -R u+w . || true",
212
+ 'bun node_modules/@dbx-tools/projen/tasks/publish.ts "$RELEASE_VERSION" $DRY_RUN',
540
213
  ].join("\n"),
541
214
  },
215
+ ],
216
+ };
217
+ }
218
+
219
+ function addDocsJobs(
220
+ workflow: GithubWorkflow,
221
+ project: DBXToolsJavaScriptProject,
222
+ options: ReleaseDocsOptions,
223
+ ): void {
224
+ workflow.addJob("build-docs", {
225
+ if: releaseStageCondition("docs"),
226
+ needs: ["verify-context"],
227
+ runsOn: ["ubuntu-latest"],
228
+ permissions: {
229
+ contents: JobPermission.READ,
230
+ pages: JobPermission.WRITE,
231
+ idToken: JobPermission.WRITE,
232
+ },
233
+ timeoutMinutes: 30,
234
+ env: {
235
+ BUN_VERSION,
236
+ DOCS_SITE_URL: options.siteUrl,
237
+ DOCS_BASE: options.base ?? "/",
238
+ },
239
+ steps: [
240
+ ...releaseSourceSteps(),
241
+ ...bunCacheRestoreSteps(project),
542
242
  {
543
- name: "Refresh workspace after native publication",
544
- if: "${{ github.event_name == 'repository_dispatch' }}",
545
- run: "bun install --force",
243
+ name: "Setup Node.js",
244
+ uses: "actions/setup-node@v6",
245
+ with: { "node-version": "22" },
546
246
  },
547
- ];
548
-
549
- const facadeCommands = [
550
- 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
551
- ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
552
- " DRY_RUN=--dry-run",
553
- "else",
554
- ' VERSION="$RELEASE_VERSION"',
555
- " DRY_RUN=",
556
- "fi",
557
- ...bindings.flatMap((binding) => {
558
- const output = `dist/uniffi/facades/${binding.crate}`;
559
- return [
560
- `node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$VERSION" --output "${output}"`,
561
- `for package in ${output}/npm-facade/*.tgz; do npm publish "$package" --access public $DRY_RUN; done`,
562
- ];
563
- }),
564
- ];
565
- return {
566
- before,
567
- after: [
568
- {
569
- name: "Build and publish UniFFI npm facades",
570
- env: {
571
- NPM_CONFIG_PROVENANCE: "true",
572
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
573
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
574
- },
575
- run: facadeCommands.join("\n"),
576
- },
577
- ],
578
- };
579
- }
247
+ { name: "Configure Pages", uses: "actions/configure-pages@v5" },
248
+ { name: "Install dependencies", run: "bun install" },
249
+ { name: "Generate docs from READMEs", run: "bun docs/scripts/sync-readmes.mjs" },
250
+ {
251
+ name: "Check generated titles",
252
+ run: "bun docs/scripts/check-generated-titles.mjs",
253
+ },
254
+ { name: "Install docs dependencies", run: "bun install --cwd .docs-build/site" },
255
+ bunCacheSaveStep(),
256
+ {
257
+ name: "Generate TypeScript API docs",
258
+ run: "bun docs/scripts/generate-api-docs.mjs",
259
+ },
260
+ { name: "Build docs", run: "bun run --cwd .docs-build/site build" },
261
+ {
262
+ name: "Check generated links",
263
+ run: "bun run --cwd .docs-build/site check-links",
264
+ },
265
+ {
266
+ name: "Upload Pages artifact",
267
+ uses: "actions/upload-pages-artifact@v4",
268
+ with: { path: ".docs-build/dist" },
269
+ },
270
+ ],
271
+ });
272
+ workflow.addJob("deploy-docs", {
273
+ if: releasePublishCondition("docs"),
274
+ needs: ["build-docs"],
275
+ environment: {
276
+ name: "github-pages",
277
+ url: "${{ steps.deployment.outputs.page_url }}",
278
+ },
279
+ runsOn: ["ubuntu-latest"],
280
+ permissions: { pages: JobPermission.WRITE, idToken: JobPermission.WRITE },
281
+ timeoutMinutes: 15,
282
+ steps: [
283
+ {
284
+ name: "Deploy to GitHub Pages",
285
+ id: "deployment",
286
+ uses: "actions/deploy-pages@v4",
287
+ },
288
+ ],
289
+ });
290
+ }
580
291
 
581
- private authorReleaseWorkflow(project: DBXToolsNodeProject): void {
582
- if (!this.workflowName) return;
583
- if (this.workflowName !== "release") project.tryRemoveFile(".github/workflows/release.yml");
584
- const bindingSteps = this.nodeBindingReleaseSteps(project);
585
- this.authorPublishWorkflow(project, {
586
- name: this.workflowName,
587
- tagPrefix: this.tagPrefix,
588
- upstreamWorkflow: this.upstreamWorkflow,
589
- rustArtifacts: bindingSteps.before.length > 0,
590
- steps: [
591
- ...bindingSteps.before,
592
- // The pushed tag is the version: `<prefix>1.2.3` -> `1.2.3`. Stamp it on
593
- // every workspace package (manifests are projen-readonly, so unlock
594
- // first), rewriting `workspace:*` sibling deps to `^<version>` so the
595
- // published tarballs resolve each other. `bun publish` honors
596
- // `publishConfig` (compiled `lib/` entry points) and provenance.
597
- {
598
- name: "Set version from tag and publish",
599
- // Exclude every standalone-release dir (e.g. `projen`): it publishes on
600
- // its own `<prefix>-v*` tag via its own workflow, not the main `v*` one.
601
- run: BUN_PUBLISH_SCRIPT(
602
- this.tagPrefix,
603
- this.standaloneReleases.map((s) => s.directory),
604
- ),
605
- env: {
606
- // `bun publish` authenticates via NPM_CONFIG_TOKEN (not NODE_AUTH_TOKEN,
607
- // which is the `npm publish` convention). Set both so either tool works.
608
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
609
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
610
- NPM_CONFIG_PROVENANCE: "true",
611
- },
612
- },
613
- ...bindingSteps.after,
614
- ],
292
+ /** Owns the single release workflow and the local bump task. */
293
+ export class DBXToolsRelease extends Component {
294
+ constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
295
+ super(project);
296
+ const tagPrefix = options.tagPrefix ?? "v";
297
+ releaseTagPrefixes.set(project, tagPrefix);
298
+ if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
299
+ applyTasks(project, {
300
+ bump: {
301
+ exec: taskScript(project, "bump.ts", `--prefix ${tagPrefix}`),
302
+ receiveArgs: true,
303
+ description: "Bump the release version (default patch), then commit, tag, and push it",
304
+ },
615
305
  });
616
- }
306
+ if (!project.github) return;
617
307
 
618
- /**
619
- * Emit a {@link StandaloneRelease}'s workflow: push `<prefix>1.2.3` and the
620
- * single package in `directory` is published at 1.2.3 via `bun publish`.
621
- *
622
- * `directory` (e.g. `projen/`) is a WORKSPACE MEMBER whose `@dbx-tools/*` deps
623
- * are `workspace:*`. `bun publish` resolves those to whatever version its
624
- * SIBLINGS carry (via the lockfile), so before publishing we re-affirm the
625
- * version on the package AND its in-scope siblings, then refresh the lockfile -
626
- * otherwise a stale resolved version could reach the published engine. The
627
- * `Install` step already ran `bun install` from the repo root (the member
628
- * subdir walks up to it), so the workspace is linked. The manifests are
629
- * projen-readonly, hence the `chmod`. A manual `workflow_dispatch` run has no
630
- * tag, so it uses a throwaway version and forces `--dry-run` (nothing to npm).
631
- */
632
- private authorStandaloneReleaseWorkflow(
633
- project: DBXToolsNodeProject,
634
- { name, directory, tagPrefix }: StandaloneRelease,
635
- ): void {
636
- // The engine's release also stamps its in-scope siblings so `bun publish`
637
- // resolves their `workspace:*` to the release version. Stamping the WHOLE
638
- // workspace is simplest and harmless (only `directory` is published here).
639
- const stampScript = "node_modules/@dbx-tools/projen/tasks/publish.ts";
640
- this.authorPublishWorkflow(project, {
641
- name,
642
- tagPrefix,
643
- upstreamWorkflow: this.upstreamWorkflow,
644
- steps: [
645
- {
646
- name: "Set version from tag and publish",
647
- run: [
648
- 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
649
- ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
650
- " DRY_RUN=--dry-run",
651
- "else",
652
- ` VERSION="\${RELEASE_VERSION:-\${GITHUB_REF_NAME#${tagPrefix}}}"`,
653
- ' DRY_RUN="${DRY_RUN_INPUT}"',
654
- "fi",
655
- "chmod -R u+w . || true",
656
- // Set the version across every member + refresh the lockfile (the
657
- // publish task's stamp phase), so bun resolves the engine's
658
- // `workspace:*` sibling deps to the release version at pack time.
659
- `bun ${stampScript} "$VERSION" --stamp-only`,
660
- // Then publish ONLY the standalone directory.
661
- `cd ${directory} && bun publish --access public $DRY_RUN`,
662
- ].join("\n"),
663
- env: {
664
- // `bun publish` authenticates via NPM_CONFIG_TOKEN (not NODE_AUTH_TOKEN,
665
- // which is the `npm publish` convention). Set both so either tool works.
666
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
667
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
668
- NPM_CONFIG_PROVENANCE: "true",
308
+ const workflow = new GithubWorkflow(project.github, "release", {
309
+ fileName: "release.yml",
310
+ limitConcurrency: true,
311
+ concurrencyOptions: { group: "release", cancelInProgress: true },
312
+ });
313
+ workflow.runName =
314
+ "release ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}";
315
+ workflow.on({
316
+ push: { tags: [`${tagPrefix}*`] },
317
+ workflowDispatch: {
318
+ inputs: {
319
+ release_tag: {
320
+ description: "Annotated release tag to validate",
321
+ type: "string",
322
+ required: true,
323
+ },
324
+ expected_sha: {
325
+ description: "Commit the release tag must reference",
326
+ type: "string",
327
+ required: true,
328
+ },
329
+ stage: {
330
+ description: "Release stage to build, validate, or recover",
331
+ type: "choice",
332
+ options: ["all", "node", "python", "docs"],
333
+ default: "all",
334
+ required: true,
335
+ },
336
+ source_run_id: {
337
+ description: "Earlier release workflow run containing Rust artifacts",
338
+ type: "string",
339
+ default: "",
340
+ required: false,
341
+ },
342
+ dry_run: {
343
+ description: "Build and validate without publishing",
344
+ type: "boolean",
345
+ default: "true",
346
+ required: true,
669
347
  },
670
348
  },
671
- ],
349
+ },
672
350
  });
351
+ workflow.file?.addOverride("permissions.contents", "read");
352
+ workflow.file?.addOverride("on.workflow_dispatch.inputs.dry_run.default", true);
353
+ workflow.addJob("verify-context", verifyContextJob(tagPrefix));
354
+ if (options.nodeRelease !== false) {
355
+ workflow.addJob("publish-node", nodePublishJob(project));
356
+ }
357
+ if (options.docs) {
358
+ addDocsJobs(workflow, project, options.docs);
359
+ }
360
+ releaseWorkflows.set(project, workflow);
673
361
  }
674
362
  }