@dbx-tools/projen 0.9.153 → 0.9.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +44 -52
  2. package/index.ts +43 -21
  3. package/package.json +8 -8
  4. package/src/_task-command.ts +7 -7
  5. package/src/appkit-workspace.ts +278 -104
  6. package/src/barrels.ts +147 -259
  7. package/src/bun-app.ts +54 -10
  8. package/src/clean.ts +6 -6
  9. package/src/engine-root.ts +4 -4
  10. package/src/mixin.ts +0 -2
  11. package/src/module-exports.ts +4 -5
  12. package/src/npm-packaging.ts +128 -0
  13. package/src/npm-release.ts +200 -0
  14. package/src/packages.ts +11 -11
  15. package/src/project-js.ts +209 -103
  16. package/src/project-predicate.ts +5 -12
  17. package/src/project-py.ts +8 -6
  18. package/src/project.ts +7 -14
  19. package/src/publish.ts +3 -3
  20. package/src/python-node-bindings.ts +16 -12
  21. package/src/python-packaging.ts +177 -0
  22. package/src/release-context.ts +4 -0
  23. package/src/release-options.ts +2 -2
  24. package/src/release.ts +133 -13
  25. package/src/shared-core-usage.ts +17 -4
  26. package/src/tags.ts +10 -9
  27. package/src/watch.ts +7 -9
  28. package/src/workspace-lock.ts +2 -2
  29. package/src/workspace-manifest.ts +2 -2
  30. package/tasks/appkit-deploy.ts +35 -15
  31. package/tasks/barrels.ts +6 -9
  32. package/tasks/bump.ts +1 -1
  33. package/tasks/clean.ts +3 -3
  34. package/tasks/cli.ts +3 -3
  35. package/tasks/compile-workspace.ts +8 -8
  36. package/tasks/dev-watch.ts +11 -11
  37. package/tasks/local-publish.ts +6 -6
  38. package/tasks/publish-npm.ts +11 -317
  39. package/tasks/publish-python.ts +2 -180
  40. package/tasks/publish.ts +6 -11
  41. package/tasks/python-node-bindings.ts +2 -2
  42. package/tasks/python-registry.ts +6 -6
  43. package/tasks/python-sync.ts +2 -3
  44. package/tasks/release-version.ts +1 -1
  45. package/tasks/release.ts +11 -11
  46. package/tasks/sync.ts +2 -2
  47. package/tasks/test-workspace.ts +9 -9
  48. package/tasks/verify-publications.ts +273 -0
  49. package/tasks/version-check.ts +5 -5
  50. /package/{tasks → src}/python-release.ts +0 -0
package/src/project.ts CHANGED
@@ -3,18 +3,13 @@
3
3
  * dbx-tools project implementations.
4
4
  */
5
5
  import { type PathMatchInput } from "@dbx-tools/path";
6
- import { object, type OneOrMany } from "@dbx-tools/shared-core";
6
+ import { type OneOrMany, toOneOrMany } from "@dbx-tools/shared-core";
7
7
  import { type IConstruct } from "constructs";
8
8
  import { Project, type ProjectOptions } from "projen";
9
9
  import * as mixin from "./mixin.ts";
10
10
  import type { DBXToolsJavaScriptProject } from "./project-js.ts";
11
11
  import * as projectPredicate from "./project-predicate.ts";
12
12
 
13
- export * from "./project-js.ts";
14
- export * from "./project-py.ts";
15
- export * from "./python-node-bindings-component.ts";
16
- export * from "./python-node-runtime.ts";
17
-
18
13
  /** Runtime family implemented by a dbx-tools project. */
19
14
  export type DBXToolsProjectLanguage = "javascript" | "python";
20
15
 
@@ -91,29 +86,27 @@ export function applyToProjects<P extends Project>(
91
86
  if (!options?.includeRoots) predicate = predicate.and((project) => project.parent != null);
92
87
  if (options?.identifierPackageName) {
93
88
  predicate = predicate.and(
94
- projectPredicate.hasIdentifierPackageName(
95
- ...object.toOneOrMany(options.identifierPackageName),
96
- ),
89
+ projectPredicate.hasIdentifierPackageName(...toOneOrMany(options.identifierPackageName)),
97
90
  );
98
91
  }
99
92
  if (options?.name) {
100
- predicate = predicate.and(projectPredicate.hasName(...object.toOneOrMany(options.name)));
93
+ predicate = predicate.and(projectPredicate.hasName(...toOneOrMany(options.name)));
101
94
  }
102
95
  if (options?.identifierScope) {
103
96
  predicate = predicate.and(
104
- projectPredicate.hasIdentifierScope(...object.toOneOrMany(options.identifierScope)),
97
+ projectPredicate.hasIdentifierScope(...toOneOrMany(options.identifierScope)),
105
98
  );
106
99
  }
107
100
  if (options?.identifierName) {
108
101
  predicate = predicate.and(
109
- projectPredicate.hasIdentifierName(...object.toOneOrMany(options.identifierName)),
102
+ projectPredicate.hasIdentifierName(...toOneOrMany(options.identifierName)),
110
103
  );
111
104
  }
112
105
  if (options?.tags) {
113
- predicate = predicate.and(projectPredicate.hasTag(...object.toOneOrMany(options.tags)));
106
+ predicate = predicate.and(projectPredicate.hasTag(...toOneOrMany(options.tags)));
114
107
  }
115
108
  if (options?.path) {
116
- predicate = predicate.and(projectPredicate.hasPath(...object.toOneOrMany(options.path)));
109
+ predicate = predicate.and(projectPredicate.hasPath(...toOneOrMany(options.path)));
117
110
  }
118
111
  const projectMixin = mixin.create(predicate, (project) => {
119
112
  callbacks.forEach((callback) => callback(project as Project));
package/src/publish.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Two compiler options make the emitted tree loadable, both native `tsc`:
18
18
  *
19
- * - `rootDir: "."` so the package-ROOT `index.ts` barrel is compiled at all.
19
+ * - `rootDir: "."` so package-root `index*.ts` barrels are compiled at all.
20
20
  * projen's default `rootDir: "src"` puts the barrel outside the compilation,
21
21
  * which is why `lib/` would otherwise have no `index.js`.
22
22
  * - `rewriteRelativeImportExtensions`, which turns the `./x.ts` specifiers this
@@ -80,7 +80,7 @@ function compiledStem(target: string): string | undefined {
80
80
  * target that ships as-is (`./package.json`, a stylesheet, an SVG asset).
81
81
  *
82
82
  * `rootDir: "."` means the emitted tree mirrors the package layout, so the
83
- * mapping is positional: `./src/react/index.ts` -> `./lib/src/react/index.js`.
83
+ * mapping is positional: `./index.react.ts` -> `./lib/index.react.js`.
84
84
  */
85
85
  function compiledTarget(target: string): { types: string; default: string } | undefined {
86
86
  const stem = compiledStem(target);
@@ -145,7 +145,7 @@ export function applyCompiledPublish(pkg: javascript.NodeProject): void {
145
145
  if (!publishesCompiled(pkg)) return;
146
146
 
147
147
  applyCompilerOptions(pkg, COMPILED_COMPILER_OPTIONS);
148
- applyIncludes(pkg, "index.ts");
148
+ applyIncludes(pkg, "index*.ts");
149
149
  addPackageFiles(pkg, COMPILED_DIR);
150
150
 
151
151
  const config = publishConfig(pkg);
@@ -1,10 +1,11 @@
1
1
  /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
- import { find } from "@dbx-tools/path";
7
- import { object, stringUtils } from "@dbx-tools/shared-core";
7
+ import { findFilesSync } from "@dbx-tools/path";
8
+ import { isRecord, toIdentifierWithOptions } from "@dbx-tools/shared-core";
8
9
  import { parse } from "smol-toml";
9
10
  import ts from "typescript";
10
11
  import { runTaskCommand } from "./_task-command.ts";
@@ -137,7 +138,7 @@ export function resolvePythonNodeBindingModules(
137
138
  config: ResolvedPythonNodeBindings,
138
139
  ): ResolvedPythonNodeBindings {
139
140
  if (config.modules.length > 0) return config;
140
- const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
141
+ const packageEntrypoint = resolveModule(config.entrypoint, config.projectDirectory);
141
142
  const modules = publicNamespaceExports(packageEntrypoint).filter(
142
143
  (module) =>
143
144
  publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
@@ -147,7 +148,7 @@ export function resolvePythonNodeBindingModules(
147
148
 
148
149
  /** Map a JavaScript export or namespace to its generated Python identifier. */
149
150
  export function pythonNodeBindingFunctionName(javascriptName: string): string {
150
- const name = stringUtils.toIdentifierWithOptions({ delimiter: "_" }, javascriptName);
151
+ const name = toIdentifierWithOptions({ delimiter: "_" }, javascriptName);
151
152
  if (!isPythonNodeBindingIdentifier(name)) {
152
153
  throw new Error(
153
154
  `JavaScript export ${javascriptName} does not map to a valid Python function name`,
@@ -184,7 +185,7 @@ export function pythonNodeBindingInputFingerprint(
184
185
  for (const input of config.watchInputs) files.add(canonicalPath(resolve(root, input)));
185
186
  for (const { handlerFile } of config.functionOverrides) files.add(canonicalPath(handlerFile));
186
187
  const packageEntrypoint = canonicalPath(
187
- Bun.resolveSync(config.entrypoint, config.projectDirectory),
188
+ resolveModule(config.entrypoint, config.projectDirectory),
188
189
  );
189
190
  files.add(packageEntrypoint);
190
191
  const packageManifest = nearestPackageManifest(packageEntrypoint);
@@ -327,8 +328,7 @@ export function resolveAllPythonNodeBindings(
327
328
  /** Repository-relative Python projects that configure generated Node bindings. */
328
329
  export function pythonNodeBindingProjects(projectRoot: string): string[] {
329
330
  return [
330
- ...find
331
- .findFilesSync("**/pyproject.toml", { cwd: projectRoot })
331
+ ...findFilesSync("**/pyproject.toml", { cwd: projectRoot })
332
332
  .filter((file) => {
333
333
  const manifest = parse(readFileSync(resolve(projectRoot, file), "utf8")) as {
334
334
  tool?: { dbx_tools?: { node_bindings?: unknown } };
@@ -443,7 +443,7 @@ type BindingSourceConfig = Omit<ResolvedPythonNodeBindings, "watchInputs">;
443
443
 
444
444
  /** Transitive workspace source files reachable from configured binding modules. */
445
445
  function bindingSourceInputs(root: string, config: BindingSourceConfig): string[] {
446
- const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
446
+ const packageEntrypoint = resolveModule(config.entrypoint, config.projectDirectory);
447
447
  const entrypoints =
448
448
  config.modules.length > 0
449
449
  ? config.modules.map((module) => resolvePythonNodeBindingModule(packageEntrypoint, module))
@@ -479,7 +479,7 @@ function transitiveSourceFiles(
479
479
  const dependencies = ts.preProcessFile(source, true, true).importedFiles;
480
480
  for (const dependency of dependencies) {
481
481
  try {
482
- pending.push(Bun.resolveSync(dependency.fileName, dirname(candidate)));
482
+ pending.push(resolveModule(dependency.fileName, dirname(candidate)));
483
483
  } catch {
484
484
  // Built-ins and external packages are outside the workspace source graph.
485
485
  }
@@ -501,7 +501,11 @@ export function resolvePythonNodeBindingModule(entrypoint: string, module: strin
501
501
  if (!match?.[1]) {
502
502
  throw new Error(`${entrypoint} does not export namespace ${module}`);
503
503
  }
504
- return Bun.resolveSync(match[1], dirname(entrypoint));
504
+ return resolveModule(match[1], dirname(entrypoint));
505
+ }
506
+
507
+ function resolveModule(specifier: string, directory: string): string {
508
+ return createRequire(join(directory, "package.json")).resolve(specifier);
505
509
  }
506
510
 
507
511
  function canonicalPath(path: string): string {
@@ -578,11 +582,11 @@ function validateBindings(configs: readonly ResolvedPythonNodeBindings[]): void
578
582
 
579
583
  function nodePackageName(packageName: string): string {
580
584
  const unscoped = packageName.split("/").at(-1) ?? packageName;
581
- return stringUtils.toIdentifierWithOptions({ delimiter: "_" }, unscoped);
585
+ return toIdentifierWithOptions({ delimiter: "_" }, unscoped);
582
586
  }
583
587
 
584
588
  function record(value: unknown, path: string): Record<string, unknown> {
585
- if (!object.isRecord(value)) throw new Error(`${path} must be a table`);
589
+ if (!isRecord(value)) throw new Error(`${path} must be a table`);
586
590
  return value;
587
591
  }
588
592
 
@@ -0,0 +1,177 @@
1
+ /** Side-effect-free Python workspace distribution packaging. */
2
+ import {
3
+ chmodSync,
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { projectUtils } from "@dbx-tools/core";
16
+ import { parse, stringify } from "smol-toml";
17
+ import { runTaskCommand } from "./_task-command.ts";
18
+ import { preparePythonProjectForPublication, pythonProjectInfo } from "./python-release.ts";
19
+
20
+ interface PythonProjectFile {
21
+ readonly directory: string;
22
+ readonly name: string;
23
+ readonly version: string;
24
+ readonly path: string;
25
+ readonly private: boolean;
26
+ readonly source: string;
27
+ }
28
+
29
+ /** Python wheel and source archives accepted by Twine and package indexes. */
30
+ export function pythonDistributionPaths(directory: string): string[] {
31
+ return readdirSync(directory)
32
+ .filter((file) => file.endsWith(".whl") || file.endsWith(".tar.gz"))
33
+ .sort()
34
+ .map((file) => join(directory, file));
35
+ }
36
+
37
+ function removeTemporaryTree(root: string): void {
38
+ if (!existsSync(root)) return;
39
+ const makeWritable = (directory: string): void => {
40
+ chmodSync(directory, 0o700);
41
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
42
+ if (entry.isDirectory()) makeWritable(join(directory, entry.name));
43
+ }
44
+ };
45
+ makeWritable(root);
46
+ rmSync(root, { recursive: true, force: true });
47
+ }
48
+
49
+ function pythonProjects(root: string): PythonProjectFile[] {
50
+ const packageFiles = readdirSync(root, { withFileTypes: true })
51
+ .filter((entry) => entry.isDirectory())
52
+ .map((entry) => resolve(root, entry.name, "pyproject.toml"))
53
+ .filter(existsSync)
54
+ .sort();
55
+ const allProjects: PythonProjectFile[] = packageFiles.map((path) => {
56
+ const source = readFileSync(path, "utf8");
57
+ const info = pythonProjectInfo(source, { parse });
58
+ return {
59
+ directory: basename(resolve(path, "..")),
60
+ name: info.name,
61
+ version: info.version,
62
+ path,
63
+ private: info.private,
64
+ source,
65
+ };
66
+ });
67
+ return allProjects;
68
+ }
69
+
70
+ /** Build and upload every selected public Python workspace distribution. */
71
+ export function publishPythonProjects(options: {
72
+ readonly dryRun?: boolean;
73
+ readonly indexUrl: string;
74
+ readonly publishUrl: string;
75
+ readonly root: string;
76
+ readonly version: string;
77
+ }): void {
78
+ const output = mkdtempSync(join(tmpdir(), "projen-python-publish-"));
79
+ try {
80
+ buildPythonProjectsInternal({ ...options, output }, true);
81
+ runTaskCommand(
82
+ process.cwd(),
83
+ "uvx",
84
+ [
85
+ "--from",
86
+ "devpi-client",
87
+ "devpi",
88
+ "upload",
89
+ "--index",
90
+ options.publishUrl,
91
+ "--from-dir",
92
+ ...(options.dryRun ? ["--dry-run"] : []),
93
+ output,
94
+ ],
95
+ { env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl } },
96
+ );
97
+ } finally {
98
+ removeTemporaryTree(output);
99
+ }
100
+ }
101
+
102
+ /** Build the exact Python release distributions without publishing them. */
103
+ export function buildPythonProjects(options: {
104
+ readonly allowEmpty?: boolean;
105
+ readonly output: string;
106
+ readonly packages?: readonly string[];
107
+ /** Place each package's artifacts in its directory beneath output. */
108
+ readonly packageDirectories?: boolean;
109
+ readonly root: string;
110
+ readonly version: string;
111
+ }): void {
112
+ buildPythonProjectsInternal(options, false);
113
+ }
114
+
115
+ function buildPythonProjectsInternal(
116
+ options: Parameters<typeof buildPythonProjects>[0],
117
+ allowOutputOutsideWorkspace: boolean,
118
+ ): void {
119
+ const root = resolve(options.root);
120
+ const output = resolve(options.output);
121
+ const workspace = projectUtils.root(root) ?? dirname(root);
122
+ const outputPath = relative(workspace, output);
123
+ if (
124
+ !allowOutputOutsideWorkspace &&
125
+ (!outputPath || outputPath.startsWith("..") || isAbsolute(outputPath))
126
+ ) {
127
+ throw new Error("Python release output must be a non-root directory inside the workspace");
128
+ }
129
+ rmSync(output, { recursive: true, force: true });
130
+ const allProjects = pythonProjects(root);
131
+ const requested = new Set(options.packages ?? []);
132
+ const missing = [...requested].filter(
133
+ (directory) => !allProjects.some((project) => project.directory === directory),
134
+ );
135
+ if (missing.length > 0) throw new Error(`Unknown Python packages: ${missing.join(", ")}`);
136
+ const projects = allProjects.filter(
137
+ (project) => !project.private && (requested.size === 0 || requested.has(project.directory)),
138
+ );
139
+ if (projects.length === 0 && options.allowEmpty) {
140
+ mkdirSync(output, { recursive: true });
141
+ return;
142
+ }
143
+ if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
144
+
145
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "projen-python-release-"));
146
+ try {
147
+ mkdirSync(output, { recursive: true });
148
+ for (const project of projects) {
149
+ const packageRoot = join(temporaryRoot, project.directory);
150
+ cpSync(dirname(project.path), packageRoot, { recursive: true });
151
+ const manifestPath = join(packageRoot, "pyproject.toml");
152
+ chmodSync(manifestPath, 0o644);
153
+ writeFileSync(
154
+ manifestPath,
155
+ preparePythonProjectForPublication(project.source, {
156
+ packages: allProjects,
157
+ toml: { parse, stringify },
158
+ version: options.version,
159
+ }),
160
+ );
161
+ const packageOutput = options.packageDirectories ? join(output, project.directory) : output;
162
+ runTaskCommand(root, "uv", ["build", "--out-dir", packageOutput, packageRoot]);
163
+ if (pythonDistributionPaths(packageOutput).length === 0) {
164
+ throw new Error(`No Python distributions found for ${project.name}`);
165
+ }
166
+ }
167
+ const distributions = options.packageDirectories
168
+ ? projects.flatMap((project) => pythonDistributionPaths(join(output, project.directory)))
169
+ : pythonDistributionPaths(output);
170
+ if (distributions.length === 0) {
171
+ throw new Error(`No Python distributions found in ${output}`);
172
+ }
173
+ runTaskCommand(process.cwd(), "uvx", ["twine", "check", ...distributions]);
174
+ } finally {
175
+ removeTemporaryTree(temporaryRoot);
176
+ }
177
+ }
@@ -3,3 +3,7 @@
3
3
  export const RELEASE_VERSION = "${{ needs.build-release.outputs.release_version }}";
4
4
  /** Generated GitHub Actions workflow triggered by release tags. */
5
5
  export const RELEASE_WORKFLOW_FILE = "release.yml";
6
+ /** Public npm registry queried after npm publication completes. */
7
+ export const PUBLIC_NPM_REGISTRY_URL = "https://registry.npmjs.org";
8
+ /** Public PyPI JSON API queried after Python publication completes. */
9
+ export const PUBLIC_PYPI_REGISTRY_URL = "https://pypi.org/pypi";
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module
9
9
  */
10
- import { json } from "@dbx-tools/shared-core";
10
+ import { parseRecord } from "@dbx-tools/shared-core";
11
11
  import { z } from "zod";
12
12
 
13
13
  /** Publication scopes accepted by the release task; auto preserves normal publication. */
@@ -70,7 +70,7 @@ export function parseReleaseTagAnnotation(annotation: string): ReleaseStepSelect
70
70
  const selections = annotation.split("\n").filter((line) => line.startsWith(ANNOTATION_MARKER));
71
71
  if (selections.length === 0) return releaseStepSelection();
72
72
  if (selections.length !== 1) throw new Error("Release tag contains multiple step selections");
73
- const value = json.parseRecord(selections[0]!.slice(ANNOTATION_MARKER.length));
73
+ const value = parseRecord(selections[0]!.slice(ANNOTATION_MARKER.length));
74
74
  const parsed = ReleaseStepSelectionSchema.safeParse(value);
75
75
  if (!parsed.success) throw new Error("Invalid release tag step selection");
76
76
  return parsed.data;
package/src/release.ts CHANGED
@@ -10,18 +10,23 @@
10
10
  * jobs download them. `DBXToolsRelease` therefore remains separate while native
11
11
  * workflow and project primitives own the YAML model and setup steps.
12
12
  */
13
- import { stringUtils } from "@dbx-tools/shared-core";
13
+ import { dedent } from "@dbx-tools/shared-core";
14
14
  import { Component, github } from "projen";
15
15
  import { GithubWorkflow } from "projen/lib/github";
16
16
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
17
17
  import { BUN_VERSION } from "./bun-workflow.ts";
18
18
  import { projectReleaseBranch, taskCommand, type DBXToolsJavaScriptProject } from "./project-js.ts";
19
- import { RELEASE_VERSION, RELEASE_WORKFLOW_FILE } from "./release-context.ts";
19
+ import {
20
+ PUBLIC_NPM_REGISTRY_URL,
21
+ RELEASE_VERSION,
22
+ RELEASE_WORKFLOW_FILE,
23
+ } from "./release-context.ts";
20
24
  import type { ReleaseStepSelection } from "./release-options.ts";
21
25
 
22
26
  const NODE_VERSION = "24";
23
27
  const NPM_VERSION = "11.4.2";
24
- const NPM_REGISTRY_URL = "https://registry.npmjs.org";
28
+ const PUBLICATION_VERIFIER_ARTIFACT = "release-publication-verifier";
29
+ const PUBLICATION_VERIFIER_JOB = "verify-publications";
25
30
  const releaseTagPrefixes = new WeakMap<DBXToolsJavaScriptProject, string>();
26
31
  const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>();
27
32
 
@@ -95,7 +100,7 @@ export function nodeReleaseSetupSteps(): readonly JobStep[] {
95
100
  uses: github.ActionRefs.ACTIONS_SETUP_NODE,
96
101
  with: {
97
102
  "node-version": NODE_VERSION,
98
- "registry-url": NPM_REGISTRY_URL,
103
+ "registry-url": PUBLIC_NPM_REGISTRY_URL,
99
104
  "package-manager-cache": false,
100
105
  },
101
106
  },
@@ -114,19 +119,14 @@ export function uvSetupStep(): JobStep {
114
119
  function refreshDocsRegistryDependencies(workflow: GithubWorkflow): void {
115
120
  const docs = workflow.getJob("deploy-docs") as Job | undefined;
116
121
  if (!docs) return;
117
- const registryJobs = Object.keys(workflow.jobs).filter(
118
- (name) => name === "publish-node" || name.startsWith("publish-pypi-"),
119
- );
120
- if (registryJobs.length > 0) {
122
+ if (workflow.getJob(PUBLICATION_VERIFIER_JOB)) {
121
123
  workflow.updateJob("deploy-docs", {
122
124
  ...docs,
123
- needs: ["build-release", ...registryJobs],
125
+ needs: ["build-release", PUBLICATION_VERIFIER_JOB],
124
126
  if: releaseCondition([
125
127
  "needs['build-release'].result == 'success'",
126
128
  "needs['build-release'].outputs.docs == 'true'",
127
- ...registryJobs.map(
128
- (name) => `needs['${name}'].result == 'success' || needs['${name}'].result == 'skipped'`,
129
- ),
129
+ `needs['${PUBLICATION_VERIFIER_JOB}'].result == 'success' || needs['${PUBLICATION_VERIFIER_JOB}'].result == 'skipped'`,
130
130
  ]),
131
131
  });
132
132
  }
@@ -140,6 +140,107 @@ export function refreshReleaseDocsDependencies(project: DBXToolsJavaScriptProjec
140
140
  }
141
141
  }
142
142
 
143
+ function publicationRegistryJobs(workflow: GithubWorkflow): string[] {
144
+ return Object.keys(workflow.jobs).filter(
145
+ (name) => name === "publish-node" || name.startsWith("publish-pypi-"),
146
+ );
147
+ }
148
+
149
+ /** Keep one public availability barrier behind every configured registry publisher. */
150
+ export function refreshPublicationVerification(project: DBXToolsJavaScriptProject): void {
151
+ const workflow = releaseWorkflows.get(project);
152
+ if (!workflow) return;
153
+ const registryJobs = publicationRegistryJobs(workflow);
154
+ if (registryJobs.length === 0) return;
155
+ const hasNpm = registryJobs.includes("publish-node");
156
+ const hasPypi = registryJobs.some((name) => name.startsWith("publish-pypi-"));
157
+ const selected = [
158
+ ...(hasNpm ? ["needs['build-release'].outputs.npm == 'true'"] : []),
159
+ ...(hasPypi ? ["needs['build-release'].outputs.pypi == 'true'"] : []),
160
+ ].join(" || ");
161
+ const prerequisites = registryJobs.map((name) => {
162
+ const output = name === "publish-node" ? "npm" : "pypi";
163
+ return `needs['build-release'].outputs.${output} != 'true' || needs['${name}'].result == 'success'`;
164
+ });
165
+ const job: Job = {
166
+ if: releaseCondition([
167
+ "needs['build-release'].result == 'success'",
168
+ selected,
169
+ ...prerequisites,
170
+ ]),
171
+ needs: ["build-release", ...registryJobs],
172
+ runsOn: ["ubuntu-latest"],
173
+ permissions: { contents: JobPermission.READ },
174
+ timeoutMinutes: 15,
175
+ steps: [
176
+ {
177
+ name: "Download publication verifier",
178
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
179
+ with: { name: PUBLICATION_VERIFIER_ARTIFACT, path: ".release/verifier" },
180
+ },
181
+ ...(hasNpm
182
+ ? [
183
+ {
184
+ name: "Download npm archives",
185
+ if: "${{ needs.build-release.outputs.npm == 'true' }}",
186
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
187
+ with: { name: "release-npm", path: ".release/npm" },
188
+ },
189
+ ]
190
+ : []),
191
+ ...(hasPypi
192
+ ? [
193
+ {
194
+ name: "Download Python distributions",
195
+ if: "${{ needs.build-release.outputs.pypi == 'true' }}",
196
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
197
+ with: {
198
+ pattern: "release-python-*",
199
+ path: ".release/python",
200
+ "merge-multiple": true,
201
+ },
202
+ },
203
+ ]
204
+ : []),
205
+ {
206
+ name: "Setup Bun",
207
+ uses: github.ActionRefs.OVEN_SH_SETUP_BUN,
208
+ with: { "bun-version": BUN_VERSION },
209
+ },
210
+ {
211
+ name: "Wait for public package availability",
212
+ shell: "bash",
213
+ env: {
214
+ RELEASE_VERSION,
215
+ VERIFY_NPM: hasNpm ? "${{ needs.build-release.outputs.npm }}" : "false",
216
+ VERIFY_PYPI: hasPypi ? "${{ needs.build-release.outputs.pypi }}" : "false",
217
+ },
218
+ // prettier-ignore
219
+ run: dedent(
220
+ // ============================================================================
221
+ /*bash*/`
222
+ args=(--version "$RELEASE_VERSION")
223
+ if [[ "$VERIFY_NPM" == "true" ]]; then
224
+ args+=(--npm-directory .release/npm)
225
+ fi
226
+ if [[ "$VERIFY_PYPI" == "true" ]]; then
227
+ args+=(--pypi-directory .release/python)
228
+ fi
229
+ bun .release/verifier/verify-publications.mjs "\${args[@]}"
230
+ `
231
+ // ============================================================================
232
+ ),
233
+ },
234
+ ],
235
+ };
236
+ if (workflow.getJob(PUBLICATION_VERIFIER_JOB)) {
237
+ workflow.updateJob(PUBLICATION_VERIFIER_JOB, job);
238
+ } else {
239
+ workflow.addJob(PUBLICATION_VERIFIER_JOB, job);
240
+ }
241
+ refreshDocsRegistryDependencies(workflow);
242
+ }
243
+
143
244
  function releaseBuildJob(
144
245
  project: DBXToolsJavaScriptProject,
145
246
  tagPrefix: string,
@@ -181,7 +282,7 @@ function releaseBuildJob(
181
282
  RELEASE_TAG: "${{ github.ref_name }}",
182
283
  },
183
284
  // prettier-ignore
184
- run: stringUtils.dedent(
285
+ run: dedent(
185
286
  // ============================================================================
186
287
  /*bash*/`
187
288
  case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac
@@ -229,6 +330,24 @@ function releaseBuildJob(
229
330
  with: { name: "release-npm", path: ".release/npm", "if-no-files-found": "error" },
230
331
  },
231
332
  ])),
333
+ {
334
+ name: "Build publication verifier",
335
+ if: "${{ success() && (steps.release.outputs.npm == 'true' || steps.release.outputs.pypi == 'true') }}",
336
+ run: [
337
+ "mkdir -p .release/verifier",
338
+ "bun build node_modules/@dbx-tools/projen/tasks/verify-publications.ts --target=bun --outfile=.release/verifier/verify-publications.mjs",
339
+ ].join("\n"),
340
+ },
341
+ {
342
+ name: "Upload publication verifier",
343
+ if: "${{ success() && (steps.release.outputs.npm == 'true' || steps.release.outputs.pypi == 'true') }}",
344
+ uses: github.ActionRefs.ACTIONS_UPLOAD_ARTIFACT,
345
+ with: {
346
+ name: PUBLICATION_VERIFIER_ARTIFACT,
347
+ path: ".release/verifier/verify-publications.mjs",
348
+ "if-no-files-found": "error",
349
+ },
350
+ },
232
351
  ...(options.docs
233
352
  ? releaseBuildSteps("docs", [
234
353
  ...options.docs.prepareSteps,
@@ -356,6 +475,7 @@ export class DBXToolsRelease extends Component {
356
475
  if (options.nodeRelease !== false) {
357
476
  workflow.addJob("publish-node", nodePublishJob());
358
477
  }
478
+ refreshPublicationVerification(project);
359
479
  if (options.docs) {
360
480
  addDocsJobs(workflow);
361
481
  }
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join, relative, resolve } from "node:path";
10
- import { find } from "@dbx-tools/path";
10
+ import { findFiles } from "@dbx-tools/path";
11
11
  import * as core from "@dbx-tools/shared-core";
12
- import { PACKAGE_IDENTIFIER, object } from "@dbx-tools/shared-core";
12
+ import { PACKAGE_IDENTIFIER, sequence } from "@dbx-tools/shared-core";
13
13
  import { PYTHON_GENERATED_PACKAGE } from "./generated.ts";
14
14
  import { readPackageManifest, recordedPackages, repoRoot } from "./packages.ts";
15
15
 
@@ -33,6 +33,7 @@ export const SHARED_CORE_BINDING_EXPORTS = [
33
33
 
34
34
  const SOURCE_PATTERN = "**/*.{ts,tsx,js,mjs}";
35
35
  const NAMESPACE_EXPORT = /^export \* as (\w+) from "\.\/src\/([^"]+)\.ts";$/gm;
36
+ const ROOT_NAMED_IMPORT = /import\s*\{([\s\S]*?)\}\s*from\s*["']@dbx-tools\/shared-core["']/g;
36
37
  const LOCAL_OWNED_HELPER =
37
38
  /(?:export\s+)?(?:async\s+)?function(?:\s*\*)?\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*\(|(?:export\s+)?const\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*=/g;
38
39
 
@@ -70,7 +71,7 @@ async function sourceFiles(root: string, includeGenerated: boolean): Promise<str
70
71
  ];
71
72
  if (!includeGenerated) ignore.push(`**/${PYTHON_GENERATED_PACKAGE}/**`);
72
73
  const files: string[] = [];
73
- for await (const file of find.findFiles(SOURCE_PATTERN, { cwd: root, ignore })) {
74
+ for await (const file of findFiles(SOURCE_PATTERN, { cwd: root, ignore })) {
74
75
  files.push(resolve(root, file));
75
76
  }
76
77
  return files.sort();
@@ -109,6 +110,18 @@ function mentionsExport(
109
110
  moduleFile: string,
110
111
  ): boolean {
111
112
  if (source.includes(`${namespace}.${name}`)) return true;
113
+ ROOT_NAMED_IMPORT.lastIndex = 0;
114
+ for (const match of source.matchAll(ROOT_NAMED_IMPORT)) {
115
+ const specifiers = match[1]!.split(",").map((specifier) => specifier.trim());
116
+ if (
117
+ specifiers.some((specifier) => {
118
+ if (!specifier || specifier.startsWith("type ")) return false;
119
+ return specifier.split(/\s+as\s+/u, 1)[0] === name;
120
+ })
121
+ ) {
122
+ return true;
123
+ }
124
+ }
112
125
  const imported =
113
126
  source.includes(`from "@dbx-tools/shared-core/${moduleFile}"`) ||
114
127
  source.includes(`from "./${moduleFile}.ts"`) ||
@@ -173,7 +186,7 @@ export async function duplicatedOwnedHelpers(root: string = repoRoot): Promise<s
173
186
 
174
187
  /** Render a compact usage table for `shared-core:usage`. */
175
188
  export function formatSharedCoreUsage(usage: readonly SharedCoreExportUsage[]): string {
176
- const counts = object.sequence(usage).group({
189
+ const counts = sequence(usage).group({
177
190
  production: (row) => row.kind === "production",
178
191
  binding: (row) => row.kind === "binding",
179
192
  internal: (row) => row.kind === "internal",