@dbx-tools/projen 0.6.153 → 0.6.160

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.
@@ -0,0 +1,36 @@
1
+ /** Shared release events and immutable source verification. */
2
+ export const DOWNSTREAM_RELEASE_EVENT = "release";
3
+ export const RUST_RELEASE_EVENT = "rust-release";
4
+ export const RELEASE_TAG =
5
+ "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.release_tag || inputs.release_tag }}";
6
+ export const RELEASE_SHA =
7
+ "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.expected_sha || inputs.expected_sha }}";
8
+
9
+ /** Check out and verify the exact commit carried by a release event. */
10
+ export function releaseSourceSteps(): readonly Record<string, unknown>[] {
11
+ return [
12
+ {
13
+ name: "Checkout release commit",
14
+ uses: "actions/checkout@v6",
15
+ with: {
16
+ ref: RELEASE_SHA,
17
+ "fetch-depth": 1,
18
+ },
19
+ },
20
+ {
21
+ name: "Verify release source",
22
+ shell: "bash",
23
+ env: {
24
+ RELEASE_TAG,
25
+ EXPECTED_SHA: RELEASE_SHA,
26
+ },
27
+ run: [
28
+ 'test -n "$RELEASE_TAG"',
29
+ 'test -n "$EXPECTED_SHA"',
30
+ 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
31
+ 'test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
32
+ 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"',
33
+ ].join("\n"),
34
+ },
35
+ ];
36
+ }
package/src/release.ts CHANGED
@@ -4,10 +4,20 @@
4
4
  * the project has a GitHub component - authors the tag-driven npm publish
5
5
  * workflow that the pushed tag triggers.
6
6
  */
7
- import { Component } from "projen";
7
+ import { rmSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { Component, YamlFile } from "projen";
8
10
  import { GithubWorkflow } from "projen/lib/github";
9
11
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
12
+ import { object } from "@dbx-tools/shared-core";
10
13
  import { applyTasks, taskScript, type DBXToolsNodeProject } from "./project.ts";
14
+ import {
15
+ DOWNSTREAM_RELEASE_EVENT,
16
+ RELEASE_SHA,
17
+ RELEASE_TAG,
18
+ RUST_RELEASE_EVENT,
19
+ releaseSourceSteps,
20
+ } from "./release-dispatch.ts";
11
21
 
12
22
  const NODE_VERSION = "lts/*";
13
23
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
@@ -168,10 +178,27 @@ export class DBXToolsRelease extends Component {
168
178
  this.standaloneReleases = options.standaloneReleases ?? [];
169
179
  this.upstreamWorkflow = options.upstreamWorkflow;
170
180
  this.workflowName = options.workflowName ?? "node-release";
181
+ if (project.github) this.authorReleaseDispatcher(project);
171
182
  }
172
183
 
173
184
  public override preSynthesize(): void {
174
185
  const project = this.project as DBXToolsNodeProject;
186
+ const rust = project.dbxToolsConfig.rust;
187
+ if (
188
+ !this.workflowName &&
189
+ this.standaloneReleases.length === 0 &&
190
+ typeof project.dbxToolsConfig.pythonReleaseWorkflow !== "string" &&
191
+ !(object.isRecord(rust) && typeof rust.releaseWorkflow === "string")
192
+ ) {
193
+ project.tryRemoveFile(".github/workflows/release-dispatch.yml");
194
+ }
195
+ const releaseEvent =
196
+ object.isRecord(rust) && typeof rust.releaseWorkflow === "string"
197
+ ? RUST_RELEASE_EVENT
198
+ : DOWNSTREAM_RELEASE_EVENT;
199
+ project
200
+ .tryFindObjectFile(".github/workflows/release-dispatch.yml")
201
+ ?.addOverride("jobs.dispatch.steps.1.env.RELEASE_EVENT", releaseEvent);
175
202
  // Release the standalone projects in the SAME run, at the same version. They
176
203
  // are not workspace members, so nothing else would ever bring them along.
177
204
  const siblingArgs = this.standaloneReleases
@@ -195,11 +222,60 @@ export class DBXToolsRelease extends Component {
195
222
  }
196
223
  }
197
224
 
225
+ public override postSynthesize(): void {
226
+ rmSync(resolve(this.project.outdir, ".github/workflows/rust-release-dispatch.yml"), {
227
+ force: true,
228
+ });
229
+ }
230
+
231
+ private authorReleaseDispatcher(project: DBXToolsNodeProject): void {
232
+ new YamlFile(project, ".github/workflows/release-dispatch.yml", {
233
+ obj: {
234
+ name: "release-dispatch",
235
+ on: { push: { tags: [`${this.tagPrefix}*`] } },
236
+ permissions: { contents: "write" },
237
+ jobs: {
238
+ dispatch: {
239
+ "runs-on": "ubuntu-latest",
240
+ steps: [
241
+ {
242
+ name: "Checkout release tag",
243
+ uses: "actions/checkout@v6",
244
+ with: { "fetch-depth": 1 },
245
+ },
246
+ {
247
+ name: "Dispatch release",
248
+ shell: "bash",
249
+ env: {
250
+ GH_TOKEN: "${{ github.token }}",
251
+ RELEASE_TAG: "${{ github.ref_name }}",
252
+ RELEASE_EVENT: DOWNSTREAM_RELEASE_EVENT,
253
+ },
254
+ run: [
255
+ `case "$RELEASE_TAG" in ${this.tagPrefix}*) ;; *) exit 1 ;; esac`,
256
+ 'EXPECTED_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
257
+ [
258
+ 'gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches"',
259
+ '--raw-field event_type="$RELEASE_EVENT"',
260
+ '--raw-field "client_payload[release_tag]=$RELEASE_TAG"',
261
+ '--raw-field "client_payload[expected_sha]=$EXPECTED_SHA"',
262
+ ].join(" \\\n "),
263
+ ].join("\n"),
264
+ },
265
+ ],
266
+ },
267
+ },
268
+ },
269
+ });
270
+ }
271
+
198
272
  /** Author the common tag trigger, concurrency policy, permissions, and publish job. */
199
273
  private authorPublishWorkflow(
200
274
  project: DBXToolsNodeProject,
201
275
  { name, tagPrefix, steps, workingDirectory, upstreamWorkflow }: PublishWorkflow,
202
276
  ): void {
277
+ const setupSteps = publishSetupSteps();
278
+ const branchDispatch = upstreamWorkflow === undefined;
203
279
  const workflow = new GithubWorkflow(project.github!, name, {
204
280
  // Serialize publishes so two tags landing together cannot race to the
205
281
  // registry, but never cancel a run already in flight: a half-published
@@ -209,14 +285,19 @@ export class DBXToolsRelease extends Component {
209
285
  });
210
286
  // Read-only floor for any job that does not declare its own permissions;
211
287
  // the publish job below overrides it with the `id-token` it needs.
212
- workflow.file?.addOverride("permissions", { contents: "read" });
288
+ workflow.file?.addOverride("permissions", {
289
+ contents: "read",
290
+ ...(upstreamWorkflow ? { actions: "read" } : {}),
291
+ });
213
292
  if (upstreamWorkflow) {
214
293
  workflow.file?.addOverride("on.workflow_run", {
215
294
  workflows: [upstreamWorkflow],
216
295
  types: ["completed"],
217
296
  });
218
297
  } else {
219
- workflow.on({ push: { tags: [`${tagPrefix}*`] } });
298
+ workflow.file?.addOverride("on.repository_dispatch", {
299
+ types: [DOWNSTREAM_RELEASE_EVENT],
300
+ });
220
301
  }
221
302
  // Manual trigger for testing the workflow WITHOUT reaching npm: a
222
303
  // `workflow_dispatch` run has no tag, so the publish script forces
@@ -224,6 +305,20 @@ export class DBXToolsRelease extends Component {
224
305
  // additionally lets a tag push be dry-run on demand.
225
306
  workflow.file?.addOverride("on.workflow_dispatch", {
226
307
  inputs: {
308
+ ...(branchDispatch
309
+ ? {
310
+ release_tag: {
311
+ description: "Release tag to package during a dry run",
312
+ type: "string",
313
+ required: true,
314
+ },
315
+ expected_sha: {
316
+ description: "Commit the release tag must reference",
317
+ type: "string",
318
+ required: true,
319
+ },
320
+ }
321
+ : {}),
227
322
  dry_run: {
228
323
  description: "Pack and validate but do not upload to npm",
229
324
  type: "boolean",
@@ -234,12 +329,16 @@ export class DBXToolsRelease extends Component {
234
329
  workflow.addJob("publish", {
235
330
  ...(upstreamWorkflow
236
331
  ? {
237
- if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'workflow_run') }}",
332
+ 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')) }}",
238
333
  }
239
334
  : {}),
240
335
  runsOn: ["ubuntu-latest"],
241
336
  // `id-token: write` lets npm mint the OIDC token for provenance attestation.
242
- permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
337
+ permissions: {
338
+ ...(upstreamWorkflow ? { actions: JobPermission.READ } : {}),
339
+ contents: JobPermission.READ,
340
+ idToken: JobPermission.WRITE,
341
+ },
243
342
  timeoutMinutes: 30,
244
343
  // `DRY_RUN_INPUT` is `--dry-run` when the dispatch input is true, else empty;
245
344
  // the publish script also FORCES it on any `workflow_dispatch` run.
@@ -248,25 +347,65 @@ export class DBXToolsRelease extends Component {
248
347
  DRY_RUN_INPUT: "${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}",
249
348
  },
250
349
  steps: [
251
- ...publishSetupSteps().map((step) =>
252
- step.name === "Checkout" && upstreamWorkflow
350
+ ...(upstreamWorkflow
351
+ ? [
352
+ {
353
+ name: "Download release metadata",
354
+ uses: "actions/download-artifact@v8",
355
+ with: {
356
+ name: "release-metadata",
357
+ path: ".release",
358
+ "run-id": "${{ github.event.workflow_run.id }}",
359
+ "github-token": "${{ github.token }}",
360
+ },
361
+ },
362
+ {
363
+ name: "Read release metadata",
364
+ id: "release_metadata",
365
+ shell: "bash",
366
+ run: [
367
+ 'RELEASE_TAG="$(cat .release/tag)"',
368
+ 'EXPECTED_SHA="$(cat .release/sha)"',
369
+ 'test -n "$RELEASE_TAG"',
370
+ 'test -n "$EXPECTED_SHA"',
371
+ 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
372
+ 'echo "expected_sha=$EXPECTED_SHA" >> "$GITHUB_OUTPUT"',
373
+ ].join("\n"),
374
+ },
375
+ ]
376
+ : []),
377
+ {
378
+ ...setupSteps[0]!,
379
+ ...(upstreamWorkflow
253
380
  ? {
254
- ...step,
255
381
  with: {
256
- ...step.with,
257
- ref: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
382
+ ...setupSteps[0]!.with,
383
+ ref: "${{ steps.release_metadata.outputs.expected_sha }}",
384
+ "fetch-depth": 0,
258
385
  },
259
386
  }
260
- : step,
261
- ),
387
+ : {
388
+ with: {
389
+ ...setupSteps[0]!.with,
390
+ ref: RELEASE_SHA,
391
+ "fetch-depth": 1,
392
+ },
393
+ }),
394
+ },
262
395
  ...(upstreamWorkflow
263
396
  ? [
264
397
  {
265
- name: "Resolve release tag",
398
+ name: "Verify release source",
399
+ shell: "bash",
400
+ env: {
401
+ SOURCE_RELEASE_TAG: "${{ steps.release_metadata.outputs.release_tag }}",
402
+ EXPECTED_SHA: "${{ steps.release_metadata.outputs.expected_sha }}",
403
+ },
266
404
  run: [
267
- 'RELEASE_TAG="$(git tag --points-at HEAD --list "' +
268
- tagPrefix +
269
- '*" | sort -V | tail -1)"',
405
+ 'git fetch --force origin "+refs/tags/$SOURCE_RELEASE_TAG:refs/tags/$SOURCE_RELEASE_TAG"',
406
+ 'test "$(git rev-parse "$SOURCE_RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
407
+ 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"',
408
+ `RELEASE_TAG="$(git tag --points-at HEAD --list "${tagPrefix}*" | sort -V | tail -1)"`,
270
409
  'test -n "$RELEASE_TAG"',
271
410
  'echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"',
272
411
  `echo "RELEASE_VERSION=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_ENV"`,
@@ -274,6 +413,36 @@ export class DBXToolsRelease extends Component {
274
413
  } satisfies JobStep,
275
414
  ]
276
415
  : []),
416
+ ...(branchDispatch ? releaseSourceSteps().slice(1) : []),
417
+ ...(branchDispatch
418
+ ? [
419
+ {
420
+ name: "Resolve package release tag",
421
+ shell: "bash",
422
+ env: {
423
+ SOURCE_RELEASE_TAG: RELEASE_TAG,
424
+ EXPECTED_SHA: RELEASE_SHA,
425
+ },
426
+ run: [
427
+ `SOURCE_VERSION="\${SOURCE_RELEASE_TAG#${this.tagPrefix}}"`,
428
+ `RELEASE_TAG="${tagPrefix}\${SOURCE_VERSION}"`,
429
+ 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
430
+ 'test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
431
+ 'echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"',
432
+ `echo "RELEASE_VERSION=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_ENV"`,
433
+ "mkdir -p .release",
434
+ 'printf "%s\\n" "$SOURCE_RELEASE_TAG" > .release/tag',
435
+ 'printf "%s\\n" "$EXPECTED_SHA" > .release/sha',
436
+ ].join("\n"),
437
+ } satisfies JobStep,
438
+ ]
439
+ : []),
440
+ ...setupSteps.slice(1),
441
+ {
442
+ name: "Upload release metadata",
443
+ uses: "actions/upload-artifact@v7",
444
+ with: { name: "release-metadata", path: ".release" },
445
+ },
277
446
  ...steps,
278
447
  ],
279
448
  ...(workingDirectory ? { defaults: { run: { workingDirectory } } } : {}),
@@ -344,6 +513,7 @@ export class DBXToolsRelease extends Component {
344
513
  this.authorPublishWorkflow(project, {
345
514
  name,
346
515
  tagPrefix,
516
+ upstreamWorkflow: this.upstreamWorkflow,
347
517
  steps: [
348
518
  {
349
519
  name: "Set version from tag and publish",
@@ -352,7 +522,7 @@ export class DBXToolsRelease extends Component {
352
522
  ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
353
523
  " DRY_RUN=--dry-run",
354
524
  "else",
355
- ` VERSION="\${GITHUB_REF_NAME#${tagPrefix}}"`,
525
+ ` VERSION="\${RELEASE_VERSION:-\${GITHUB_REF_NAME#${tagPrefix}}}"`,
356
526
  ' DRY_RUN="${DRY_RUN_INPUT}"',
357
527
  "fi",
358
528
  "chmod -R u+w . || true",
package/tasks/bump.ts CHANGED
@@ -115,6 +115,16 @@ function git(args: string[], capture = false): string {
115
115
  return res.stdout?.trim() ?? "";
116
116
  }
117
117
 
118
+ function assertReleaseTagsPointToHead(tags: readonly string[]): void {
119
+ const head = git(["rev-parse", "HEAD"], true);
120
+ for (const tag of tags) {
121
+ const target = git(["rev-parse", `${tag}^{commit}`], true);
122
+ if (!head || target !== head) {
123
+ throw new Error(`Release tag ${tag} does not point to the pushed HEAD commit`);
124
+ }
125
+ }
126
+ }
127
+
118
128
  /**
119
129
  * Write ONLY the `version` field into a manifest projen owns (read-only, so
120
130
  * bracketed by a chmod that restores the mode). Used for the ROOT and the
@@ -324,9 +334,17 @@ program
324
334
 
325
335
  if (push) {
326
336
  git(["push", "origin", "HEAD"]);
327
- // Push every tag in ONE invocation: each push triggers a workflow, and a
328
- // partial push would release half the set at this version.
329
- if (opts.tag) git(["push", "origin", ...tags]);
337
+ // The managed Databricks pre-push hook treats a newly created tag's
338
+ // all-zero remote SHA as a branch with no upstream and scans hundreds of
339
+ // historical commits. The release commit was already scanned by the
340
+ // branch push immediately above, and each annotated tag points to that
341
+ // exact commit, so bypass the hook only for the tag-ref transport.
342
+ // Push every tag in one invocation so a partial push cannot release
343
+ // only some namespaces at this version.
344
+ if (opts.tag) {
345
+ assertReleaseTagsPointToHead(tags);
346
+ git(["push", "--no-verify", "origin", ...tags]);
347
+ }
330
348
  logger.success(`pushed ${opts.tag ? tags.join(", ") : "HEAD"} to origin`);
331
349
  } else {
332
350
  logger.info("skipped push (--no-push / --no-publish)");
@@ -47,9 +47,21 @@ const required = (name) => {
47
47
  };
48
48
 
49
49
  const root = resolve(parsed.values.root ?? process.cwd());
50
+ const commandInvocation = (command, args) => {
51
+ if (process.platform !== "win32" || command !== "npm") return { command, args };
52
+ const npmCli = resolve(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
53
+ if (!existsSync(npmCli)) throw new Error(`Missing npm CLI ${npmCli}`);
54
+ return { command: process.execPath, args: [npmCli, ...args] };
55
+ };
50
56
  const run = (command, args, cwd = root) => {
51
- const result = spawnSync(command, args, { cwd, stdio: "inherit" });
52
- if (result.status !== 0) throw new Error(`${command} exited with ${result.status}`);
57
+ const invocation = commandInvocation(command, args);
58
+ const result = spawnSync(invocation.command, invocation.args, { cwd, stdio: "inherit" });
59
+ if (result.error) {
60
+ throw new Error(`${invocation.command} failed: ${result.error.message}`, {
61
+ cause: result.error,
62
+ });
63
+ }
64
+ if (result.status !== 0) throw new Error(`${invocation.command} exited with ${result.status}`);
53
65
  };
54
66
 
55
67
  const replaceVersion = (source, version) =>