putitoutthere 0.2.29 → 0.2.31

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 (150) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/MIGRATIONS.md +1311 -0
  3. package/README.md +373 -41
  4. package/action.yml +9 -0
  5. package/dist/action.d.ts.map +1 -1
  6. package/dist/action.js +28 -6
  7. package/dist/action.js.map +1 -1
  8. package/dist/cascade.js +14 -7
  9. package/dist/cascade.js.map +1 -1
  10. package/dist/check-crate-size.d.ts +26 -0
  11. package/dist/check-crate-size.d.ts.map +1 -0
  12. package/dist/check-crate-size.js +99 -0
  13. package/dist/check-crate-size.js.map +1 -0
  14. package/dist/check.d.ts.map +1 -1
  15. package/dist/check.js +116 -16
  16. package/dist/check.js.map +1 -1
  17. package/dist/cli.d.ts +17 -4
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +173 -22
  20. package/dist/cli.js.map +1 -1
  21. package/dist/completeness.js +6 -3
  22. package/dist/completeness.js.map +1 -1
  23. package/dist/config.d.ts +2 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +20 -5
  26. package/dist/config.js.map +1 -1
  27. package/dist/ensure-tag.d.ts +17 -0
  28. package/dist/ensure-tag.d.ts.map +1 -0
  29. package/dist/ensure-tag.js +28 -0
  30. package/dist/ensure-tag.js.map +1 -0
  31. package/dist/env.js +6 -3
  32. package/dist/env.js.map +1 -1
  33. package/dist/error-codes.d.ts +89 -0
  34. package/dist/error-codes.d.ts.map +1 -1
  35. package/dist/error-codes.js +102 -0
  36. package/dist/error-codes.js.map +1 -1
  37. package/dist/git.d.ts +5 -0
  38. package/dist/git.d.ts.map +1 -1
  39. package/dist/git.js +19 -6
  40. package/dist/git.js.map +1 -1
  41. package/dist/glob.d.ts +9 -0
  42. package/dist/glob.d.ts.map +1 -1
  43. package/dist/glob.js +29 -1
  44. package/dist/glob.js.map +1 -1
  45. package/dist/handlers/crates.d.ts +15 -0
  46. package/dist/handlers/crates.d.ts.map +1 -1
  47. package/dist/handlers/crates.js +84 -9
  48. package/dist/handlers/crates.js.map +1 -1
  49. package/dist/handlers/npm-platform.d.ts +11 -0
  50. package/dist/handlers/npm-platform.d.ts.map +1 -1
  51. package/dist/handlers/npm-platform.js +91 -8
  52. package/dist/handlers/npm-platform.js.map +1 -1
  53. package/dist/handlers/npm.d.ts.map +1 -1
  54. package/dist/handlers/npm.js +59 -9
  55. package/dist/handlers/npm.js.map +1 -1
  56. package/dist/handlers/pypi.d.ts +0 -6
  57. package/dist/handlers/pypi.d.ts.map +1 -1
  58. package/dist/handlers/pypi.js +28 -10
  59. package/dist/handlers/pypi.js.map +1 -1
  60. package/dist/log.js +16 -8
  61. package/dist/log.js.map +1 -1
  62. package/dist/normalize-artifacts.js +8 -4
  63. package/dist/normalize-artifacts.js.map +1 -1
  64. package/dist/plan-skew.d.ts +14 -0
  65. package/dist/plan-skew.d.ts.map +1 -0
  66. package/dist/plan-skew.js +28 -0
  67. package/dist/plan-skew.js.map +1 -0
  68. package/dist/plan-status-types.d.ts +42 -0
  69. package/dist/plan-status-types.d.ts.map +1 -0
  70. package/dist/plan-status-types.js +10 -0
  71. package/dist/plan-status-types.js.map +1 -0
  72. package/dist/plan-status.d.ts +23 -0
  73. package/dist/plan-status.d.ts.map +1 -0
  74. package/dist/plan-status.js +66 -0
  75. package/dist/plan-status.js.map +1 -0
  76. package/dist/plan.d.ts +8 -0
  77. package/dist/plan.d.ts.map +1 -1
  78. package/dist/plan.js +125 -25
  79. package/dist/plan.js.map +1 -1
  80. package/dist/preflight.d.ts +79 -0
  81. package/dist/preflight.d.ts.map +1 -1
  82. package/dist/preflight.js +715 -33
  83. package/dist/preflight.js.map +1 -1
  84. package/dist/publish.d.ts +7 -0
  85. package/dist/publish.d.ts.map +1 -1
  86. package/dist/publish.js +49 -18
  87. package/dist/publish.js.map +1 -1
  88. package/dist/python-versions.d.ts +40 -0
  89. package/dist/python-versions.d.ts.map +1 -0
  90. package/dist/python-versions.js +142 -0
  91. package/dist/python-versions.js.map +1 -0
  92. package/dist/reconcile-types.d.ts +36 -0
  93. package/dist/reconcile-types.d.ts.map +1 -0
  94. package/dist/reconcile-types.js +9 -0
  95. package/dist/reconcile-types.js.map +1 -0
  96. package/dist/reconcile.d.ts +21 -0
  97. package/dist/reconcile.d.ts.map +1 -0
  98. package/dist/reconcile.js +54 -0
  99. package/dist/reconcile.js.map +1 -0
  100. package/dist/release-packages.d.ts +33 -0
  101. package/dist/release-packages.d.ts.map +1 -0
  102. package/dist/release-packages.js +74 -0
  103. package/dist/release-packages.js.map +1 -0
  104. package/dist/resolve-tag-commit.d.ts +22 -0
  105. package/dist/resolve-tag-commit.d.ts.map +1 -0
  106. package/dist/resolve-tag-commit.js +26 -0
  107. package/dist/resolve-tag-commit.js.map +1 -0
  108. package/dist/retry.js +14 -7
  109. package/dist/retry.js.map +1 -1
  110. package/dist/status-classify.d.ts +17 -0
  111. package/dist/status-classify.d.ts.map +1 -0
  112. package/dist/status-classify.js +37 -0
  113. package/dist/status-classify.js.map +1 -0
  114. package/dist/status-format.d.ts +9 -0
  115. package/dist/status-format.d.ts.map +1 -0
  116. package/dist/status-format.js +19 -0
  117. package/dist/status-format.js.map +1 -0
  118. package/dist/status-types.d.ts +31 -0
  119. package/dist/status-types.d.ts.map +1 -0
  120. package/dist/status-types.js +8 -0
  121. package/dist/status-types.js.map +1 -0
  122. package/dist/status.d.ts +28 -0
  123. package/dist/status.d.ts.map +1 -0
  124. package/dist/status.js +72 -0
  125. package/dist/status.js.map +1 -0
  126. package/dist/tag-template.js +2 -1
  127. package/dist/tag-template.js.map +1 -1
  128. package/dist/trailer.js +16 -9
  129. package/dist/trailer.js.map +1 -1
  130. package/dist/types.d.ts +11 -0
  131. package/dist/types.d.ts.map +1 -1
  132. package/dist/types.js +4 -2
  133. package/dist/types.js.map +1 -1
  134. package/dist/verbose.js +7 -4
  135. package/dist/verbose.js.map +1 -1
  136. package/dist/wheel-abi.d.ts +37 -0
  137. package/dist/wheel-abi.d.ts.map +1 -0
  138. package/dist/wheel-abi.js +118 -0
  139. package/dist/wheel-abi.js.map +1 -0
  140. package/dist/write-crate-version.d.ts +32 -0
  141. package/dist/write-crate-version.d.ts.map +1 -0
  142. package/dist/write-crate-version.js +52 -0
  143. package/dist/write-crate-version.js.map +1 -0
  144. package/dist/write-launcher.d.ts +82 -0
  145. package/dist/write-launcher.d.ts.map +1 -0
  146. package/dist/write-launcher.js +236 -0
  147. package/dist/write-launcher.js.map +1 -0
  148. package/dist/write-version.js +2 -1
  149. package/dist/write-version.js.map +1 -1
  150. package/package.json +4 -2
package/dist/preflight.js CHANGED
@@ -21,9 +21,10 @@
21
21
  * Issue #14, #280.
22
22
  */
23
23
  import { readFileSync } from 'node:fs';
24
- import { dirname, join, parse as parsePath } from 'node:path';
24
+ import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
25
25
  import { parse as parseToml } from 'smol-toml';
26
26
  import { ErrorCodes } from './error-codes.js';
27
+ import { expandDirGlob } from './glob.js';
27
28
  // Per-kind accepted env var names, primary first. `checkAuth` scans left
28
29
  // to right and reports the first that has a non-empty value. npm takes
29
30
  // two names because ecosystems have split on the convention:
@@ -60,8 +61,9 @@ export function checkAuth(packages) {
60
61
  }
61
62
  export function requireAuth(packages) {
62
63
  const status = checkAuth(packages);
63
- if (status.ok)
64
+ if (status.ok) {
64
65
  return;
66
+ }
65
67
  const missing = status.results.filter((r) => r.via === 'missing');
66
68
  const lines = missing.map((r) => {
67
69
  const vars = r.acceptedEnvVars.join(' or ');
@@ -100,8 +102,9 @@ const REPOSITORY_DOC_POINTER = 'https://github.com/thekevinscott/putitoutthere#k
100
102
  export function checkProvenanceMetadata(packages) {
101
103
  const findings = [];
102
104
  for (const p of packages) {
103
- if (p.kind !== 'npm')
105
+ if (p.kind !== 'npm') {
104
106
  continue;
107
+ }
105
108
  const pkgJsonPath = join(p.path, 'package.json');
106
109
  let raw;
107
110
  try {
@@ -128,8 +131,9 @@ export function checkProvenanceMetadata(packages) {
128
131
  return findings;
129
132
  }
130
133
  function hasNonEmptyRepository(value) {
131
- if (typeof value === 'string')
134
+ if (typeof value === 'string') {
132
135
  return value.trim().length > 0;
136
+ }
133
137
  if (value !== null && typeof value === 'object') {
134
138
  const url = value.url;
135
139
  return typeof url === 'string' && url.trim().length > 0;
@@ -149,8 +153,9 @@ function hasNonEmptyRepository(value) {
149
153
  */
150
154
  export function requireProvenanceMetadata(packages) {
151
155
  const findings = checkProvenanceMetadata(packages);
152
- if (findings.length === 0)
156
+ if (findings.length === 0) {
153
157
  return;
158
+ }
154
159
  const lines = [
155
160
  `[${ErrorCodes.NPM_MISSING_REPOSITORY}] npm publish requires a non-empty \`repository\` field in package.json.`,
156
161
  '',
@@ -186,8 +191,9 @@ const CARGO_DOC_POINTER = 'https://doc.rust-lang.org/cargo/reference/manifest.ht
186
191
  export function checkCratesMetadata(packages) {
187
192
  const findings = [];
188
193
  for (const p of packages) {
189
- if (p.kind !== 'crates')
194
+ if (p.kind !== 'crates') {
190
195
  continue;
196
+ }
191
197
  const cargoTomlPath = join(p.path, 'Cargo.toml');
192
198
  let raw;
193
199
  try {
@@ -219,8 +225,9 @@ export function checkCratesMetadata(packages) {
219
225
  const license = resolveInherited(pkgTable.license, wsPkgTable, 'license');
220
226
  const licenseFile = resolveInherited(pkgTable['license-file'], wsPkgTable, 'license-file');
221
227
  const missing = [];
222
- if (!nonEmptyString(description))
228
+ if (!nonEmptyString(description)) {
223
229
  missing.push('description');
230
+ }
224
231
  if (!nonEmptyString(license) && !nonEmptyString(licenseFile)) {
225
232
  missing.push('license');
226
233
  }
@@ -261,8 +268,9 @@ const PYPI_VERSION_DOC_POINTER = 'https://thekevinscott.github.io/putitoutthere/
261
268
  export function checkPypiVersionSource(packages) {
262
269
  const findings = [];
263
270
  for (const p of packages) {
264
- if (p.kind !== 'pypi')
271
+ if (p.kind !== 'pypi') {
265
272
  continue;
273
+ }
266
274
  const pyprojectPath = join(p.path, 'pyproject.toml');
267
275
  let raw;
268
276
  try {
@@ -279,10 +287,12 @@ export function checkPypiVersionSource(packages) {
279
287
  continue;
280
288
  }
281
289
  const project = parsed.project;
282
- if (project === undefined)
290
+ if (project === undefined) {
283
291
  continue;
284
- if (declaresDynamicVersion(project))
292
+ }
293
+ if (declaresDynamicVersion(project)) {
285
294
  continue;
295
+ }
286
296
  if (typeof project.version === 'string') {
287
297
  findings.push({ package: p.name, pyprojectPath });
288
298
  }
@@ -306,8 +316,9 @@ function declaresDynamicVersion(project) {
306
316
  */
307
317
  export function requirePypiVersionSource(packages) {
308
318
  const findings = checkPypiVersionSource(packages);
309
- if (findings.length === 0)
319
+ if (findings.length === 0) {
310
320
  return;
321
+ }
311
322
  const lines = [
312
323
  `[${ErrorCodes.PYPI_STATIC_VERSION}] pyproject.toml must declare \`[project].dynamic = ["version"]\` instead of a static \`[project].version = "..."\` literal.`,
313
324
  '',
@@ -346,31 +357,17 @@ function readWorkspacePackageTable(crateDir) {
346
357
  let dir = dirname(crateDir);
347
358
  while (dir && dir !== rootMarker) {
348
359
  const manifest = join(dir, 'Cargo.toml');
349
- let raw;
350
- try {
351
- raw = readFileSync(manifest, 'utf8');
352
- }
353
- catch {
354
- const parent = dirname(dir);
355
- if (parent === dir)
356
- break;
357
- dir = parent;
358
- continue;
359
- }
360
360
  let parsed;
361
361
  try {
362
- parsed = parseToml(raw);
362
+ parsed = parseToml(readFileSync(manifest, 'utf8'));
363
363
  }
364
364
  catch {
365
- // Cargo will surface the parser error; just skip this manifest
366
- // for workspace lookup so we don't crash on a malformed parent.
367
- const parent = dirname(dir);
368
- if (parent === dir)
369
- break;
370
- dir = parent;
371
- continue;
365
+ // Missing or malformed Cargo.toml at this level — skip it and keep
366
+ // walking up. (Cargo surfaces a real parse error itself; we just
367
+ // don't crash the workspace lookup on an unreadable ancestor.)
368
+ parsed = undefined;
372
369
  }
373
- if (parsed.workspace !== undefined) {
370
+ if (parsed !== undefined && parsed.workspace !== undefined) {
374
371
  const ws = parsed.workspace;
375
372
  const wsPkg = ws.package;
376
373
  if (wsPkg !== undefined && typeof wsPkg === 'object') {
@@ -379,9 +376,13 @@ function readWorkspacePackageTable(crateDir) {
379
376
  // Workspace root found, but no shared metadata — stop walking.
380
377
  return undefined;
381
378
  }
379
+ // Advance one directory toward the filesystem root. The fixpoint
380
+ // guard terminates the walk for relative crate paths, where
381
+ // dirname() settles on '.' rather than reaching rootMarker.
382
382
  const parent = dirname(dir);
383
- if (parent === dir)
383
+ if (parent === dir) {
384
384
  break;
385
+ }
385
386
  dir = parent;
386
387
  }
387
388
  return undefined;
@@ -399,8 +400,9 @@ function readWorkspacePackageTable(crateDir) {
399
400
  */
400
401
  export function requireCratesMetadata(packages) {
401
402
  const findings = checkCratesMetadata(packages);
402
- if (findings.length === 0)
403
+ if (findings.length === 0) {
403
404
  return;
405
+ }
404
406
  const lines = [
405
407
  `[${ErrorCodes.CRATES_MISSING_METADATA}] cargo publish requires the following Cargo.toml [package] fields: description, and license (or license-file).`,
406
408
  '',
@@ -412,4 +414,684 @@ export function requireCratesMetadata(packages) {
412
414
  lines.push('', 'Why: crates.io rejects publish with `400 Bad Request: missing or empty', 'metadata fields: ...` after cargo publish has compiled the crate and every', 'transitive dep — wasting the full publish job on a precondition checkable', 'in milliseconds.', '', 'Fix: add the missing fields to each Cargo.toml. Example:', '', ' [package]', ' description = "One-line summary of what the crate does."', ' license = "MIT OR Apache-2.0"', '', `See ${CARGO_DOC_POINTER}.`);
413
415
  throw new Error(lines.join('\n'));
414
416
  }
417
+ // Each PYPI_BUILD mode maps to the prefix(es) the upstream build
418
+ // backend identifier should start with. The check is intentionally
419
+ // loose: maturin / setuptools / hatchling all ship multiple backend
420
+ // identifiers across versions, and the failure mode this gate exists
421
+ // to catch is the *kind* mismatch (e.g. `build = "maturin"` but the
422
+ // pyproject declares hatchling), not a backend-version drift.
423
+ const PYPI_BACKEND_PREFIX = {
424
+ maturin: ['maturin'],
425
+ setuptools: ['setuptools'],
426
+ hatch: ['hatchling', 'hatch'],
427
+ };
428
+ export function checkPyprojectShape(packages) {
429
+ const findings = [];
430
+ for (const p of packages) {
431
+ if (p.kind !== 'pypi') {
432
+ continue;
433
+ }
434
+ const pyprojectPath = join(p.path, 'pyproject.toml');
435
+ let raw;
436
+ try {
437
+ raw = readFileSync(pyprojectPath, 'utf8');
438
+ }
439
+ catch {
440
+ // Missing pyproject.toml is a different failure surface (the
441
+ // build tool surfaces it with a clear error, and `check.ts`
442
+ // catches it at PR time). Skip.
443
+ continue;
444
+ }
445
+ let parsed;
446
+ try {
447
+ parsed = parseToml(raw);
448
+ }
449
+ catch {
450
+ // Malformed TOML — let the build tool surface its own diagnostic.
451
+ continue;
452
+ }
453
+ const project = (parsed.project ?? {});
454
+ const buildSystem = (parsed['build-system'] ?? {});
455
+ const tool = (parsed.tool ?? {});
456
+ const expectedName = p.pypi ?? p.name;
457
+ // PYPI_NAME_MISMATCH
458
+ if (typeof project.name === 'string' && project.name !== expectedName) {
459
+ findings.push({
460
+ package: p.name,
461
+ pyprojectPath,
462
+ code: 'PIOT_PYPI_NAME_MISMATCH',
463
+ detail: `[project].name = "${project.name}" but configured name is "${expectedName}"`,
464
+ });
465
+ }
466
+ // PYPI_BUILD_BACKEND_MISMATCH — only fires when the field is set
467
+ // *and* disagrees. A missing [build-system] table is technically
468
+ // allowed (pip falls back to setuptools); flagging it here would
469
+ // create noise on packages this gate is not meant to police.
470
+ const backend = buildSystem['build-backend'];
471
+ if (typeof backend === 'string' && backend.length > 0) {
472
+ const allowed = PYPI_BACKEND_PREFIX[p.build];
473
+ const ok = allowed.some((prefix) => backend.startsWith(prefix));
474
+ if (!ok) {
475
+ findings.push({
476
+ package: p.name,
477
+ pyprojectPath,
478
+ code: 'PIOT_PYPI_BUILD_BACKEND_MISMATCH',
479
+ detail: `[build-system].build-backend = "${backend}" but build = "${p.build}" expects ${allowed.map((a) => `"${a}*"`).join(' or ')}`,
480
+ });
481
+ }
482
+ }
483
+ // PYPI_DYNAMIC_VERSION_NO_BACKEND — only checks setuptools / hatch.
484
+ // Maturin sources its version from Cargo.toml's [package].version
485
+ // when `dynamic = ["version"]`, so requiring a `[tool.hatch.version]`
486
+ // or `[tool.setuptools_scm]` block would surface false positives on
487
+ // every maturin pypi package.
488
+ if (p.build !== 'maturin') {
489
+ const dynamic = project.dynamic;
490
+ const dynamicHasVersion = Array.isArray(dynamic) && dynamic.some((v) => v === 'version');
491
+ if (dynamicHasVersion) {
492
+ const hatchVersion = (tool.hatch ?? {}).version;
493
+ const setuptoolsScm = tool.setuptools_scm;
494
+ const hasHatchSource = typeof hatchVersion === 'object' && hatchVersion !== null;
495
+ const hasSetuptoolsScm = typeof setuptoolsScm === 'object' && setuptoolsScm !== null;
496
+ if (!hasHatchSource && !hasSetuptoolsScm) {
497
+ findings.push({
498
+ package: p.name,
499
+ pyprojectPath,
500
+ code: 'PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND',
501
+ detail: '[project].dynamic includes "version" but neither [tool.hatch.version] nor [tool.setuptools_scm] is present; the build backend has no way to compute a version',
502
+ });
503
+ }
504
+ }
505
+ }
506
+ // PYPI_MATURIN_INCLUDE_MISSING
507
+ if (p.bundle_cli !== undefined) {
508
+ const stageTo = p.bundle_cli.stage_to;
509
+ const maturin = (tool.maturin ?? {});
510
+ const include = maturin.include;
511
+ const includes = Array.isArray(include) ? include : [];
512
+ if (!maturinIncludeCovers(includes, stageTo)) {
513
+ findings.push({
514
+ package: p.name,
515
+ pyprojectPath,
516
+ code: 'PIOT_PYPI_MATURIN_INCLUDE_MISSING',
517
+ detail: `bundle_cli.stage_to = "${stageTo}" is not covered by any [tool.maturin].include entry; the cross-compiled binary will not be packed into the wheel`,
518
+ });
519
+ }
520
+ }
521
+ }
522
+ return findings;
523
+ }
524
+ function maturinIncludeCovers(includes, stageTo) {
525
+ for (const entry of includes) {
526
+ const path = extractIncludePath(entry);
527
+ if (path === undefined) {
528
+ continue;
529
+ }
530
+ // Strip trailing glob segments so `a/bin/*` and `a/bin/**` both
531
+ // cover `a/bin`.
532
+ const normalized = path.replace(/\/?\*+$/, '').replace(/\/$/, '');
533
+ if (normalized === stageTo) {
534
+ return true;
535
+ }
536
+ if (normalized.length > 0 && stageTo.startsWith(normalized + '/')) {
537
+ return true;
538
+ }
539
+ if (path.startsWith(stageTo + '/') || path === stageTo) {
540
+ return true;
541
+ }
542
+ }
543
+ return false;
544
+ }
545
+ function extractIncludePath(entry) {
546
+ if (typeof entry === 'string') {
547
+ return entry;
548
+ }
549
+ if (typeof entry === 'object' && entry !== null) {
550
+ const path = entry.path;
551
+ if (typeof path === 'string') {
552
+ return path;
553
+ }
554
+ }
555
+ return undefined;
556
+ }
557
+ export function requirePyprojectShape(packages) {
558
+ const findings = checkPyprojectShape(packages);
559
+ if (findings.length === 0) {
560
+ return;
561
+ }
562
+ const lines = [
563
+ 'Pre-flight pyproject.toml shape check failed:',
564
+ '',
565
+ ];
566
+ for (const f of findings) {
567
+ lines.push(` [${f.code}] ${f.package}: ${f.pyprojectPath}`);
568
+ lines.push(` ${f.detail}`);
569
+ }
570
+ lines.push('', 'Why: these mismatches would otherwise surface 10-20 minutes into a release run', 'when the build backend (maturin / setuptools / hatchling) finally tripped on them.', 'Fix the pyproject.toml + putitoutthere.toml fields in this list, then re-run.');
571
+ throw new Error(lines.join('\n'));
572
+ }
573
+ export function checkCargoShape(packages, options = {}) {
574
+ const findings = [];
575
+ const cwd = options.cwd ?? process.cwd();
576
+ for (const p of packages) {
577
+ if (p.kind === 'crates') {
578
+ collectCratesPackageFindings(p, cwd, findings);
579
+ }
580
+ else if (p.kind === 'pypi' && p.bundle_cli !== undefined) {
581
+ collectBundleCliCrateFindings(p, p.bundle_cli, cwd, findings);
582
+ }
583
+ }
584
+ return findings;
585
+ }
586
+ function collectCratesPackageFindings(p, cwd, findings) {
587
+ const cargoTomlPath = join(p.path, 'Cargo.toml');
588
+ const parsed = readToml(cargoTomlPath);
589
+ if (parsed === null) {
590
+ return;
591
+ }
592
+ const pkgTable = (parsed.package ?? {});
593
+ const expectedName = p.crate ?? p.name;
594
+ // CRATES_NAME_MISMATCH
595
+ if (typeof pkgTable.name === 'string' && pkgTable.name !== expectedName) {
596
+ findings.push({
597
+ package: p.name,
598
+ cargoTomlPath,
599
+ code: 'PIOT_CRATES_NAME_MISMATCH',
600
+ detail: `[package].name = "${pkgTable.name}" but configured name is "${expectedName}"`,
601
+ });
602
+ }
603
+ // CRATES_FEATURE_NOT_DECLARED — only when features is set on the
604
+ // configured package.
605
+ if (p.features !== undefined && p.features.length > 0) {
606
+ const declared = declaredFeatures(parsed);
607
+ const missing = p.features.filter((f) => !declared.has(f));
608
+ if (missing.length > 0) {
609
+ findings.push({
610
+ package: p.name,
611
+ cargoTomlPath,
612
+ code: 'PIOT_CRATES_FEATURE_NOT_DECLARED',
613
+ detail: `features = ${JSON.stringify(p.features)} references undeclared feature(s) ${JSON.stringify(missing)}; Cargo.toml [features] declares ${JSON.stringify([...declared])}`,
614
+ });
615
+ }
616
+ }
617
+ // CRATES_WORKSPACE_VERSION_MISMATCH
618
+ if (versionInheritsWorkspace(pkgTable.version)) {
619
+ if (!workspaceVersionDeclared(cargoTomlPath, cwd)) {
620
+ findings.push({
621
+ package: p.name,
622
+ cargoTomlPath,
623
+ code: 'PIOT_CRATES_WORKSPACE_VERSION_MISMATCH',
624
+ detail: '[package].version.workspace = true but no ancestor Cargo.toml declares [workspace.package].version',
625
+ });
626
+ }
627
+ }
628
+ }
629
+ function collectBundleCliCrateFindings(p, bundleCli, cwd, findings) {
630
+ const cratePathAbs = isAbsolute(bundleCli.crate_path)
631
+ ? bundleCli.crate_path
632
+ : resolve(cwd, bundleCli.crate_path);
633
+ const cargoTomlPath = join(cratePathAbs, 'Cargo.toml');
634
+ const parsed = readToml(cargoTomlPath);
635
+ if (parsed === null) {
636
+ return;
637
+ }
638
+ // CRATES_MISSING_BIN
639
+ const declaredBins = readDeclaredBinNames(parsed, cargoTomlPath);
640
+ if (!declaredBins.includes(bundleCli.bin)) {
641
+ findings.push({
642
+ package: p.name,
643
+ cargoTomlPath,
644
+ code: 'PIOT_CRATES_MISSING_BIN',
645
+ detail: `bundle_cli.bin = "${bundleCli.bin}" is not declared as a [[bin]] in ${cargoTomlPath}; declared bins: ${declaredBins.length === 0 ? '(none)' : declaredBins.join(', ')}`,
646
+ });
647
+ }
648
+ // CRATES_FEATURE_NOT_DECLARED — bundle_cli.features path
649
+ if (bundleCli.features.length > 0) {
650
+ const declared = declaredFeatures(parsed);
651
+ const missing = bundleCli.features.filter((f) => !declared.has(f));
652
+ if (missing.length > 0) {
653
+ findings.push({
654
+ package: p.name,
655
+ cargoTomlPath,
656
+ code: 'PIOT_CRATES_FEATURE_NOT_DECLARED',
657
+ detail: `bundle_cli.features = ${JSON.stringify(bundleCli.features)} references undeclared feature(s) ${JSON.stringify(missing)}; Cargo.toml [features] declares ${JSON.stringify([...declared])}`,
658
+ });
659
+ }
660
+ }
661
+ }
662
+ function readToml(path) {
663
+ let raw;
664
+ try {
665
+ raw = readFileSync(path, 'utf8');
666
+ }
667
+ catch {
668
+ return null;
669
+ }
670
+ try {
671
+ return parseToml(raw);
672
+ }
673
+ catch {
674
+ return null;
675
+ }
676
+ }
677
+ function declaredFeatures(cargoToml) {
678
+ const features = (cargoToml.features ?? {});
679
+ return new Set(Object.keys(features));
680
+ }
681
+ function readDeclaredBinNames(cargoToml, cargoTomlPath) {
682
+ const result = collectBinsFromManifest(cargoToml);
683
+ // Workspace manifests delegate [[bin]] declarations to member crates
684
+ // (#337). `cargo build --bin X` from anywhere in the workspace
685
+ // resolves X transparently, so a check that only reads the
686
+ // workspace-root manifest reports bins as missing even when they
687
+ // exist in a member.
688
+ for (const memberManifest of workspaceMemberManifests(cargoToml, cargoTomlPath)) {
689
+ const memberParsed = readToml(memberManifest);
690
+ if (memberParsed === null) {
691
+ continue;
692
+ }
693
+ for (const b of collectBinsFromManifest(memberParsed)) {
694
+ if (!result.includes(b)) {
695
+ result.push(b);
696
+ }
697
+ }
698
+ }
699
+ return result;
700
+ }
701
+ function workspaceMemberManifests(cargoToml, cargoTomlPath) {
702
+ const workspace = cargoToml.workspace;
703
+ if (typeof workspace !== 'object' || workspace === null) {
704
+ return [];
705
+ }
706
+ const members = workspace.members;
707
+ if (!Array.isArray(members)) {
708
+ return [];
709
+ }
710
+ const workspaceDir = dirname(cargoTomlPath);
711
+ const out = [];
712
+ for (const m of members) {
713
+ if (typeof m === 'string') {
714
+ // `members` entries are globs (`packages/*`); expand them against
715
+ // the filesystem the way cargo does so a member crate's [[bin]] is
716
+ // found even when the workspace declares its members with a pattern.
717
+ for (const memberDir of expandDirGlob(workspaceDir, m)) {
718
+ out.push(join(memberDir, 'Cargo.toml'));
719
+ }
720
+ }
721
+ }
722
+ return out;
723
+ }
724
+ function collectBinsFromManifest(cargoToml) {
725
+ const result = [];
726
+ const bins = cargoToml.bin;
727
+ if (Array.isArray(bins)) {
728
+ for (const entry of bins) {
729
+ if (typeof entry === 'object' && entry !== null) {
730
+ const name = entry.name;
731
+ if (typeof name === 'string') {
732
+ result.push(name);
733
+ }
734
+ }
735
+ }
736
+ }
737
+ // Cargo's implicit-binary rule: a crate without any [[bin]] table
738
+ // ships a binary named after `[package].name` when `src/main.rs`
739
+ // exists. We can't observe the filesystem here without extra reads,
740
+ // but treating the implicit name as a candidate avoids false
741
+ // positives on the common single-bin shape.
742
+ if (result.length === 0) {
743
+ const pkg = cargoToml.package;
744
+ if (pkg && typeof pkg.name === 'string') {
745
+ result.push(pkg.name);
746
+ }
747
+ }
748
+ return result;
749
+ }
750
+ function versionInheritsWorkspace(version) {
751
+ return (typeof version === 'object' &&
752
+ version !== null &&
753
+ version.workspace === true);
754
+ }
755
+ function workspaceVersionDeclared(cargoTomlPath, cwd) {
756
+ // Walk parents until we find a Cargo.toml carrying a [workspace]
757
+ // table; bound the walk at `cwd` so we never escape the repo.
758
+ const cwdAbs = resolve(cwd);
759
+ let cur = dirname(resolve(cargoTomlPath));
760
+ // Step out of the package's own directory first — the package's own
761
+ // Cargo.toml never declares its own workspace root.
762
+ cur = dirname(cur);
763
+ while (true) {
764
+ const candidate = join(cur, 'Cargo.toml');
765
+ const parsed = readToml(candidate);
766
+ if (parsed !== null) {
767
+ const workspace = (parsed.workspace ?? null);
768
+ if (workspace !== null) {
769
+ const wsPkg = (workspace.package ?? {});
770
+ return nonEmptyString(wsPkg.version);
771
+ }
772
+ }
773
+ if (cur === cwdAbs || cur === dirname(cur)) {
774
+ return false;
775
+ }
776
+ cur = dirname(cur);
777
+ }
778
+ }
779
+ // User-facing pointer for the manifest-repository-URL recipe. Surfaced
780
+ // verbatim in the thrown error so the consumer has a copy/paste fix
781
+ // instead of a code-search target.
782
+ const REPO_URL_DOC_POINTER = 'https://thekevinscott.github.io/putitoutthere/guide/repository-url';
783
+ export function checkRepoUrlMatch(packages, options = {}) {
784
+ const expectedOwnerRepo = normalizeOwnerRepo(options.githubRepository);
785
+ if (expectedOwnerRepo === null) {
786
+ return [];
787
+ }
788
+ const findings = [];
789
+ for (const p of packages) {
790
+ const declared = readDeclaredRepoUrl(p);
791
+ if (declared === null) {
792
+ continue;
793
+ }
794
+ const declaredOwnerRepo = parseOwnerRepo(declared.url);
795
+ if (declaredOwnerRepo === null) {
796
+ continue;
797
+ }
798
+ if (declaredOwnerRepo.toLowerCase() === expectedOwnerRepo.toLowerCase()) {
799
+ continue;
800
+ }
801
+ findings.push({
802
+ package: p.name,
803
+ manifestPath: declared.manifestPath,
804
+ declaredOwnerRepo,
805
+ expectedOwnerRepo,
806
+ declaredUrl: declared.url,
807
+ });
808
+ }
809
+ return findings;
810
+ }
811
+ export function requireRepoUrlMatch(packages, options = {}) {
812
+ const findings = checkRepoUrlMatch(packages, options);
813
+ if (findings.length === 0) {
814
+ return;
815
+ }
816
+ const lines = [
817
+ `[${ErrorCodes.REPO_URL_MISMATCH}] manifest repository URL does not match GITHUB_REPOSITORY.`,
818
+ '',
819
+ 'Failing packages:',
820
+ ];
821
+ for (const f of findings) {
822
+ lines.push(` - ${f.package}: ${f.manifestPath}`, ` declared: ${f.declaredOwnerRepo} (${f.declaredUrl})`, ` expected: ${f.expectedOwnerRepo} (from GITHUB_REPOSITORY)`);
823
+ }
824
+ lines.push('', 'Why: npm verifies `package.json#repository.url` against the OIDC source claim', 'before accepting a `--provenance` publish and rejects mismatches with a 422', 'after the artifact has already been uploaded. crates.io and PyPI carry the same', 'risk on their trusted-publisher paths.', '', 'Fix: either update the manifest URL to match the GitHub repository this workflow', 'is running from, or rename the GitHub repository so the slugs line up. Run the', 'release again once the manifest and the repo agree.', '', `See ${REPO_URL_DOC_POINTER}.`);
825
+ throw new Error(lines.join('\n'));
826
+ }
827
+ function readDeclaredRepoUrl(p) {
828
+ switch (p.kind) {
829
+ case 'npm': {
830
+ const manifestPath = join(p.path, 'package.json');
831
+ const parsed = readJson(manifestPath);
832
+ if (parsed === null) {
833
+ return null;
834
+ }
835
+ const repository = parsed.repository;
836
+ if (typeof repository === 'string' && repository.trim().length > 0) {
837
+ return { url: repository.trim(), manifestPath };
838
+ }
839
+ if (repository !== null && typeof repository === 'object') {
840
+ const url = repository.url;
841
+ if (typeof url === 'string' && url.trim().length > 0) {
842
+ return { url: url.trim(), manifestPath };
843
+ }
844
+ }
845
+ return null;
846
+ }
847
+ case 'crates': {
848
+ const manifestPath = join(p.path, 'Cargo.toml');
849
+ const parsed = readTomlDoc(manifestPath);
850
+ if (parsed === null) {
851
+ return null;
852
+ }
853
+ const pkgTable = (parsed.package ?? {});
854
+ const repo = pkgTable.repository;
855
+ if (typeof repo === 'string' && repo.trim().length > 0) {
856
+ return { url: repo.trim(), manifestPath };
857
+ }
858
+ return null;
859
+ }
860
+ case 'pypi': {
861
+ const manifestPath = join(p.path, 'pyproject.toml');
862
+ const parsed = readTomlDoc(manifestPath);
863
+ if (parsed === null) {
864
+ return null;
865
+ }
866
+ const project = (parsed.project ?? {});
867
+ const urls = (project.urls ?? {});
868
+ // PEP 621 leaves the key casing to the project, and PyPI normalises
869
+ // common labels case-insensitively. Accept the canonical "Repository"
870
+ // first and fall back to common synonyms (source-code repos vs.
871
+ // homepage links) so a project picking any reasonable label still
872
+ // gets the check.
873
+ for (const key of ['Repository', 'repository', 'Source', 'source', 'Homepage', 'homepage']) {
874
+ const candidate = urls[key];
875
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
876
+ return { url: candidate.trim(), manifestPath };
877
+ }
878
+ }
879
+ return null;
880
+ }
881
+ }
882
+ }
883
+ function readJson(path) {
884
+ let raw;
885
+ try {
886
+ raw = readFileSync(path, 'utf8');
887
+ }
888
+ catch {
889
+ return null;
890
+ }
891
+ try {
892
+ return JSON.parse(raw);
893
+ }
894
+ catch {
895
+ return null;
896
+ }
897
+ }
898
+ function readTomlDoc(path) {
899
+ let raw;
900
+ try {
901
+ raw = readFileSync(path, 'utf8');
902
+ }
903
+ catch {
904
+ return null;
905
+ }
906
+ try {
907
+ return parseToml(raw);
908
+ }
909
+ catch {
910
+ return null;
911
+ }
912
+ }
913
+ function normalizeOwnerRepo(value) {
914
+ if (value === undefined) {
915
+ return null;
916
+ }
917
+ const trimmed = value.trim();
918
+ if (trimmed.length === 0) {
919
+ return null;
920
+ }
921
+ // Tolerate accidental wrapping (e.g. `https://github.com/owner/repo`
922
+ // landed in the GITHUB_REPOSITORY env var by misconfiguration); the
923
+ // GHA-provided value is always `owner/repo` so this is defence in
924
+ // depth, not a documented surface.
925
+ const slugMatch = trimmed.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
926
+ if (slugMatch) {
927
+ return `${slugMatch[1]}/${slugMatch[2]}`;
928
+ }
929
+ return parseOwnerRepo(trimmed);
930
+ }
931
+ // Recognises the canonical GitHub URL shapes the npm / cargo / hatch
932
+ // ecosystems serialise into manifests:
933
+ // - git+https://github.com/owner/repo(.git)?
934
+ // - https://github.com/owner/repo(.git)?(/)?
935
+ // - http://github.com/owner/repo(.git)?(/)?
936
+ // - git@github.com:owner/repo(.git)?
937
+ // - ssh://git@github.com/owner/repo(.git)?
938
+ // Non-github hosts return null; the check skips those packages rather
939
+ // than false-positive on legitimately-hosted forks (provenance still
940
+ // catches them at publish time).
941
+ function parseOwnerRepo(url) {
942
+ const stripped = url.trim().replace(/^git\+/i, '');
943
+ const match = stripped.match(/github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/);
944
+ if (match === null) {
945
+ return null;
946
+ }
947
+ return `${match[1]}/${match[2]}`;
948
+ }
949
+ const REPO_VISIBILITY_DOC_POINTER = 'https://thekevinscott.github.io/putitoutthere/guide/public-repo';
950
+ export async function checkRepoPublic(options = {}) {
951
+ const githubRepository = options.githubRepository?.trim();
952
+ if (githubRepository === undefined || githubRepository.length === 0) {
953
+ return null;
954
+ }
955
+ // Fall back to the raw value if normalization fails; the API call
956
+ // will 404 and the check will report `not-found-or-private`, which
957
+ // is the right diagnosis for a malformed slug we can't disambiguate.
958
+ const apiSlug = normalizeOwnerRepo(githubRepository) ?? githubRepository;
959
+ const fetchImpl = options.fetchImpl ?? fetch;
960
+ const headers = {
961
+ accept: 'application/vnd.github+json',
962
+ 'user-agent': 'putitoutthere',
963
+ };
964
+ if (options.githubToken !== undefined && options.githubToken.length > 0) {
965
+ headers.authorization = `Bearer ${options.githubToken}`;
966
+ }
967
+ const url = `https://api.github.com/repos/${apiSlug}`;
968
+ let res;
969
+ try {
970
+ res = await fetchImpl(url, { method: 'GET', headers });
971
+ }
972
+ catch (err) {
973
+ // A network failure says nothing about repository visibility.
974
+ // Treat it as indeterminate rather than blocking the release.
975
+ warnIndeterminate(apiSlug, `request failed (${err.message})`);
976
+ return null;
977
+ }
978
+ if (res.status === 404) {
979
+ return { githubRepository: apiSlug, reason: 'not-found-or-private' };
980
+ }
981
+ if (res.status === 200) {
982
+ const body = (await res.json());
983
+ const isPrivate = body.private === true ||
984
+ (typeof body.visibility === 'string' && body.visibility !== 'public');
985
+ if (isPrivate) {
986
+ return { githubRepository: apiSlug, reason: 'private' };
987
+ }
988
+ return null;
989
+ }
990
+ // Any other status (most commonly a 403 from an unauthenticated
991
+ // rate-limited call, or a transient 5xx) tells us nothing about
992
+ // visibility. Blocking the publish on "we couldn't reach the API"
993
+ // is the kind of release surprise this engine exists to prevent —
994
+ // treat it as indeterminate and non-fatal, with a visible warning.
995
+ warnIndeterminate(apiSlug, `GitHub API returned ${res.status}`);
996
+ return null;
997
+ }
998
+ function warnIndeterminate(apiSlug, detail) {
999
+ process.stdout.write(`::warning::repository visibility check skipped for ${apiSlug}: ${detail}\n`);
1000
+ }
1001
+ export async function requireRepoPublic(options = {}) {
1002
+ const finding = await checkRepoPublic(options);
1003
+ if (finding === null) {
1004
+ return;
1005
+ }
1006
+ const lines = [
1007
+ `[${ErrorCodes.REPO_PRIVATE}] refusing to publish from a private GitHub repository (${finding.githubRepository}).`,
1008
+ '',
1009
+ finding.reason === 'private'
1010
+ ? 'The GitHub API reports this repository as private.'
1011
+ : 'The GitHub API returned 404 for this repository slug — either the repo is private and the configured token lacks access, or the slug does not exist.',
1012
+ '',
1013
+ 'Why: npm provenance attestations embed a public source-ref pointer that',
1014
+ 'consumers cannot dereference on a private repo, and the same source-visibility',
1015
+ 'expectation underpins the trusted-publisher story across the registries we',
1016
+ 'publish to. Publishing from a private repo silently degrades every consumer\'s',
1017
+ 'ability to verify the artifact.',
1018
+ '',
1019
+ 'Fix: make the repository public before publishing, or unset the release',
1020
+ 'trigger until visibility flips. See',
1021
+ ` ${REPO_VISIBILITY_DOC_POINTER}`,
1022
+ 'for the trusted-publisher rationale and the registry-specific failure modes',
1023
+ 'this check exists to prevent.',
1024
+ ];
1025
+ throw new Error(lines.join('\n'));
1026
+ }
1027
+ export function requireCargoShape(packages, options = {}) {
1028
+ const findings = checkCargoShape(packages, options);
1029
+ if (findings.length === 0) {
1030
+ return;
1031
+ }
1032
+ const lines = [
1033
+ 'Pre-flight Cargo.toml shape check failed:',
1034
+ '',
1035
+ ];
1036
+ for (const f of findings) {
1037
+ lines.push(` [${f.code}] ${f.package}: ${f.cargoTomlPath}`);
1038
+ lines.push(` ${f.detail}`);
1039
+ }
1040
+ lines.push('', 'Why: these mismatches would otherwise surface mid-`cargo build` or mid-`cargo', 'publish` after the verification build has compiled the crate and every transitive', 'dep — wasting the publish job on a precondition checkable in milliseconds.', 'Fix the Cargo.toml + putitoutthere.toml fields in this list, then re-run.');
1041
+ throw new Error(lines.join('\n'));
1042
+ }
1043
+ export function checkPackageJsonShape(packages) {
1044
+ const findings = [];
1045
+ for (const p of packages) {
1046
+ if (p.kind !== 'npm') {
1047
+ continue;
1048
+ }
1049
+ const packageJsonPath = join(p.path, 'package.json');
1050
+ let raw;
1051
+ try {
1052
+ raw = readFileSync(packageJsonPath, 'utf8');
1053
+ }
1054
+ catch {
1055
+ // Missing package.json is a different failure surface:
1056
+ // `checkProvenanceMetadata` reports it, and `check.ts` surfaces it
1057
+ // at PR time. Skip.
1058
+ continue;
1059
+ }
1060
+ let parsed;
1061
+ try {
1062
+ parsed = JSON.parse(raw);
1063
+ }
1064
+ catch {
1065
+ // Malformed package.json — let the publish step surface its own
1066
+ // diagnostic. This check only owns the `name` field.
1067
+ continue;
1068
+ }
1069
+ const expectedName = p.npm ?? p.name;
1070
+ if (typeof parsed.name === 'string' && parsed.name !== expectedName) {
1071
+ findings.push({
1072
+ package: p.name,
1073
+ packageJsonPath,
1074
+ code: 'PIOT_NPM_NAME_MISMATCH',
1075
+ detail: `name = "${parsed.name}" but configured name is "${expectedName}"`,
1076
+ });
1077
+ }
1078
+ }
1079
+ return findings;
1080
+ }
1081
+ export function requirePackageJsonShape(packages) {
1082
+ const findings = checkPackageJsonShape(packages);
1083
+ if (findings.length === 0) {
1084
+ return;
1085
+ }
1086
+ const lines = [
1087
+ 'Pre-flight package.json shape check failed:',
1088
+ '',
1089
+ ];
1090
+ for (const f of findings) {
1091
+ lines.push(` [${f.code}] ${f.package}: ${f.packageJsonPath}`);
1092
+ lines.push(` ${f.detail}`);
1093
+ }
1094
+ lines.push('', 'Why: `npm publish` packs the `name` from package.json, but the engine\'s', 'idempotency probe (`npm view <name>`) and the tag/URL bookkeeping use the', 'configured [[package]].name (or the `npm` override) — a divergence breaks', 'idempotency and can publish under an unexpected name.', 'Fix the package.json `name` or set the `npm` override in putitoutthere.toml, then re-run.');
1095
+ throw new Error(lines.join('\n'));
1096
+ }
415
1097
  //# sourceMappingURL=preflight.js.map