@dbx-tools/projen 0.9.158 → 0.9.161

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
@@ -340,9 +340,12 @@ to `appkit:deploy` unless the repository already owns `deploy`. The AppKit task
340
340
  runs `databricks apps deploy --skip-validation` with all forwarded arguments
341
341
  from an isolated source snapshot, so the Databricks CLI never runs npm locally.
342
342
  Because the remote builder selects npm when no portable pnpm lockfile is
343
- uploaded, the snapshot replaces `catalog:` and `workspace:` dependency protocols
344
- with concrete versions and keeps every manifest stable through upload. The
345
- generated working tree is never modified. The task passes an explicit profile,
343
+ uploaded, the snapshot replaces `catalog:` protocols with concrete versions.
344
+ When the application uses `workspace:` dependencies, the task packs their
345
+ transitive runtime workspace closure and links those archives from the staged
346
+ application, so deployment does not wait for a matching public release. Every
347
+ source manifest stays stable through upload and the generated working tree is
348
+ never modified. The task passes an explicit profile,
346
349
  using the supplied flag or resolving one through `@dbx-tools/auth`, and fails
347
350
  before staging when neither profile nor complete environment credentials are
348
351
  available. It validates the bundle strictly, removes only stale direct-engine
@@ -574,9 +577,10 @@ the current branch is pushed first. A non-`main` branch then fast-forwards
574
577
  diverged. From `main`, it calls the existing `bump` task, commits the generated
575
578
  version changes, pushes `main`, and pushes the matching annotated `vX.Y.Z` tag.
576
579
  Pass `--no-bump` only to release an existing synchronized local bump. The tag
577
- workflow verifies that the tag points at the exact `origin/main` commit. The
578
- local task completes local publication and demo deployment first, then waits for
579
- this workflow by default. It prints the initial job and step state, then only
580
+ workflow verifies that the tag points at the exact `origin/main` commit. A newer
581
+ tag cancels the previous release run, including leftover publish, wait, and docs
582
+ jobs. The local task completes local publication and demo deployment first, then
583
+ waits for this workflow by default. It prints the initial job and step state, then only
580
584
  append-only status changes, without annotations. Successful runs omit full logs;
581
585
  unsuccessful runs print failed-step logs only. Local-only releases and
582
586
  repositories without a resolved authenticated GitHub account skip the watcher.
@@ -590,8 +594,10 @@ site. Install validation prerequisites through `releaseSetupSteps`.
590
594
  Publication jobs download the resulting artifacts rather than rebuilding the
591
595
  workspace. Each Python package retains its own publishing environment and waits
592
596
  for its package dependencies to publish. The npm job uses the bundled archive
593
- publisher without a checkout or workspace install. After all selected public
594
- publishers finish, one verifier downloads the complete release artifact set and
597
+ publisher without a checkout or workspace install. If npm reports that a version
598
+ is already staged, that publisher waits for the public artifact instead of
599
+ publishing the version again. After all selected public publishers finish, one
600
+ verifier downloads the complete release artifact set and
595
601
  polls only unresolved packages concurrently. It validates npm tarball contents
596
602
  and PyPI distribution hashes before Pages deploys the already built site. Local
597
603
  npm and Python registry publication remains available through the direct local
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.158";
6
+ export const PACKAGE_VERSION = "0.9.161";
7
7
  export * as appkitWorkspace from "./src/appkit-workspace.ts";
8
8
  export * as barrels from "./src/barrels.ts";
9
9
  export * as bunApp from "./src/bun-app.ts";
@@ -41,6 +41,7 @@ export * as vscode from "./src/vscode.ts";
41
41
  export * as watch from "./src/watch.ts";
42
42
  export * as workspaceLock from "./src/workspace-lock.ts";
43
43
  export * as workspaceManifest from "./src/workspace-manifest.ts";
44
+ export * as workspaceVendor from "./src/workspace-vendor.ts";
44
45
  export * as workspaceVersion from "./src/workspace-version.ts";
45
46
  export { isAppKitApplication, AppKitWorkspace } from "./src/appkit-workspace.ts";
46
47
  export type { AppKitWorkspaceMode, AppKitBundleOptions, AppKitWorkspaceOptions } from "./src/appkit-workspace.ts";
@@ -61,8 +62,8 @@ export { moduleStatements, moduleExports, publicFunctionExports, publicNamespace
61
62
  export type { ModuleStatement, ModuleExport, PublicFunctionExport } from "./src/module-exports.ts";
62
63
  export { applyPublishConfig, packNpmPackage } from "./src/npm-packaging.ts";
63
64
  export type { NpmArchiveManifestTransform } from "./src/npm-packaging.ts";
64
- export { npmReleaseMatches, publishedNpmRelease, readNpmArchiveManifest, readNpmArchiveIdentity, npmArchiveContentDigest } from "./src/npm-release.ts";
65
- export type { NpmReleaseIdentity, NpmArchiveManifest } from "./src/npm-release.ts";
65
+ export { npmReleaseMatches, publishedNpmRelease, isNpmStagedVersionConflict, waitForMatchingNpmRelease, publishNpmArchive, readNpmArchiveManifest, readNpmArchiveIdentity, npmArchiveContentDigest } from "./src/npm-release.ts";
66
+ export type { NpmReleaseIdentity, NpmArchiveManifest, NpmPublishOutcome } from "./src/npm-release.ts";
66
67
  export { resolveRepoRoot, repoRoot, DEFAULT_PACKAGE_ROOTS, projectName, toPosix, isGeneratedFile, isModuleFile, DiscoveredPackage, scanPackages, readPackageManifest, readDbxToolsConfig, syncResynthPaths, syncWatchTasks, recordedPackages, workspaceDependencyDirectories, recordedRoots } from "./src/packages.ts";
67
68
  export type { RecordedPackage } from "./src/packages.ts";
68
69
  export { PnpmWorkspaceState } from "./src/pnpm-workspace.ts";
@@ -84,7 +85,7 @@ export { PYTHON_NODE_RUNTIME_DISTRIBUTION, PYTHON_NODE_RUNTIME_MODULE, PYTHON_NO
84
85
  export type { PythonNodeRuntimeOptions } from "./src/python-node-runtime.ts";
85
86
  export { pythonDistributionPaths, publishPythonProjects, buildPythonProjects } from "./src/python-packaging.ts";
86
87
  export { pythonProjectInfo, preparePythonProjectForPublication } from "./src/python-release.ts";
87
- export { RELEASE_VERSION, RELEASE_WORKFLOW_FILE, PUBLIC_NPM_REGISTRY_URL, PUBLIC_PYPI_REGISTRY_URL } from "./src/release-context.ts";
88
+ export { RELEASE_VERSION, RELEASE_WORKFLOW_FILE, PUBLIC_NPM_REGISTRY_URL, PUBLIC_PYPI_REGISTRY_URL, PUBLICATION_WAIT_TIMEOUT_SECONDS, PUBLICATION_WAIT_INTERVAL_SECONDS } from "./src/release-context.ts";
88
89
  export { RELEASE_PUBLISH_TARGETS, RELEASE_INSTALL_MODES, ReleaseStepSelectionSchema, releaseStepSelection, releaseTagAnnotation, parseReleaseTagAnnotation, releasePublishesLocally } from "./src/release-options.ts";
89
90
  export type { ReleasePublishTarget, ReleaseInstallMode, ReleaseStepSelection, ReleaseSelectionOptions } from "./src/release-options.ts";
90
91
  export { tryReleaseWorkflow, releaseTagPattern, releaseCondition, releaseBuildSteps, nodeReleaseSetupSteps, uvSetupStep, refreshReleaseDocsDependencies, refreshPublicationVerification, DBXToolsRelease } from "./src/release.ts";
@@ -100,7 +101,9 @@ export { configureVsCode } from "./src/vscode.ts";
100
101
  export { watchRoots, watchLoop } from "./src/watch.ts";
101
102
  export type { IgnoreGroupOptions, WatchLoopOptions } from "./src/watch.ts";
102
103
  export { withWorkspaceMutationLock } from "./src/workspace-lock.ts";
103
- export type { WorkspaceMutationLockOptions } from "./src/workspace-lock.ts";
104
+ export type { WorkspaceMutationLockTimer, WorkspaceMutationLockScheduler, WorkspaceMutationLockOptions } from "./src/workspace-lock.ts";
104
105
  export { materializeWorkspaceManifest, withMaterializedWorkspaceManifests, withStagedWorkspaceManifests } from "./src/workspace-manifest.ts";
106
+ export { vendorWorkspaceDependencies } from "./src/workspace-vendor.ts";
107
+ export type { VendoredWorkspaceDependencies } from "./src/workspace-vendor.ts";
105
108
  export { VERSION_FILE, DEFAULT_VERSION, parseSemver, compareSemver, incrementSemver, versionPath, readWorkspaceVersion, writeWorkspaceVersion, syncWorkspaceManifestVersion, resolveNextVersion } from "./src/workspace-version.ts";
106
109
  export type { Semver, VersionLevel } from "./src/workspace-version.ts";
package/package.json CHANGED
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/auth": "^0.9.158",
34
- "@dbx-tools/cli-args": "^0.9.158",
35
- "@dbx-tools/core": "^0.9.158",
36
- "@dbx-tools/path": "^0.9.158",
37
- "@dbx-tools/shared-core": "^0.9.158",
33
+ "@dbx-tools/auth": "^0.9.161",
34
+ "@dbx-tools/cli-args": "^0.9.161",
35
+ "@dbx-tools/core": "^0.9.161",
36
+ "@dbx-tools/path": "^0.9.161",
37
+ "@dbx-tools/shared-core": "^0.9.161",
38
38
  "commander": "^15.0.0",
39
39
  "concurrently": "^10.0.3",
40
40
  "constructs": "^10.6.0",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "main": "index.ts",
58
58
  "license": "Apache-2.0",
59
- "version": "0.9.158",
59
+ "version": "0.9.161",
60
60
  "types": "index.ts",
61
61
  "type": "module",
62
62
  "exports": {
@@ -11,8 +11,15 @@ import {
11
11
  } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join, relative } from "node:path";
14
- import { spawnSync } from "@dbx-tools/core";
15
- import { PUBLIC_NPM_REGISTRY_URL } from "./release-context.ts";
14
+ import { spawn, spawnSync } from "@dbx-tools/core";
15
+ import { log, poll, toError } from "@dbx-tools/shared-core";
16
+ import {
17
+ PUBLIC_NPM_REGISTRY_URL,
18
+ PUBLICATION_WAIT_INTERVAL_SECONDS,
19
+ PUBLICATION_WAIT_TIMEOUT_SECONDS,
20
+ } from "./release-context.ts";
21
+
22
+ const logger = log.logger("projen:npm-release");
16
23
 
17
24
  /** Registry and archive identity used to verify idempotent npm publication. */
18
25
  export interface NpmReleaseIdentity {
@@ -72,6 +79,7 @@ export async function publishedNpmRelease(
72
79
  version: string,
73
80
  registry = process.env.NPM_CONFIG_REGISTRY ?? PUBLIC_NPM_REGISTRY_URL,
74
81
  signal?: AbortSignal,
82
+ options: { readonly content?: boolean } = {},
75
83
  ): Promise<NpmReleaseIdentity | undefined> {
76
84
  const response = await fetch(registryUrl(registry, name, version), {
77
85
  headers: { accept: "application/json" },
@@ -91,7 +99,7 @@ export async function publishedNpmRelease(
91
99
  throw new Error(`npm registry returned an incomplete identity for ${name}@${version}`);
92
100
  }
93
101
  let contentDigest: string | undefined;
94
- if (metadata.dist?.tarball) {
102
+ if (options.content !== false && metadata.dist?.tarball) {
95
103
  const archive = await fetch(metadata.dist.tarball, { signal });
96
104
  if (!archive.ok) {
97
105
  throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
@@ -114,6 +122,106 @@ export async function publishedNpmRelease(
114
122
  };
115
123
  }
116
124
 
125
+ /** Whether npm refused a retry because it already accepted this version for staging. */
126
+ export function isNpmStagedVersionConflict(error: unknown): boolean {
127
+ const message = toError(error).message;
128
+ return /409\b/.test(message) && /previously staged version/i.test(message);
129
+ }
130
+
131
+ /** Wait until the public registry serves the exact local npm archive. */
132
+ export async function waitForMatchingNpmRelease(
133
+ local: NpmReleaseIdentity,
134
+ registry?: string,
135
+ options: { readonly intervalMs?: number; readonly timeoutMs?: number } = {},
136
+ ): Promise<void> {
137
+ const selectedRegistry = registry ?? process.env.NPM_CONFIG_REGISTRY ?? PUBLIC_NPM_REGISTRY_URL;
138
+ try {
139
+ for await (const _available of poll(
140
+ async ({ signal }) => {
141
+ const published = await publishedNpmRelease(
142
+ local.name,
143
+ local.version,
144
+ selectedRegistry,
145
+ signal,
146
+ { content: false },
147
+ );
148
+ if (!published) return false;
149
+ return npmReleaseMatches(
150
+ local,
151
+ await publishedNpmRelease(local.name, local.version, selectedRegistry, signal),
152
+ );
153
+ },
154
+ {
155
+ intervalMs: options.intervalMs ?? PUBLICATION_WAIT_INTERVAL_SECONDS * 1_000,
156
+ timeoutMs: options.timeoutMs ?? PUBLICATION_WAIT_TIMEOUT_SECONDS * 1_000,
157
+ predicate: (available) => !available,
158
+ },
159
+ )) {
160
+ void _available;
161
+ }
162
+ } catch (error) {
163
+ throw new Error(
164
+ `Staged npm version ${local.name}@${local.version} did not become available`,
165
+ { cause: error },
166
+ );
167
+ }
168
+ }
169
+
170
+ /** Outcome of one idempotent npm archive publication. */
171
+ export type NpmPublishOutcome = "published" | "skipped" | "staged";
172
+
173
+ /** Publish one archive, or wait when npm has already staged that exact version. */
174
+ export async function publishNpmArchive(options: {
175
+ readonly archive: string;
176
+ readonly identity: NpmReleaseIdentity;
177
+ readonly cwd?: string;
178
+ readonly dryRun?: boolean;
179
+ readonly env?: NodeJS.ProcessEnv;
180
+ readonly intervalMs?: number;
181
+ readonly registry?: string;
182
+ readonly timeoutMs?: number;
183
+ }): Promise<NpmPublishOutcome> {
184
+ const { archive, identity } = options;
185
+ if (!options.dryRun) {
186
+ const published = await publishedNpmRelease(identity.name, identity.version, options.registry);
187
+ if (npmReleaseMatches(identity, published)) return "skipped";
188
+ }
189
+ const result = await spawn(
190
+ "npm",
191
+ [
192
+ "publish",
193
+ archive,
194
+ "--ignore-scripts",
195
+ ...(identity.access ? ["--access", identity.access] : []),
196
+ ...(options.registry ? ["--registry", options.registry] : []),
197
+ ...(options.dryRun ? ["--dry-run"] : []),
198
+ ],
199
+ {
200
+ cwd: options.cwd ?? process.cwd(),
201
+ env: options.env,
202
+ stdin: "ignore",
203
+ stdout: "inherit",
204
+ stderr: "capture",
205
+ check: false,
206
+ },
207
+ );
208
+ if (result.stderr) process.stderr.write(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
209
+ if (result.exitCode === 0) return "published";
210
+ const error = new Error(
211
+ `npm publish failed (exit ${result.exitCode})${result.stderr ? `: ${result.stderr}` : ""}`,
212
+ );
213
+ if (options.dryRun) throw error;
214
+ const published = await publishedNpmRelease(identity.name, identity.version, options.registry);
215
+ if (npmReleaseMatches(identity, published)) return "skipped";
216
+ if (!isNpmStagedVersionConflict(error)) throw error;
217
+ logger.info(`waiting for staged ${identity.name}@${identity.version}`);
218
+ await waitForMatchingNpmRelease(identity, options.registry, {
219
+ intervalMs: options.intervalMs,
220
+ timeoutMs: options.timeoutMs,
221
+ });
222
+ return "staged";
223
+ }
224
+
117
225
  /** Read one npm package manifest directly from its archive. */
118
226
  export function readNpmArchiveManifest(path: string): NpmArchiveManifest {
119
227
  const result = spawnSync("tar", ["-xOf", path, "package/package.json"], {
package/src/project-js.ts CHANGED
@@ -1397,7 +1397,11 @@ function initProject(
1397
1397
  // repairs the worktree it is meant to validate; `eslint:fix` is the explicit
1398
1398
  // local mutation path.
1399
1399
  const eslint = new javascript.Eslint(project, {
1400
- dirs: [...roots, ...project.extraWorkspaceMembers],
1400
+ dirs: [
1401
+ ...roots,
1402
+ ...project.extraWorkspaceMembers,
1403
+ ...(configuredAppRoot ? [configuredAppRoot] : []),
1404
+ ],
1401
1405
  fileExtensions: [".ts", ".tsx"],
1402
1406
  projectService: true,
1403
1407
  prettier: Boolean(project.prettier),
@@ -1605,9 +1609,12 @@ function preSynthesizeProject(project: javascript.NodeProject): void {
1605
1609
  const subtree = project.node.findAll().filter(Project.isProject);
1606
1610
  if (project.prettier) {
1607
1611
  const ignorePatterns = new Set<string>();
1612
+ const root = resolve(project.outdir);
1608
1613
  for (const p of subtree) {
1614
+ const prefix = toPosix(relative(root, p.outdir));
1609
1615
  p.files.forEach((file) => {
1610
- if (file.readonly) ignorePatterns.add(file.path);
1616
+ if (!file.readonly) return;
1617
+ ignorePatterns.add(prefix ? `${prefix}/${file.path}` : file.path);
1611
1618
  });
1612
1619
  }
1613
1620
  ignorePatterns.forEach((pattern) => project.prettier!.addIgnorePattern(pattern));
@@ -7,3 +7,7 @@ export const RELEASE_WORKFLOW_FILE = "release.yml";
7
7
  export const PUBLIC_NPM_REGISTRY_URL = "https://registry.npmjs.org";
8
8
  /** Public PyPI JSON API queried after Python publication completes. */
9
9
  export const PUBLIC_PYPI_REGISTRY_URL = "https://pypi.org/pypi";
10
+ /** Shared wait for every unresolved public npm and PyPI artifact. */
11
+ export const PUBLICATION_WAIT_TIMEOUT_SECONDS = 20 * 60;
12
+ /** Delay between public registry probes while a release is still invisible. */
13
+ export const PUBLICATION_WAIT_INTERVAL_SECONDS = 5;
package/src/release.ts CHANGED
@@ -18,6 +18,7 @@ import { BUN_VERSION } from "./bun-workflow.ts";
18
18
  import { projectReleaseBranch, taskCommand, type DBXToolsJavaScriptProject } from "./project-js.ts";
19
19
  import {
20
20
  PUBLIC_NPM_REGISTRY_URL,
21
+ PUBLICATION_WAIT_TIMEOUT_SECONDS,
21
22
  RELEASE_VERSION,
22
23
  RELEASE_WORKFLOW_FILE,
23
24
  } from "./release-context.ts";
@@ -73,7 +74,7 @@ export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
73
74
  /** Run a release job after all explicit prerequisite results succeed. */
74
75
  export function releaseCondition(prerequisites: readonly string[] = []): string {
75
76
  if (prerequisites.length === 0) return "${{ success() }}";
76
- return `\${{ always() && ${prerequisites.map((condition) => `(${condition})`).join(" && ")} }}`;
77
+ return `\${{ always() && !cancelled() && ${prerequisites.map((condition) => `(${condition})`).join(" && ")} }}`;
77
78
  }
78
79
 
79
80
  /** Gate shared-build steps using the same per-run flag, preserving their existing conditions. */
@@ -171,7 +172,7 @@ export function refreshPublicationVerification(project: DBXToolsJavaScriptProjec
171
172
  needs: ["build-release", ...registryJobs],
172
173
  runsOn: ["ubuntu-latest"],
173
174
  permissions: { contents: JobPermission.READ },
174
- timeoutMinutes: 15,
175
+ timeoutMinutes: Math.ceil(PUBLICATION_WAIT_TIMEOUT_SECONDS / 60) + 5,
175
176
  steps: [
176
177
  {
177
178
  name: "Download publication verifier",
@@ -7,6 +7,26 @@ const MUTATION_LOCK_SCOPE = "dbx-tools-workspace-mutation";
7
7
  const WAIT_LOG_INTERVAL_MS = 5_000;
8
8
  const logger = log.logger("projen:workspace-lock");
9
9
 
10
+ /** Cancelable recurring task returned by a workspace-lock scheduler. */
11
+ export interface WorkspaceMutationLockTimer {
12
+ cancel(): void;
13
+ }
14
+
15
+ /** Clock and recurring scheduler used by workspace-lock wait logging. */
16
+ export interface WorkspaceMutationLockScheduler {
17
+ now(): number;
18
+ repeat(callback: () => void, intervalMs: number): WorkspaceMutationLockTimer;
19
+ }
20
+
21
+ const _systemScheduler: WorkspaceMutationLockScheduler = {
22
+ now: Date.now,
23
+ repeat(callback, intervalMs) {
24
+ const timer = setInterval(callback, intervalMs);
25
+ timer.unref?.();
26
+ return { cancel: () => clearInterval(timer) };
27
+ },
28
+ };
29
+
10
30
  /** Optional preflight used before and after acquiring the workspace mutation lock. */
11
31
  export interface WorkspaceMutationLockOptions {
12
32
  /**
@@ -15,6 +35,8 @@ export interface WorkspaceMutationLockOptions {
15
35
  * without taking the critical section only to return.
16
36
  */
17
37
  check?: () => boolean | Promise<boolean>;
38
+ /** Scheduler override for deterministic wait-logging tests. */
39
+ scheduler?: WorkspaceMutationLockScheduler;
18
40
  }
19
41
 
20
42
  /**
@@ -31,10 +53,11 @@ export async function withWorkspaceMutationLock<T>(
31
53
  ): Promise<T | undefined> {
32
54
  const repository = resolve(root);
33
55
  const check = options?.check;
56
+ const scheduler = options?.scheduler ?? _systemScheduler;
34
57
  if (check && !(await check())) {
35
58
  return undefined;
36
59
  }
37
- let waitLogTimer: ReturnType<typeof setInterval> | undefined;
60
+ let waitLogTimer: WorkspaceMutationLockTimer | undefined;
38
61
  try {
39
62
  return await withFileLock(
40
63
  [MUTATION_LOCK_SCOPE, repository],
@@ -48,20 +71,19 @@ export async function withWorkspaceMutationLock<T>(
48
71
  backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
49
72
  onWait: () => {
50
73
  if (waitLogTimer) return;
51
- const startedAt = Date.now();
52
- waitLogTimer = setInterval(
74
+ const startedAt = scheduler.now();
75
+ waitLogTimer = scheduler.repeat(
53
76
  () =>
54
77
  logger.info("waiting for workspace mutation lock", {
55
78
  repository,
56
- elapsedMs: Date.now() - startedAt,
79
+ elapsedMs: scheduler.now() - startedAt,
57
80
  }),
58
81
  WAIT_LOG_INTERVAL_MS,
59
82
  );
60
- waitLogTimer.unref?.();
61
83
  },
62
84
  },
63
85
  );
64
86
  } finally {
65
- if (waitLogTimer) clearInterval(waitLogTimer);
87
+ waitLogTimer?.cancel();
66
88
  }
67
89
  }
@@ -0,0 +1,103 @@
1
+ /** Workspace package vendoring for isolated AppKit deployment snapshots. */
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { isRecord } from "@dbx-tools/shared-core";
5
+ import { applyPublishConfig, packNpmPackage } from "./npm-packaging.ts";
6
+ import { readPackageManifest, workspaceDependencyDirectories } from "./packages.ts";
7
+
8
+ const RUNTIME_DEPENDENCY_FIELDS = [
9
+ "dependencies",
10
+ "optionalDependencies",
11
+ "peerDependencies",
12
+ ] as const;
13
+
14
+ /** Result of staging a workspace package dependency closure. */
15
+ export interface VendoredWorkspaceDependencies {
16
+ readonly archives: readonly string[];
17
+ readonly packages: readonly string[];
18
+ }
19
+
20
+ /** Whether a runtime manifest field contains a workspace protocol dependency. */
21
+ function _workspaceDependencyNames(manifest: Readonly<Record<string, unknown>>): string[] {
22
+ return RUNTIME_DEPENDENCY_FIELDS.flatMap((field) => {
23
+ const dependencies = manifest[field];
24
+ if (!isRecord(dependencies)) return [];
25
+ return Object.entries(dependencies).flatMap(([name, value]) =>
26
+ typeof value === "string" && value.startsWith("workspace:") ? [name] : [],
27
+ );
28
+ });
29
+ }
30
+
31
+ /** Ensure generated archives survive the staged application's ignore rules. */
32
+ function _includeVendorArchives(application: string): void {
33
+ const gitignore = join(application, ".gitignore");
34
+ const before = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
35
+ const lines = ["!/vendor/", "!/vendor/npm/", "!/vendor/npm/*.tgz"];
36
+ const missing = lines.filter((line) => !before.split(/\r?\n/).includes(line));
37
+ if (missing.length === 0) return;
38
+ if (existsSync(gitignore)) chmodSync(gitignore, statSync(gitignore).mode | 0o200);
39
+ writeFileSync(gitignore, `${before.replace(/\s*$/, "")}\n${missing.join("\n")}\n`);
40
+ }
41
+
42
+ /**
43
+ * Pack the transitive runtime workspace closure and link every archive directly
44
+ * from the staged application manifest. Returns without writing when the source
45
+ * application has no workspace protocol dependencies.
46
+ */
47
+ export function vendorWorkspaceDependencies(
48
+ sourceRoot: string,
49
+ stageRoot: string,
50
+ applicationPath: string,
51
+ ): VendoredWorkspaceDependencies {
52
+ const sourceApplication = resolve(sourceRoot, applicationPath);
53
+ const sourceManifest = readPackageManifest(sourceApplication);
54
+ if (!sourceManifest || typeof sourceManifest.name !== "string") {
55
+ throw new Error(`Application package manifest is missing: ${sourceApplication}`);
56
+ }
57
+ const directWorkspaceDependencies = _workspaceDependencyNames(sourceManifest);
58
+ if (directWorkspaceDependencies.length === 0) return { archives: [], packages: [] };
59
+
60
+ const selected = workspaceDependencyDirectories(sourceManifest.name, sourceRoot)
61
+ .filter((directory) => resolve(directory) !== sourceApplication)
62
+ .map((directory) => {
63
+ const manifest = readPackageManifest(directory);
64
+ if (!manifest || typeof manifest.name !== "string") {
65
+ throw new Error(`Workspace package manifest is missing: ${directory}`);
66
+ }
67
+ return { directory, name: manifest.name };
68
+ });
69
+ const selectedNames = new Set(selected.map(({ name }) => name));
70
+ const unresolved = directWorkspaceDependencies.filter((name) => !selectedNames.has(name));
71
+ if (unresolved.length > 0) {
72
+ throw new Error(`Workspace dependencies are not recorded: ${unresolved.join(", ")}`);
73
+ }
74
+
75
+ const stagedApplication = resolve(stageRoot, applicationPath);
76
+ const archiveDirectory = join(stagedApplication, "vendor", "npm");
77
+ mkdirSync(archiveDirectory, { recursive: true });
78
+ const links = Object.fromEntries(
79
+ selected.map(({ directory, name }) => {
80
+ const archive = packNpmPackage(
81
+ directory,
82
+ archiveDirectory,
83
+ process.env.PATH,
84
+ applyPublishConfig,
85
+ );
86
+ return [name, `file:./vendor/npm/${basename(archive)}`];
87
+ }),
88
+ );
89
+ const stagedManifestPath = join(stagedApplication, "package.json");
90
+ const stagedManifest = JSON.parse(readFileSync(stagedManifestPath, "utf8")) as Record<
91
+ string,
92
+ unknown
93
+ >;
94
+ const dependencies = isRecord(stagedManifest.dependencies) ? stagedManifest.dependencies : {};
95
+ stagedManifest.dependencies = { ...dependencies, ...links };
96
+ chmodSync(stagedManifestPath, statSync(stagedManifestPath).mode | 0o200);
97
+ writeFileSync(stagedManifestPath, `${JSON.stringify(stagedManifest, null, 2)}\n`);
98
+ _includeVendorArchives(stagedApplication);
99
+ return {
100
+ archives: Object.values(links),
101
+ packages: selected.map(({ name }) => name),
102
+ };
103
+ }
@@ -9,6 +9,7 @@ import { log, toError } from "@dbx-tools/shared-core";
9
9
  import { z } from "zod";
10
10
  import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
11
11
  import { withStagedWorkspaceManifests } from "../src/workspace-manifest.ts";
12
+ import { vendorWorkspaceDependencies } from "../src/workspace-vendor.ts";
12
13
 
13
14
  const logger = log.logger("projen:appkit-deploy");
14
15
 
@@ -146,6 +147,13 @@ export async function deployApp(
146
147
  }));
147
148
  await withStagedWorkspaceManifests(root, async (stage) => {
148
149
  const stagedApplication = resolve(stage, options.directory);
150
+ const vendored = vendorWorkspaceDependencies(root, stage, applicationPath);
151
+ if (vendored.packages.length > 0) {
152
+ logger.info("Vendored workspace packages for deployment", {
153
+ count: vendored.packages.length,
154
+ packages: vendored.packages,
155
+ });
156
+ }
149
157
  cpSync(clientDist, join(stagedApplication, "client", "dist"), { recursive: true });
150
158
  await reconcileStagedBundleState(stagedApplication, contextArgs, options.target, dependencies);
151
159
  const result = await run(deployArgs, stagedApplication);
@@ -6,10 +6,8 @@ import { log, mapConcurrent } from "@dbx-tools/shared-core";
6
6
  import { z } from "zod";
7
7
  import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
8
8
  import { TaskConcurrencyOptionSchema, TaskDryRunOptionSchema } from "./options.ts";
9
- import { runTaskCommandAsync } from "../src/_task-command.ts";
10
9
  import {
11
- npmReleaseMatches,
12
- publishedNpmRelease,
10
+ publishNpmArchive,
13
11
  readNpmArchiveIdentity,
14
12
  readNpmArchiveManifest,
15
13
  } from "../src/npm-release.ts";
@@ -84,20 +82,15 @@ export async function publishNpmArchives(options: {
84
82
  `npm archive ${archive} carries ${local.version}, expected ${options.version}`,
85
83
  );
86
84
  }
87
- if (!options.dryRun) {
88
- const published = await publishedNpmRelease(local.name, local.version, options.registry);
89
- if (npmReleaseMatches(local, published)) {
90
- logger.info(`skip published ${local.name}@${local.version}`);
91
- return;
92
- }
93
- }
94
- await runTaskCommandAsync(process.cwd(), "npm", [
95
- "publish",
85
+ const outcome = await publishNpmArchive({
96
86
  archive,
97
- ...(local.access ? ["--access", local.access] : []),
98
- ...(options.registry ? ["--registry", options.registry] : []),
99
- ...(options.dryRun ? ["--dry-run"] : []),
100
- ]);
87
+ identity: local,
88
+ dryRun: options.dryRun,
89
+ registry: options.registry,
90
+ });
91
+ if (outcome === "skipped") {
92
+ logger.info(`skip published ${local.name}@${local.version}`);
93
+ }
101
94
  },
102
95
  {
103
96
  concurrency: options.concurrency ?? DEFAULT_PUBLISH_CONCURRENCY,
package/tasks/publish.ts CHANGED
@@ -32,7 +32,8 @@
32
32
  * standalone publishes.
33
33
  * - **release recovery** compares each packed archive's integrity and
34
34
  * repository identity with registry metadata. A matching immutable version
35
- * is skipped, while any mismatch fails the retry.
35
+ * is skipped. A 409 for a previously staged version waits for that exact
36
+ * public artifact instead of publishing again. Any other mismatch fails.
36
37
  *
37
38
  * `--dry-run` forwards to `npm publish`: it packs + validates
38
39
  * but uploads nothing. `--registry` targets a non-default registry (a local
@@ -56,13 +57,9 @@ import {
56
57
  TaskOutputOptionSchema,
57
58
  TaskRootOptionSchema,
58
59
  } from "./options.ts";
59
- import { runTaskCommand, runTaskCommandAsync } from "../src/_task-command.ts";
60
+ import { runTaskCommand } from "../src/_task-command.ts";
60
61
  import { applyPublishConfig, packNpmPackage } from "../src/npm-packaging.ts";
61
- import {
62
- npmReleaseMatches,
63
- publishedNpmRelease,
64
- readNpmArchiveIdentity,
65
- } from "../src/npm-release.ts";
62
+ import { publishNpmArchive, readNpmArchiveIdentity } from "../src/npm-release.ts";
66
63
  import { recordedPackages } from "../src/packages.ts";
67
64
 
68
65
  const logger = log.logger("projen:publish");
@@ -153,11 +150,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
153
150
  }
154
151
  logger.info(`validated ${members.length} selected member manifests`);
155
152
 
156
- const publishArgs = [
157
- "--ignore-scripts",
158
- ...(registry ? ["--registry", registry] : []),
159
- ...(dryRun ? ["--dry-run"] : []),
160
- ];
161
153
  const publishable: Array<{
162
154
  dir: string;
163
155
  name: string;
@@ -253,20 +245,19 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
253
245
  logger.info(`packed ${name} @ ${packageVersion}`);
254
246
  return;
255
247
  }
256
- if (!dryRun) {
257
- const published = await publishedNpmRelease(local.name, local.version, registry);
258
- if (npmReleaseMatches(local, published)) {
259
- logger.info(`skip published ${name} @ ${packageVersion}`);
260
- return;
261
- }
248
+ const outcome = await publishNpmArchive({
249
+ archive,
250
+ identity: local,
251
+ cwd: dir,
252
+ dryRun,
253
+ env: { ...process.env, PATH: path },
254
+ registry,
255
+ });
256
+ if (outcome === "skipped") {
257
+ logger.info(`skip published ${name} @ ${packageVersion}`);
258
+ return;
262
259
  }
263
- logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
264
- await runTaskCommandAsync(
265
- dir,
266
- "npm",
267
- ["publish", ...(access ? ["--access", access] : []), ...publishArgs, archive],
268
- { env: { ...process.env, PATH: path } },
269
- );
260
+ logger.info(`${dryRun ? "dry-run published" : "published"} ${name} @ ${packageVersion}`);
270
261
  } finally {
271
262
  rmSync(packed, { recursive: true, force: true });
272
263
  }
package/tasks/release.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  /** Run the configured release transaction. */
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { serializeArgs } from "@dbx-tools/cli-args";
5
6
  import { projectUtils, type ProjectGhAccount, resolveProjectGhAccount } from "@dbx-tools/core";
6
7
  import { log, toError, parse } from "@dbx-tools/shared-core";
7
8
  import type { Command } from "commander";
@@ -598,10 +599,14 @@ export async function runRelease(
598
599
  }
599
600
 
600
601
  if (options.demoDeploy) {
602
+ const demoArgs = serializeArgs({
603
+ ...(selection.validation ? {} : { validation: false }),
604
+ ...(options.demoProfile ? { profile: options.demoProfile } : {}),
605
+ });
601
606
  runTaskCommand(root, "bun", [
602
607
  "run",
603
608
  "demo:deploy",
604
- ...(options.demoProfile ? ["--", "--profile", options.demoProfile] : []),
609
+ ...(demoArgs.length > 0 ? ["--", ...demoArgs] : []),
605
610
  ]);
606
611
  }
607
612
 
@@ -11,11 +11,16 @@ import {
11
11
  publishedNpmRelease,
12
12
  readNpmArchiveIdentity,
13
13
  } from "../src/npm-release.ts";
14
- import { PUBLIC_NPM_REGISTRY_URL, PUBLIC_PYPI_REGISTRY_URL } from "../src/release-context.ts";
14
+ import {
15
+ PUBLIC_NPM_REGISTRY_URL,
16
+ PUBLIC_PYPI_REGISTRY_URL,
17
+ PUBLICATION_WAIT_INTERVAL_SECONDS,
18
+ PUBLICATION_WAIT_TIMEOUT_SECONDS,
19
+ } from "../src/release-context.ts";
15
20
 
16
21
  const DEFAULT_CONCURRENCY = 8;
17
- const DEFAULT_INTERVAL_SECONDS = 5;
18
- const DEFAULT_TIMEOUT_SECONDS = 10 * 60;
22
+ const DEFAULT_INTERVAL_SECONDS = PUBLICATION_WAIT_INTERVAL_SECONDS;
23
+ const DEFAULT_TIMEOUT_SECONDS = PUBLICATION_WAIT_TIMEOUT_SECONDS;
19
24
  const logger = log.logger("projen:verify-publications");
20
25
 
21
26
  const PypiReleaseSchema = z