@dbx-tools/projen 0.9.6 → 0.9.7

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/README.md CHANGED
@@ -254,7 +254,11 @@ and select `rust-lld` for the workspace build. Rust release rows restore and
254
254
  update dependency-only Cargo caches in the release branch's cache scope. The
255
255
  cache key excludes workspace version changes, and manual tag recovery restores
256
256
  without saving a tag-scoped copy. There is no separate cache workflow or
257
- sccache layer. Phase timings are written to each build log. Python generation executes the already-built
257
+ sccache layer. After publishing the current raw Rust bundles and build manifests,
258
+ the GitHub release job deletes those internal assets from published
259
+ `vMAJOR.MINOR.PATCH` releases strictly lower than the current version while
260
+ leaving versioned consumer binaries in place. Phase timings are written to each
261
+ build log. Python generation executes the already-built
258
262
  `target/<triple>/release/<crate>-uniffi-bindgen` directly. Artifact packaging therefore
259
263
  does no Rust compilation after the main workspace build.
260
264
 
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.6";
6
+ export const PACKAGE_VERSION = "0.9.7";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.6",
34
- "@dbx-tools/path": "^0.9.6",
35
- "@dbx-tools/shared-core": "^0.9.6",
33
+ "@dbx-tools/core": "^0.9.7",
34
+ "@dbx-tools/path": "^0.9.7",
35
+ "@dbx-tools/shared-core": "^0.9.7",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "main": "index.ts",
55
55
  "license": "Apache-2.0",
56
- "version": "0.9.6",
56
+ "version": "0.9.7",
57
57
  "types": "index.ts",
58
58
  "type": "module",
59
59
  "exports": {
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { stringUtils } from "@dbx-tools/shared-core";
6
7
  import { TextFile, javascript } from "projen";
7
8
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
8
9
  import type { RustProject } from "./_rust-project.ts";
@@ -482,29 +483,34 @@ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly
482
483
  id: "raw-native",
483
484
  shell: "bash",
484
485
  env: { GH_TOKEN: "${{ github.token }}" },
485
- run: [
486
- 'KEY="${{ steps.rust-fingerprint.outputs.key }}"',
487
- 'test -n "$KEY"',
488
- 'ASSET="rust-raw-${{ matrix.node }}-$KEY.tar.gz"',
489
- "mkdir -p dist/rust-raw",
490
- 'MATCH="$(gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" --jq \'.[] | select(.draft == false) | . as $release | .assets[] | select(.name == "\'"$ASSET"\'") | [$release.tag_name, .url] | @tsv\' | head -n 1)"',
491
- 'if [ -n "$MATCH" ]; then',
492
- " TAG=\"${MATCH%%$'\\t'*}\"",
493
- " URL=\"${MATCH#*$'\\t'}\"",
494
- ' CHECKSUM_URL="$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq \'.assets[] | select(.name == "\'"$ASSET.sha256"\'") | .url\')"',
495
- ' rm -rf "target/${{ matrix.cargo }}/release"',
496
- ' if test -n "$CHECKSUM_URL" && gh api "$URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET" && gh api "$CHECKSUM_URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET.sha256" && (cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum --check "$ASSET.sha256"; else shasum -a 256 --check "$ASSET.sha256"; fi) && tar -xzf "dist/rust-raw/$ASSET"; then',
497
- ' echo "hit=true" >> "$GITHUB_OUTPUT"',
498
- " else",
499
- ' rm -rf "target/${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"',
500
- ' echo "::warning::matching raw Rust bundle failed validation; rebuilding"',
501
- ' echo "hit=false" >> "$GITHUB_OUTPUT"',
502
- " fi",
503
- "else",
504
- ' echo "hit=false" >> "$GITHUB_OUTPUT"',
505
- "fi",
506
- 'echo "asset=$ASSET" >> "$GITHUB_OUTPUT"',
507
- ].join("\n"),
486
+ // prettier-ignore
487
+ run: stringUtils.dedent(
488
+ // ============================================================================
489
+ /*bash*/`
490
+ KEY="\${{ steps.rust-fingerprint.outputs.key }}"
491
+ test -n "$KEY"
492
+ ASSET="rust-raw-\${{ matrix.node }}-$KEY.tar.gz"
493
+ mkdir -p dist/rust-raw
494
+ MATCH="$(gh api --paginate "repos/\${{ github.repository }}/releases?per_page=100" --jq '.[] | select(.draft == false) | . as $release | .assets[] | select(.name == "'"$ASSET"'") | [$release.tag_name, .url] | @tsv' | head -n 1)"
495
+ if [ -n "$MATCH" ]; then
496
+ TAG="\${MATCH%%$'\\t'*}"
497
+ URL="\${MATCH#*$'\\t'}"
498
+ CHECKSUM_URL="$(gh api "repos/\${{ github.repository }}/releases/tags/$TAG" --jq '.assets[] | select(.name == "'"$ASSET.sha256"'") | .url')"
499
+ rm -rf "target/\${{ matrix.cargo }}/release"
500
+ if test -n "$CHECKSUM_URL" && gh api "$URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET" && gh api "$CHECKSUM_URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET.sha256" && (cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum --check "$ASSET.sha256"; else shasum -a 256 --check "$ASSET.sha256"; fi) && tar -xzf "dist/rust-raw/$ASSET"; then
501
+ echo "hit=true" >> "$GITHUB_OUTPUT"
502
+ else
503
+ rm -rf "target/\${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"
504
+ echo "::warning::matching raw Rust bundle failed validation; rebuilding"
505
+ echo "hit=false" >> "$GITHUB_OUTPUT"
506
+ fi
507
+ else
508
+ echo "hit=false" >> "$GITHUB_OUTPUT"
509
+ fi
510
+ echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
511
+ `
512
+ // ============================================================================
513
+ ),
508
514
  },
509
515
  ]
510
516
  : []),
@@ -757,6 +763,53 @@ export function rustGitHubReleaseJob(): Job {
757
763
  target_commitish: RELEASE_SHA,
758
764
  },
759
765
  },
766
+ {
767
+ name: "Delete superseded raw Rust release assets",
768
+ uses: "actions/github-script@v8",
769
+ env: { CURRENT_RELEASE_TAG: RELEASE_TAG },
770
+ with: {
771
+ // prettier-ignore
772
+ script: stringUtils.dedent(
773
+ // ============================================================================
774
+ /*js*/`
775
+ const currentTag = process.env.CURRENT_RELEASE_TAG;
776
+ if (!currentTag) throw new Error("CURRENT_RELEASE_TAG is required");
777
+ const parseVersion = (tag) => {
778
+ const match = /^v(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(tag);
779
+ return match ? match.slice(1).map(Number) : undefined;
780
+ };
781
+ const compareVersions = (left, right) => {
782
+ for (let index = 0; index < 3; index += 1) {
783
+ if (left[index] !== right[index]) return left[index] - right[index];
784
+ }
785
+ return 0;
786
+ };
787
+ const currentVersion = parseVersion(currentTag);
788
+ if (!currentVersion) throw new Error(\`Unsupported release tag: \${currentTag}\`);
789
+ const releases = await github.paginate(github.rest.repos.listReleases, {
790
+ ...context.repo,
791
+ per_page: 100,
792
+ });
793
+ const assets = releases.flatMap((release) => {
794
+ const version = parseVersion(release.tag_name);
795
+ if (release.draft || !version || compareVersions(version, currentVersion) >= 0) {
796
+ return [];
797
+ }
798
+ return release.assets.filter((asset) => /^rust-(?:raw|build)-/.test(asset.name));
799
+ });
800
+ for (const asset of assets) {
801
+ core.info(\`Deleting \${asset.name} (\${asset.id})\`);
802
+ await github.rest.repos.deleteReleaseAsset({
803
+ ...context.repo,
804
+ asset_id: asset.id,
805
+ });
806
+ }
807
+ core.info(\`Deleted \${assets.length} superseded raw Rust release assets\`);
808
+ `
809
+ // ============================================================================
810
+ ),
811
+ },
812
+ },
760
813
  ],
761
814
  };
762
815
  }
package/src/barrels.ts CHANGED
@@ -523,10 +523,9 @@ const WRITE_ATTEMPTS = 3;
523
523
  * and the projenrc watcher's post-synth `generateBarrels()` sweep), so any gap
524
524
  * between `makeWritable` and the write is a window in which another process's
525
525
  * `makeReadonly` lands and this write fails with
526
- * `EACCES: permission denied, open '<pkg>/index.ts'`. The gap used to span all of
527
- * the oxc parsing done for export hoisting, which made it wide enough to hit
528
- * routinely. Keeping the unlock adjacent to the write shrinks it to nothing much,
529
- * and a retry absorbs what is left.
526
+ * `EACCES: permission denied, open '<pkg>/index.ts'`. Keeping the unlock adjacent
527
+ * to the write avoids a gap during export parsing, and a retry absorbs what is
528
+ * left.
530
529
  *
531
530
  * Do NOT "simplify" this back to a single unlock-then-write: the failure is
532
531
  * timing-dependent, so it looks fine until a full-repo sweep runs against a
package/src/bun-app.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * `src/`), they are outside the package's `tsconfig` include, so their `Bun.*`
24
24
  * usage never trips the `app` package's DOM-only `compile`.
25
25
  */
26
+ import { stringUtils } from "@dbx-tools/shared-core";
26
27
  import { type Project, TextFile } from "projen";
27
28
 
28
29
  /** Unmanaged dev-server override module, merged over the generated options if present. */
@@ -90,34 +91,38 @@ export class BunfigFile extends TextFile {
90
91
  /** `dev.ts`: a `Bun.serve()` fullstack dev server with React HMR. */
91
92
  export class BunDevServerFile extends TextFile {
92
93
  constructor(project: Project) {
93
- super(project, "dev.ts", {
94
- marker: true,
95
- readonly: true,
96
- lines: String.raw`
97
- import { existsSync } from "node:fs";
98
- import index from "./index.html";
94
+ // prettier-ignore
95
+ const source = (
96
+ // ============================================================================
97
+ /*ts*/`
98
+ import { existsSync } from "node:fs";
99
+ import index from "./index.html";
99
100
 
100
- // Unmanaged override (${BUN_DEV_OVERRIDE}): its default export is merged over the
101
- // generated serve options - later wins - so a package tweaks the dev server
102
- // without editing this generated file.
103
- let options: Bun.ServeFunctionOptions<unknown, {}> = {
104
- port: Number(process.env.PORT ?? 5173),
105
- development: { hmr: true, console: true },
106
- routes: { "/*": index },
107
- };
101
+ // Unmanaged override (${BUN_DEV_OVERRIDE}): its default export is merged over the
102
+ // generated serve options - later wins - so a package tweaks the dev server
103
+ // without editing this generated file.
104
+ let options: Bun.ServeFunctionOptions<unknown, {}> = {
105
+ port: Number(process.env.PORT ?? 5173),
106
+ development: { hmr: true, console: true },
107
+ routes: { "/*": index },
108
+ };
108
109
 
109
- const overrideUrl = new URL("./${BUN_DEV_OVERRIDE}", import.meta.url);
110
- if (existsSync(overrideUrl)) {
111
- const mod = await import(overrideUrl.href);
112
- const override = typeof mod.default === "function" ? await mod.default() : mod.default;
113
- options = { ...options, ...override };
114
- }
110
+ const overrideUrl = new URL("./${BUN_DEV_OVERRIDE}", import.meta.url);
111
+ if (existsSync(overrideUrl)) {
112
+ const mod = await import(overrideUrl.href);
113
+ const override = typeof mod.default === "function" ? await mod.default() : mod.default;
114
+ options = { ...options, ...override };
115
+ }
115
116
 
116
- const server = Bun.serve(options);
117
- console.log("dev server running at " + server.url.href);
118
- `
119
- .trimStart()
120
- .split("\n"),
117
+ const server = Bun.serve(options);
118
+ console.log("dev server running at " + server.url.href);
119
+ `
120
+ // ============================================================================
121
+ );
122
+ super(project, "dev.ts", {
123
+ marker: true,
124
+ readonly: true,
125
+ lines: stringUtils.dedentLines(source, { trimEnd: false }),
121
126
  });
122
127
  }
123
128
  }
@@ -125,73 +130,79 @@ console.log("dev server running at " + server.url.href);
125
130
  /** `build.ts`: a `Bun.build()` production bundle (Tailwind via the plugin API). */
126
131
  export class BunBuildFile extends TextFile {
127
132
  constructor(project: Project) {
128
- super(project, "build.ts", {
129
- marker: true,
130
- readonly: true,
131
- lines: String.raw`
132
- import { existsSync } from "node:fs";
133
- import { cp, rm } from "node:fs/promises";
134
- import { basename } from "node:path";
135
- import tailwind from "bun-plugin-tailwind";
133
+ // Raw template preserves backslashes in generated regular expression literals.
134
+ // prettier-ignore
135
+ const source = (
136
+ String.raw
137
+ // ============================================================================
138
+ /*ts*/`
139
+ import { existsSync } from "node:fs";
140
+ import { cp, rm } from "node:fs/promises";
141
+ import { basename } from "node:path";
142
+ import tailwind from "bun-plugin-tailwind";
136
143
 
137
- // Unmanaged override (${BUN_BUILD_OVERRIDE}): its default export is merged over
138
- // the generated build options - later wins.
139
- let options: Bun.BuildConfig = {
140
- entrypoints: ["./index.html"],
141
- outdir: "./dist",
142
- define: { "process.env.NODE_ENV": JSON.stringify("production") },
143
- minify: true,
144
- splitting: true,
145
- publicPath: "/",
146
- external: ["/fonts/*"],
147
- sourcemap: "none",
148
- plugins: [tailwind],
149
- };
144
+ // Unmanaged override (${BUN_BUILD_OVERRIDE}): its default export is merged over
145
+ // the generated build options - later wins.
146
+ let options: Bun.BuildConfig = {
147
+ entrypoints: ["./index.html"],
148
+ outdir: "./dist",
149
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
150
+ minify: true,
151
+ splitting: true,
152
+ publicPath: "/",
153
+ external: ["/fonts/*"],
154
+ sourcemap: "none",
155
+ plugins: [tailwind],
156
+ };
150
157
 
151
- const overrideUrl = new URL("./${BUN_BUILD_OVERRIDE}", import.meta.url);
152
- if (existsSync(overrideUrl)) {
153
- const mod = await import(overrideUrl.href);
154
- const override = typeof mod.default === "function" ? await mod.default() : mod.default;
155
- options = { ...options, ...override, plugins: [...(options.plugins ?? []), ...(override.plugins ?? [])] };
156
- }
158
+ const overrideUrl = new URL("./${BUN_BUILD_OVERRIDE}", import.meta.url);
159
+ if (existsSync(overrideUrl)) {
160
+ const mod = await import(overrideUrl.href);
161
+ const override = typeof mod.default === "function" ? await mod.default() : mod.default;
162
+ options = { ...options, ...override, plugins: [...(options.plugins ?? []), ...(override.plugins ?? [])] };
163
+ }
157
164
 
158
- const outdir = options.outdir ?? "./dist";
159
- await rm(outdir, { recursive: true, force: true });
165
+ const outdir = options.outdir ?? "./dist";
166
+ await rm(outdir, { recursive: true, force: true });
160
167
 
161
- const result = await Bun.build(options);
162
- if (!result.success) {
163
- for (const message of result.logs) console.error(message);
164
- process.exit(1);
165
- }
168
+ const result = await Bun.build(options);
169
+ if (!result.success) {
170
+ for (const message of result.logs) console.error(message);
171
+ process.exit(1);
172
+ }
166
173
 
167
- // Fix a Bun bug: with \`splitting: true\` and an HTML entrypoint, the emitted
168
- // HTML's <script src> is wired to an arbitrary chunk instead of the JS
169
- // entry-point, so the app never boots (a blank page). Rewrite the HTML script
170
- // (and stylesheet) to the real entry-point outputs. No-op when they already
171
- // match (e.g. splitting off), so it is always safe to run.
172
- const htmlOut = result.outputs.find((o) => o.path.endsWith(".html"));
173
- const entryJs = result.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js"));
174
- const entryCss = result.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".css"));
175
- if (htmlOut && entryJs) {
176
- let html = await Bun.file(htmlOut.path).text();
177
- const jsName = basename(entryJs.path);
178
- html = html.replace(/(<script[^>]*\bsrc=")([^"]*\/)?[^"/]+\.js(")/i, "$1$2" + jsName + "$3");
179
- if (entryCss) {
180
- const cssName = basename(entryCss.path);
181
- html = html.replace(/(<link[^>]*\bhref=")([^"]*\/)?[^"/]+\.css(")/i, "$1$2" + cssName + "$3");
182
- }
183
- await Bun.write(htmlOut.path, html);
184
- }
174
+ // Fix a Bun bug: with \`splitting: true\` and an HTML entrypoint, the emitted
175
+ // HTML's <script src> is wired to an arbitrary chunk instead of the JS
176
+ // entry-point, so the app never boots (a blank page). Rewrite the HTML script
177
+ // (and stylesheet) to the real entry-point outputs. No-op when they already
178
+ // match (e.g. splitting off), so it is always safe to run.
179
+ const htmlOut = result.outputs.find((o) => o.path.endsWith(".html"));
180
+ const entryJs = result.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js"));
181
+ const entryCss = result.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".css"));
182
+ if (htmlOut && entryJs) {
183
+ let html = await Bun.file(htmlOut.path).text();
184
+ const jsName = basename(entryJs.path);
185
+ html = html.replace(/(<script[^>]*\bsrc=")([^"]*\/)?[^"/]+\.js(")/i, "$1$2" + jsName + "$3");
186
+ if (entryCss) {
187
+ const cssName = basename(entryCss.path);
188
+ html = html.replace(/(<link[^>]*\bhref=")([^"]*\/)?[^"/]+\.css(")/i, "$1$2" + cssName + "$3");
189
+ }
190
+ await Bun.write(htmlOut.path, html);
191
+ }
185
192
 
186
- const publicDir = "./public";
187
- if (existsSync(publicDir)) {
188
- await cp(publicDir, outdir, { recursive: true });
189
- }
193
+ const publicDir = "./public";
194
+ if (existsSync(publicDir)) {
195
+ await cp(publicDir, outdir, { recursive: true });
196
+ }
190
197
 
191
- console.log("built " + result.outputs.length + " files to " + options.outdir);
192
- `
193
- .trimStart()
194
- .split("\n"),
198
+ console.log("built " + result.outputs.length + " files to " + options.outdir);
199
+ `
200
+ // ============================================================================
201
+ );
202
+ super(project, "build.ts", {
203
+ marker: true,
204
+ readonly: true,
205
+ lines: stringUtils.dedentLines(source, { trimEnd: false }),
195
206
  });
196
207
  }
197
208
  }
@@ -1,4 +1,5 @@
1
1
  /** Shared Bun setup and package-cache steps for generated workflows. */
2
+ import { stringUtils } from "@dbx-tools/shared-core";
2
3
  import { TextFile, javascript } from "projen";
3
4
 
4
5
  export const BUN_VERSION = "1.3.14";
@@ -16,61 +17,68 @@ const DEFAULT_CACHE_IGNORE_PATHS = [
16
17
 
17
18
  function cacheKeyScript(extraIgnorePaths: readonly string[]): string {
18
19
  const ignorePaths = [...new Set(extraIgnorePaths)].sort();
19
- return `#!/usr/bin/env node
20
- import { createHash } from "node:crypto";
21
- import { readdirSync, readFileSync } from "node:fs";
22
- import { join, sep } from "node:path";
20
+ // prettier-ignore
21
+ const source = (
22
+ // ============================================================================
23
+ /*js*/`
24
+ #!/usr/bin/env node
25
+ import { createHash } from "node:crypto";
26
+ import { readdirSync, readFileSync } from "node:fs";
27
+ import { join, sep } from "node:path";
23
28
 
24
- const root = process.cwd();
25
- const ignoredNames = new Set(${JSON.stringify(DEFAULT_CACHE_IGNORE_PATHS)});
26
- const ignoredPaths = new Set(${JSON.stringify(ignorePaths)});
27
- const manifests = [];
28
- const walk = (directory) => {
29
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
30
- const path = join(directory, entry.name);
31
- const relativePath = path.slice(root.length + 1).split(sep).join("/");
32
- if (ignoredNames.has(entry.name) || ignoredPaths.has(relativePath)) continue;
33
- if (entry.isDirectory()) walk(path);
34
- else if (entry.name === "package.json") manifests.push(path);
35
- }
36
- };
37
- walk(root);
29
+ const root = process.cwd();
30
+ const ignoredNames = new Set(${JSON.stringify(DEFAULT_CACHE_IGNORE_PATHS)});
31
+ const ignoredPaths = new Set(${JSON.stringify(ignorePaths)});
32
+ const manifests = [];
33
+ const walk = (directory) => {
34
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
35
+ const path = join(directory, entry.name);
36
+ const relativePath = path.slice(root.length + 1).split(sep).join("/");
37
+ if (ignoredNames.has(entry.name) || ignoredPaths.has(relativePath)) continue;
38
+ if (entry.isDirectory()) walk(path);
39
+ else if (entry.name === "package.json") manifests.push(path);
40
+ }
41
+ };
42
+ walk(root);
38
43
 
39
- const dependencyFields = [
40
- "catalog",
41
- "dependencies",
42
- "devDependencies",
43
- "optionalDependencies",
44
- "overrides",
45
- "peerDependencies",
46
- "peerDependenciesMeta",
47
- "resolutions",
48
- "trustedDependencies",
49
- ];
50
- const canonical = (value) => {
51
- if (Array.isArray(value)) return value.map(canonical);
52
- if (!value || typeof value !== "object") return value;
53
- return Object.fromEntries(
54
- Object.entries(value)
55
- .sort(([left], [right]) => left.localeCompare(right))
56
- .map(([key, child]) => [key, canonical(child)]),
44
+ const dependencyFields = [
45
+ "catalog",
46
+ "dependencies",
47
+ "devDependencies",
48
+ "optionalDependencies",
49
+ "overrides",
50
+ "peerDependencies",
51
+ "peerDependenciesMeta",
52
+ "resolutions",
53
+ "trustedDependencies",
54
+ ];
55
+ const canonical = (value) => {
56
+ if (Array.isArray(value)) return value.map(canonical);
57
+ if (!value || typeof value !== "object") return value;
58
+ return Object.fromEntries(
59
+ Object.entries(value)
60
+ .sort(([left], [right]) => left.localeCompare(right))
61
+ .map(([key, child]) => [key, canonical(child)]),
62
+ );
63
+ };
64
+ const dependencies = manifests
65
+ .sort()
66
+ .map((path) => {
67
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
68
+ return [
69
+ path.slice(root.length + 1),
70
+ Object.fromEntries(
71
+ dependencyFields
72
+ .filter((field) => manifest[field] !== undefined)
73
+ .map((field) => [field, canonical(manifest[field])]),
74
+ ),
75
+ ];
76
+ });
77
+ process.stdout.write(createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"));
78
+ `
79
+ // ============================================================================
57
80
  );
58
- };
59
- const dependencies = manifests
60
- .sort()
61
- .map((path) => {
62
- const manifest = JSON.parse(readFileSync(path, "utf8"));
63
- return [
64
- path.slice(root.length + 1),
65
- Object.fromEntries(
66
- dependencyFields
67
- .filter((field) => manifest[field] !== undefined)
68
- .map((field) => [field, canonical(manifest[field])]),
69
- ),
70
- ];
71
- });
72
- process.stdout.write(createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"));
73
- `;
81
+ return stringUtils.dedent(source, { trimEnd: false });
74
82
  }
75
83
 
76
84
  const configured = new WeakMap<javascript.NodeProject, string>();
package/src/openapi.ts CHANGED
@@ -37,7 +37,7 @@ import { join } from "node:path";
37
37
  import { promisify } from "node:util";
38
38
  import * as bin from "@dbx-tools/core/bin";
39
39
  import { find } from "@dbx-tools/path";
40
- import { log } from "@dbx-tools/shared-core";
40
+ import { log, stringUtils } from "@dbx-tools/shared-core";
41
41
  import type * as ts from "typescript";
42
42
  import { lazyRequire } from "./_lazy-require.ts";
43
43
  import { makeReadonly, makeWritable, stampGenerated } from "./generated.ts";
@@ -61,14 +61,21 @@ const SPEAKEASY_OPENAPI_VERSION = "1.24.0";
61
61
  const SPEAKEASY_OPENAPI_RELEASE_URL = `https://github.com/speakeasy-api/openapi/releases/download/v${SPEAKEASY_OPENAPI_VERSION}`;
62
62
  const execFileAsync = promisify(execFile);
63
63
 
64
- const CLIENT_SRC = `import createClient, { type ClientOptions } from "openapi-fetch";
65
- import type { paths } from "./schema";
64
+ // prettier-ignore
65
+ const CLIENT_TEMPLATE = (
66
+ // ============================================================================
67
+ /*ts*/`
68
+ import createClient, { type ClientOptions } from "openapi-fetch";
69
+ import type { paths } from "./schema";
66
70
 
67
- /** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
68
- export function createApiClient(options?: ClientOptions) {
69
- return createClient<paths>(options);
70
- }
71
- `;
71
+ /** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
72
+ export function createApiClient(options?: ClientOptions) {
73
+ return createClient<paths>(options);
74
+ }
75
+ `
76
+ // ============================================================================
77
+ );
78
+ const CLIENT_SRC = stringUtils.dedent(CLIENT_TEMPLATE, { trimEnd: false });
72
79
 
73
80
  /** True if any module file in `<pkg>/src` matches {@link TSOA_IMPORT}. */
74
81
  function hasTsoaControllers(pkg: Pick<RecordedPackage, "dir">): boolean {
package/src/project-py.ts CHANGED
@@ -766,23 +766,28 @@ export class DBXToolsPythonWorkspace extends Component {
766
766
  ];
767
767
  const helper = ".projen/pypi-trusted-publisher-instructions.mjs";
768
768
  new TextFile(project.root, helper, {
769
- lines: [
770
- "#!/usr/bin/env node",
771
- 'import { parseArgs } from "node:util";',
772
- "",
773
- "const { values } = parseArgs({",
774
- ' options: { secretFile: { type: "string" } },',
775
- "});",
776
- `const beforeAuthentication = ${JSON.stringify(linesBeforeAuthentication)};`,
777
- `const afterAuthentication = ${JSON.stringify(linesAfterAuthentication)};`,
778
- "const authentication = values.secretFile",
779
- " ? [",
780
- " `- If browser authentication is required, read credentials from ${values.secretFile} directly into the browser without printing, logging, or exposing secret values.`,",
781
- " `- If ${values.secretFile} is absent, invalid, or insufficient for authentication, pause and ask the user to complete authentication.`,",
782
- " ]",
783
- ' : ["- If browser authentication is required, pause and ask the user to complete authentication."];',
784
- 'process.stdout.write(`${[...beforeAuthentication, ...authentication, ...afterAuthentication].join("\\n").trimEnd()}\\n`);',
785
- ],
769
+ // prettier-ignore
770
+ lines: stringUtils.dedent(
771
+ // ============================================================================
772
+ /*js*/`
773
+ #!/usr/bin/env node
774
+ import { parseArgs } from "node:util";
775
+
776
+ const { values } = parseArgs({
777
+ options: { secretFile: { type: "string" } },
778
+ });
779
+ const beforeAuthentication = ${JSON.stringify(linesBeforeAuthentication)};
780
+ const afterAuthentication = ${JSON.stringify(linesAfterAuthentication)};
781
+ const authentication = values.secretFile
782
+ ? [
783
+ \`- If browser authentication is required, read credentials from \${values.secretFile} directly into the browser without printing, logging, or exposing secret values.\`,
784
+ \`- If \${values.secretFile} is absent, invalid, or insufficient for authentication, pause and ask the user to complete authentication.\`,
785
+ ]
786
+ : ["- If browser authentication is required, pause and ask the user to complete authentication."];
787
+ process.stdout.write(\`\${[...beforeAuthentication, ...authentication, ...afterAuthentication].join("\\n").trimEnd()}\\n\`);
788
+ `
789
+ // ============================================================================
790
+ ).split("\n"),
786
791
  });
787
792
  project.root.addTask("pypiTrustedPublisherInstructions", {
788
793
  description: "Print system-browser instructions for PyPI trusted publishers",
package/src/release.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Unified default-branch release workflow generation. */
2
+ import { stringUtils } from "@dbx-tools/shared-core";
2
3
  import { Component } from "projen";
3
4
  import { GithubWorkflow } from "projen/lib/github";
4
5
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
@@ -205,47 +206,52 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
205
206
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
206
207
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
207
208
  },
208
- run: [
209
- 'if [ "$GITHUB_EVENT_NAME" = "push" ]; then',
210
- ' test "$GITHUB_REF_TYPE" = "branch"',
211
- ` test "$GITHUB_REF_NAME" = "${releaseBranch}"`,
212
- ' RELEASE_SHA="$GITHUB_SHA"',
213
- ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
214
- " RELEASE_VERSION=\"$(tr -d '\\r\\n' < VERSION)\"",
215
- ' bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"',
216
- ' PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d \'\\r\\n\')"',
217
- ' test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"',
218
- ` RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"`,
219
- ' if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then',
220
- ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
221
- ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
222
- ' test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"',
223
- " else",
224
- " git fetch --force --tags origin",
225
- ` bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION" --prefix ${JSON.stringify(tagPrefix)} --assert-next`,
226
- ' git config user.name "github-actions[bot]"',
227
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
228
- ' git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"',
229
- ' git push origin "refs/tags/$RELEASE_TAG"',
230
- " fi",
231
- "else",
232
- ` case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
233
- ` RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"`,
234
- ' bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"',
235
- ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
236
- ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
237
- ' RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
238
- ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
239
- ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
240
- ' if [ -n "${{ inputs.source_run_id }}" ]; then',
241
- ' case "${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac',
242
- ' case "${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac',
243
- " fi",
244
- "fi",
245
- 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
246
- 'echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"',
247
- 'echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"',
248
- ].join("\n"),
209
+ // prettier-ignore
210
+ run: stringUtils.dedent(
211
+ // ============================================================================
212
+ /*bash*/`
213
+ if [ "$GITHUB_EVENT_NAME" = "push" ]; then
214
+ test "$GITHUB_REF_TYPE" = "branch"
215
+ test "$GITHUB_REF_NAME" = "${releaseBranch}"
216
+ RELEASE_SHA="$GITHUB_SHA"
217
+ test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
218
+ RELEASE_VERSION="$(tr -d '\\r\\n' < VERSION)"
219
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
220
+ PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d '\\r\\n')"
221
+ test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"
222
+ RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"
223
+ if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then
224
+ git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
225
+ test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
226
+ test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"
227
+ else
228
+ git fetch --force --tags origin
229
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION" --prefix ${JSON.stringify(tagPrefix)} --assert-next
230
+ git config user.name "github-actions[bot]"
231
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
232
+ git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"
233
+ git push origin "refs/tags/$RELEASE_TAG"
234
+ fi
235
+ else
236
+ case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac
237
+ RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"
238
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
239
+ git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
240
+ test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
241
+ RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"
242
+ test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
243
+ test "$RELEASE_SHA" = "$EXPECTED_SHA"
244
+ if [ -n "\${{ inputs.source_run_id }}" ]; then
245
+ case "\${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac
246
+ case "\${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac
247
+ fi
248
+ fi
249
+ echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
250
+ echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
251
+ echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
252
+ `
253
+ // ============================================================================
254
+ ),
249
255
  },
250
256
  {
251
257
  name: "Verify source artifact run",
@@ -256,15 +262,20 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
256
262
  SOURCE_RUN_ID: "${{ inputs.source_run_id }}",
257
263
  },
258
264
  with: {
259
- script: [
260
- "const run = await github.rest.actions.getWorkflowRun({",
261
- " owner: context.repo.owner,",
262
- " repo: context.repo.repo,",
263
- " run_id: Number(process.env.SOURCE_RUN_ID),",
264
- "});",
265
- 'if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");',
266
- 'if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");',
267
- ].join("\n"),
265
+ // prettier-ignore
266
+ script: stringUtils.dedent(
267
+ // ============================================================================
268
+ /*js*/`
269
+ const run = await github.rest.actions.getWorkflowRun({
270
+ owner: context.repo.owner,
271
+ repo: context.repo.repo,
272
+ run_id: Number(process.env.SOURCE_RUN_ID),
273
+ });
274
+ if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");
275
+ if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");
276
+ `
277
+ // ============================================================================
278
+ ),
268
279
  },
269
280
  },
270
281
  ],
@@ -422,44 +433,49 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
422
433
  RELEASE_PRS: "${{ steps.release.outputs.prs }}",
423
434
  },
424
435
  shell: "bash",
425
- run: [
426
- 'BRANCH="$(jq -r \'.[0].headBranchName\' <<<"$RELEASE_PRS")"',
427
- 'test -n "$BRANCH" && test "$BRANCH" != "null"',
428
- 'git fetch origin "$BRANCH"',
429
- 'git switch --force-create "$BRANCH" "origin/$BRANCH"',
430
- "bun install",
431
- "bunx projen",
432
- "bun node_modules/@dbx-tools/projen/tasks/version-check.ts",
433
- `bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}`,
434
- `bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}`,
435
- 'if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then',
436
- ' git config user.name "github-actions[bot]"',
437
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
438
- " git add -A",
439
- ' git commit -m "chore: reconcile release metadata"',
440
- ' git push origin "HEAD:$BRANCH"',
441
- "fi",
442
- 'MERGED="false"',
443
- "for ATTEMPT in $(seq 1 15); do",
444
- ' STATE="$(gh pr view "$BRANCH" --json state,mergeable --jq \'[.state,.mergeable] | join(" ")\')"',
445
- ' if [[ "$STATE" == MERGED* ]]; then MERGED="true"; break; fi',
446
- ' if [ "$STATE" = "OPEN CONFLICTING" ]; then',
447
- ' echo "::error::release pull request has merge conflicts"',
448
- " exit 1",
449
- " fi",
450
- ' if [ "$STATE" = "OPEN MERGEABLE" ] && gh pr merge "$BRANCH" --merge; then',
451
- ' MERGED="true"',
452
- " break",
453
- " fi",
454
- ' echo "release pull request is not mergeable yet (attempt $ATTEMPT/15)"',
455
- " sleep 2",
456
- "done",
457
- 'if [ "$MERGED" != "true" ]; then',
458
- ' echo "::error::release pull request did not become mergeable"',
459
- " exit 1",
460
- "fi",
461
- 'gh workflow run release.yml --ref "$RELEASE_BRANCH" -f automatic=true',
462
- ].join("\n"),
436
+ // prettier-ignore
437
+ run: stringUtils.dedent(
438
+ // ============================================================================
439
+ /*bash*/`
440
+ BRANCH="$(jq -r '.[0].headBranchName' <<<"$RELEASE_PRS")"
441
+ test -n "$BRANCH" && test "$BRANCH" != "null"
442
+ git fetch origin "$BRANCH"
443
+ git switch --force-create "$BRANCH" "origin/$BRANCH"
444
+ bun install
445
+ bunx projen
446
+ bun node_modules/@dbx-tools/projen/tasks/version-check.ts
447
+ bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}
448
+ bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}
449
+ if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then
450
+ git config user.name "github-actions[bot]"
451
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
452
+ git add -A
453
+ git commit -m "chore: reconcile release metadata"
454
+ git push origin "HEAD:$BRANCH"
455
+ fi
456
+ MERGED="false"
457
+ for ATTEMPT in $(seq 1 15); do
458
+ STATE="$(gh pr view "$BRANCH" --json state,mergeable --jq '[.state,.mergeable] | join(" ")')"
459
+ if [[ "$STATE" == MERGED* ]]; then MERGED="true"; break; fi
460
+ if [ "$STATE" = "OPEN CONFLICTING" ]; then
461
+ echo "::error::release pull request has merge conflicts"
462
+ exit 1
463
+ fi
464
+ if [ "$STATE" = "OPEN MERGEABLE" ] && gh pr merge "$BRANCH" --merge; then
465
+ MERGED="true"
466
+ break
467
+ fi
468
+ echo "release pull request is not mergeable yet (attempt $ATTEMPT/15)"
469
+ sleep 2
470
+ done
471
+ if [ "$MERGED" != "true" ]; then
472
+ echo "::error::release pull request did not become mergeable"
473
+ exit 1
474
+ fi
475
+ gh workflow run release.yml --ref "$RELEASE_BRANCH" -f automatic=true
476
+ `
477
+ // ============================================================================
478
+ ),
463
479
  },
464
480
  ],
465
481
  };
@@ -499,22 +515,27 @@ function configureReleaseRequestWorkflow(
499
515
  name: "Read release request",
500
516
  id: "request",
501
517
  shell: "bash",
502
- run: [
503
- 'MESSAGE="$(git log -1 --format=%B)"',
504
- 'if ! grep -q "^Release-Request: true$" <<<"$MESSAGE"; then',
505
- ' echo "requested=false" >> "$GITHUB_OUTPUT"',
506
- " exit 0",
507
- "fi",
508
- `git fetch origin ${JSON.stringify(baseBranch)}`,
509
- `if git diff --quiet "origin/${baseBranch}...HEAD"; then`,
510
- ' echo "requested=false" >> "$GITHUB_OUTPUT"',
511
- " exit 0",
512
- "fi",
513
- 'echo "requested=true" >> "$GITHUB_OUTPUT"',
514
- 'echo "branch=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"',
515
- 'echo "notes_path=$(sed -n \'s/^Release-Notes-Path: //p\' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"',
516
- 'echo "title=$(git log -1 --skip=1 --format=%s)" >> "$GITHUB_OUTPUT"',
517
- ].join("\n"),
518
+ // prettier-ignore
519
+ run: stringUtils.dedent(
520
+ // ============================================================================
521
+ /*bash*/`
522
+ MESSAGE="$(git log -1 --format=%B)"
523
+ if ! grep -q "^Release-Request: true$" <<<"$MESSAGE"; then
524
+ echo "requested=false" >> "$GITHUB_OUTPUT"
525
+ exit 0
526
+ fi
527
+ git fetch origin ${JSON.stringify(baseBranch)}
528
+ if git diff --quiet "origin/${baseBranch}...HEAD"; then
529
+ echo "requested=false" >> "$GITHUB_OUTPUT"
530
+ exit 0
531
+ fi
532
+ echo "requested=true" >> "$GITHUB_OUTPUT"
533
+ echo "branch=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
534
+ echo "notes_path=$(sed -n 's/^Release-Notes-Path: //p' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"
535
+ echo "title=$(git log -1 --skip=1 --format=%s)" >> "$GITHUB_OUTPUT"
536
+ `
537
+ // ============================================================================
538
+ ),
518
539
  },
519
540
  {
520
541
  name: "Create, merge, and release source pull request",
@@ -527,20 +548,25 @@ function configureReleaseRequestWorkflow(
527
548
  NOTES_PATH: "${{ steps.request.outputs.notes_path }}",
528
549
  },
529
550
  shell: "bash",
530
- run: [
531
- 'test -f "$NOTES_PATH"',
532
- 'NOTES="$(cat "$NOTES_PATH")"',
533
- 'BODY="$(printf \'## Release notes\\n\\n%s\\n\' "$NOTES")"',
534
- 'PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number // empty\')"',
535
- 'if [ -n "$PR" ]; then',
536
- ' gh pr edit "$PR" --title "$TITLE" --body "$BODY"',
537
- "else",
538
- ' gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY"',
539
- ' PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number\')"',
540
- "fi",
541
- 'gh pr merge "$PR" --merge',
542
- 'gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$BASE_BRANCH" -f automatic=true',
543
- ].join("\n"),
551
+ // prettier-ignore
552
+ run: stringUtils.dedent(
553
+ // ============================================================================
554
+ /*bash*/`
555
+ test -f "$NOTES_PATH"
556
+ NOTES="$(cat "$NOTES_PATH")"
557
+ BODY="$(printf '## Release notes\\n\\n%s\\n' "$NOTES")"
558
+ PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[0].number // empty')"
559
+ if [ -n "$PR" ]; then
560
+ gh pr edit "$PR" --title "$TITLE" --body "$BODY"
561
+ else
562
+ gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY"
563
+ PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[0].number')"
564
+ fi
565
+ gh pr merge "$PR" --merge
566
+ gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$BASE_BRANCH" -f automatic=true
567
+ `
568
+ // ============================================================================
569
+ ),
544
570
  },
545
571
  ],
546
572
  });
@@ -672,20 +698,25 @@ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
672
698
  {
673
699
  name: "Remove consumed release notes",
674
700
  shell: "bash",
675
- run: [
676
- `git fetch origin ${JSON.stringify(projectReleaseBranch(project))}`,
677
- `if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then`,
678
- ' echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"',
679
- " exit 0",
680
- "fi",
681
- "git rm -r --ignore-unmatch .release-notes",
682
- "if ! git diff --cached --quiet; then",
683
- ' git config user.name "github-actions[bot]"',
684
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
685
- ' git commit -m "chore: remove consumed release notes"',
686
- ` git push origin "HEAD:${projectReleaseBranch(project)}"`,
687
- "fi",
688
- ].join("\n"),
701
+ // prettier-ignore
702
+ run: stringUtils.dedent(
703
+ // ============================================================================
704
+ /*bash*/`
705
+ git fetch origin ${JSON.stringify(projectReleaseBranch(project))}
706
+ if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then
707
+ echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"
708
+ exit 0
709
+ fi
710
+ git rm -r --ignore-unmatch .release-notes
711
+ if ! git diff --cached --quiet; then
712
+ git config user.name "github-actions[bot]"
713
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
714
+ git commit -m "chore: remove consumed release notes"
715
+ git push origin "HEAD:${projectReleaseBranch(project)}"
716
+ fi
717
+ `
718
+ // ============================================================================
719
+ ),
689
720
  },
690
721
  ],
691
722
  };
@@ -769,44 +800,49 @@ function addIndependentBranchSyncJob(
769
800
  name: `Safely sync ${branch}`,
770
801
  shell: "bash",
771
802
  env: { SOURCE_BRANCH: branch, RELEASE_BRANCH: releaseBranch },
772
- run: [
773
- 'if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then',
774
- ' echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"',
775
- " exit 0",
776
- "fi",
777
- 'git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"',
778
- 'git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH"',
779
- 'if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" "origin/$RELEASE_BRANCH"; then',
780
- ' git push origin "refs/remotes/origin/$RELEASE_BRANCH:refs/heads/$SOURCE_BRANCH"',
781
- 'elif git merge-base --is-ancestor "origin/$RELEASE_BRANCH" "origin/$SOURCE_BRANCH"; then',
782
- ' echo "source branch $SOURCE_BRANCH already contains released main"',
783
- "else",
784
- ' git config user.name "github-actions[bot]"',
785
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
786
- ' git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"',
787
- ' if git merge --no-ff --no-edit "origin/$RELEASE_BRANCH"; then',
788
- ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
789
- " else",
790
- ' SAFE_GENERATED="true"',
791
- " while IFS= read -r FILE; do",
792
- ' case "$FILE" in',
793
- " .projen/release-units.json|.release-please-manifest.json|.release-units/*/source.json|.release-units/*/version.txt|.release-units/*/CHANGELOG.md|Cargo.lock|*/Cargo.toml|*/package.json|*/pyproject.toml|*/index.ts) ;;",
794
- ' *) SAFE_GENERATED="false" ;;',
795
- " esac",
796
- " done < <(git diff --name-only --diff-filter=U)",
797
- ' if [ "$SAFE_GENERATED" = "true" ] && bunx projen; then',
798
- " git add -A",
799
- ' if [ -z "$(git diff --name-only --diff-filter=U)" ]; then',
800
- " git commit --no-edit",
801
- ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
802
- " exit 0",
803
- " fi",
804
- " fi",
805
- " git merge --abort",
806
- ' echo "::warning::source branch $SOURCE_BRANCH has handwritten or unresolved conflicts with released main; leaving it untouched"',
807
- " fi",
808
- "fi",
809
- ].join("\n"),
803
+ // prettier-ignore
804
+ run: stringUtils.dedent(
805
+ // ============================================================================
806
+ /*bash*/`
807
+ if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then
808
+ echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"
809
+ exit 0
810
+ fi
811
+ git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"
812
+ git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH"
813
+ if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" "origin/$RELEASE_BRANCH"; then
814
+ git push origin "refs/remotes/origin/$RELEASE_BRANCH:refs/heads/$SOURCE_BRANCH"
815
+ elif git merge-base --is-ancestor "origin/$RELEASE_BRANCH" "origin/$SOURCE_BRANCH"; then
816
+ echo "source branch $SOURCE_BRANCH already contains released main"
817
+ else
818
+ git config user.name "github-actions[bot]"
819
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
820
+ git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"
821
+ if git merge --no-ff --no-edit "origin/$RELEASE_BRANCH"; then
822
+ git push origin "HEAD:refs/heads/$SOURCE_BRANCH"
823
+ else
824
+ SAFE_GENERATED="true"
825
+ while IFS= read -r FILE; do
826
+ case "$FILE" in
827
+ .projen/release-units.json|.release-please-manifest.json|.release-units/*/source.json|.release-units/*/version.txt|.release-units/*/CHANGELOG.md|Cargo.lock|*/Cargo.toml|*/package.json|*/pyproject.toml|*/index.ts) ;;
828
+ *) SAFE_GENERATED="false" ;;
829
+ esac
830
+ done < <(git diff --name-only --diff-filter=U)
831
+ if [ "$SAFE_GENERATED" = "true" ] && bunx projen; then
832
+ git add -A
833
+ if [ -z "$(git diff --name-only --diff-filter=U)" ]; then
834
+ git commit --no-edit
835
+ git push origin "HEAD:refs/heads/$SOURCE_BRANCH"
836
+ exit 0
837
+ fi
838
+ fi
839
+ git merge --abort
840
+ echo "::warning::source branch $SOURCE_BRANCH has handwritten or unresolved conflicts with released main; leaving it untouched"
841
+ fi
842
+ fi
843
+ `
844
+ // ============================================================================
845
+ ),
810
846
  },
811
847
  ],
812
848
  });
package/tasks/sync.ts CHANGED
@@ -95,9 +95,9 @@ if (!process.argv.includes("--watch")) {
95
95
  * re-emitting it is how we say "stop" in a language the supervisor understands.
96
96
  *
97
97
  * Killing is asynchronous (concurrently shells out to `ps` to walk each process tree)
98
- * and can outlive `result`, which is how a plain SIGTERM used to leave orphaned
99
- * watchers behind. The timer holds the process open until the kills land, and doubles
100
- * as the backstop that leaves anyway if one of them wedges.
98
+ * and can outlive `result`. The timer keeps the process open until the kills
99
+ * land, which prevents orphaned watchers after SIGTERM. It also stops waiting
100
+ * if one of them wedges.
101
101
  */
102
102
  function stop(): void {
103
103
  if (stopping) return;