putitoutthere 0.2.28 → 0.2.30
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/CHANGELOG.md +40 -0
- package/MIGRATIONS.md +1274 -0
- package/README.md +343 -41
- package/action.yml +9 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +28 -6
- package/dist/action.js.map +1 -1
- package/dist/cascade.js +14 -7
- package/dist/cascade.js.map +1 -1
- package/dist/check-crate-size.d.ts +26 -0
- package/dist/check-crate-size.d.ts.map +1 -0
- package/dist/check-crate-size.js +99 -0
- package/dist/check-crate-size.js.map +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +116 -16
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +17 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +156 -19
- package/dist/cli.js.map +1 -1
- package/dist/completeness.js +6 -3
- package/dist/completeness.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -5
- package/dist/config.js.map +1 -1
- package/dist/ensure-tag.d.ts +17 -0
- package/dist/ensure-tag.d.ts.map +1 -0
- package/dist/ensure-tag.js +28 -0
- package/dist/ensure-tag.js.map +1 -0
- package/dist/env.js +6 -3
- package/dist/env.js.map +1 -1
- package/dist/error-codes.d.ts +89 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +102 -0
- package/dist/error-codes.js.map +1 -1
- package/dist/git.d.ts +5 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +19 -6
- package/dist/git.js.map +1 -1
- package/dist/glob.d.ts +9 -0
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +29 -1
- package/dist/glob.js.map +1 -1
- package/dist/handlers/crates.d.ts +15 -0
- package/dist/handlers/crates.d.ts.map +1 -1
- package/dist/handlers/crates.js +84 -9
- package/dist/handlers/crates.js.map +1 -1
- package/dist/handlers/npm-platform.d.ts +11 -0
- package/dist/handlers/npm-platform.d.ts.map +1 -1
- package/dist/handlers/npm-platform.js +91 -8
- package/dist/handlers/npm-platform.js.map +1 -1
- package/dist/handlers/npm.d.ts.map +1 -1
- package/dist/handlers/npm.js +59 -9
- package/dist/handlers/npm.js.map +1 -1
- package/dist/handlers/pypi.d.ts +0 -6
- package/dist/handlers/pypi.d.ts.map +1 -1
- package/dist/handlers/pypi.js +28 -10
- package/dist/handlers/pypi.js.map +1 -1
- package/dist/log.js +16 -8
- package/dist/log.js.map +1 -1
- package/dist/normalize-artifacts.js +8 -4
- package/dist/normalize-artifacts.js.map +1 -1
- package/dist/plan.d.ts +8 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +125 -25
- package/dist/plan.js.map +1 -1
- package/dist/preflight.d.ts +79 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +715 -33
- package/dist/preflight.js.map +1 -1
- package/dist/publish.d.ts +7 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +49 -18
- package/dist/publish.js.map +1 -1
- package/dist/python-versions.d.ts +40 -0
- package/dist/python-versions.d.ts.map +1 -0
- package/dist/python-versions.js +142 -0
- package/dist/python-versions.js.map +1 -0
- package/dist/reconcile-types.d.ts +36 -0
- package/dist/reconcile-types.d.ts.map +1 -0
- package/dist/reconcile-types.js +9 -0
- package/dist/reconcile-types.js.map +1 -0
- package/dist/reconcile.d.ts +21 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +54 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/release-packages.d.ts +33 -0
- package/dist/release-packages.d.ts.map +1 -0
- package/dist/release-packages.js +74 -0
- package/dist/release-packages.js.map +1 -0
- package/dist/resolve-tag-commit.d.ts +22 -0
- package/dist/resolve-tag-commit.d.ts.map +1 -0
- package/dist/resolve-tag-commit.js +26 -0
- package/dist/resolve-tag-commit.js.map +1 -0
- package/dist/retry.js +14 -7
- package/dist/retry.js.map +1 -1
- package/dist/status-classify.d.ts +17 -0
- package/dist/status-classify.d.ts.map +1 -0
- package/dist/status-classify.js +37 -0
- package/dist/status-classify.js.map +1 -0
- package/dist/status-format.d.ts +9 -0
- package/dist/status-format.d.ts.map +1 -0
- package/dist/status-format.js +19 -0
- package/dist/status-format.js.map +1 -0
- package/dist/status-types.d.ts +31 -0
- package/dist/status-types.d.ts.map +1 -0
- package/dist/status-types.js +8 -0
- package/dist/status-types.js.map +1 -0
- package/dist/status.d.ts +28 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +72 -0
- package/dist/status.js.map +1 -0
- package/dist/tag-template.js +2 -1
- package/dist/tag-template.js.map +1 -1
- package/dist/trailer.js +16 -9
- package/dist/trailer.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -2
- package/dist/types.js.map +1 -1
- package/dist/verbose.js +7 -4
- package/dist/verbose.js.map +1 -1
- package/dist/wheel-abi.d.ts +37 -0
- package/dist/wheel-abi.d.ts.map +1 -0
- package/dist/wheel-abi.js +118 -0
- package/dist/wheel-abi.js.map +1 -0
- package/dist/write-crate-version.d.ts +32 -0
- package/dist/write-crate-version.d.ts.map +1 -0
- package/dist/write-crate-version.js +52 -0
- package/dist/write-crate-version.js.map +1 -0
- package/dist/write-launcher.d.ts +82 -0
- package/dist/write-launcher.d.ts.map +1 -0
- package/dist/write-launcher.js +236 -0
- package/dist/write-launcher.js.map +1 -0
- package/dist/write-version.js +2 -1
- package/dist/write-version.js.map +1 -1
- 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
|
-
|
|
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(
|
|
362
|
+
parsed = parseToml(readFileSync(manifest, 'utf8'));
|
|
363
363
|
}
|
|
364
364
|
catch {
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
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
|