From eacec8b9227d90f2238783a407881e3c1d85a771 Mon Sep 17 00:00:00 2001 From: Evilander <98758891+Evilander@users.noreply.github.com> Date: Wed, 13 May 2026 19:29:45 -0500 Subject: [PATCH 1/2] Harden PyPI release readiness after publish --- scripts/verify-release-readiness.mjs | 56 +++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/verify-release-readiness.mjs b/scripts/verify-release-readiness.mjs index 37e2229..500ca3b 100644 --- a/scripts/verify-release-readiness.mjs +++ b/scripts/verify-release-readiness.mjs @@ -16,6 +16,7 @@ const ROOT = process.cwd(); const DEFAULT_TARGET_VERSION = '1.0.0'; const PYPI_CREDENTIAL_ENVS = ['TWINE_PASSWORD', 'PYPI_API_TOKEN', 'UV_PUBLISH_TOKEN']; const NPM_REGISTRY = 'https://registry.npmjs.org/'; +const PYPI_JSON_BASE = 'https://pypi.org/pypi'; function fromRoot(path) { return resolve(ROOT, path); @@ -35,6 +36,7 @@ function parseArgs(argv = process.argv.slice(2)) { const args = { targetVersion: DEFAULT_TARGET_VERSION, allowPending: false, + checkPypiRegistry: true, json: false, }; @@ -42,6 +44,7 @@ function parseArgs(argv = process.argv.slice(2)) { const token = argv[i]; if ((token === '--target-version' || token === '--version') && argv[i + 1]) args.targetVersion = argv[++i]; else if (token === '--allow-pending') args.allowPending = true; + else if (token === '--skip-pypi-registry') args.checkPypiRegistry = false; else if (token === '--json') args.json = true; else if (token === '--help' || token === '-h') args.help = true; else throw new Error(`Unknown argument: ${token}`); @@ -56,6 +59,7 @@ function usage() { Options: --target-version Target release version. Default: ${DEFAULT_TARGET_VERSION}. --allow-pending Exit 0 when only publish/account/credential blockers remain. + --skip-pypi-registry Do not check whether the target PyPI version is already public. --json Print the machine-readable readiness report. `; } @@ -501,10 +505,21 @@ function pythonDistCheck(targetVersion) { ); } -function pypiPublishCheck(targetVersion) { - const pyproject = readText('python/pyproject.toml'); - const packageName = pyproject.match(/^name\s*=\s*"([^"]+)"/m)?.[1] ?? 'unknown'; - const version = pythonVersion(); +async function pypiRegistryVersionStatus(packageName, targetVersion, fetchImpl = fetch) { + try { + const response = await fetchImpl(`${PYPI_JSON_BASE}/${encodeURIComponent(packageName)}/${encodeURIComponent(targetVersion)}/json`, { + headers: { accept: 'application/json' }, + }); + if (response.ok) return { ok: true, published: true, status: response.status }; + if (response.status === 404) return { ok: true, published: false, status: response.status }; + return { ok: false, published: false, status: response.status, error: `PyPI returned HTTP ${response.status}` }; + } catch (error) { + return { ok: false, published: false, status: 'network-error', error: error.message }; + } +} + +export async function pypiPackageTargetStatus({ packageName, version }, targetVersion, options = {}) { + const env = options.env ?? process.env; const evidence = [`python package=${packageName}`, `python version=${version ?? 'missing'}`]; if (version !== targetVersion) { @@ -516,7 +531,26 @@ function pypiPublishCheck(targetVersion) { ); } - const credentialEnv = PYPI_CREDENTIAL_ENVS.find(name => Boolean(process.env[name])); + if (options.checkRegistry === true) { + const registry = await pypiRegistryVersionStatus(packageName, targetVersion, options.fetchImpl); + if (registry.ok && registry.published) { + return ok('pypi-package-target', `PyPI package is already published as ${targetVersion}`, [ + ...evidence, + `registry=${packageName}==${targetVersion}`, + ]); + } + if (!registry.ok) { + return pending( + 'pypi-package-target', + `PyPI package is ready to publish as ${targetVersion}`, + evidence, + [`Verify PyPI registry availability before publishing (${registry.error ?? `status=${registry.status}`})`], + ); + } + + evidence.push(`registry=${packageName}==${targetVersion}:unpublished`); + } + const credentialEnv = PYPI_CREDENTIAL_ENVS.find(name => Boolean(env[name])); if (!credentialEnv) { return pending( 'pypi-package-target', @@ -529,6 +563,16 @@ function pypiPublishCheck(targetVersion) { return ok('pypi-package-target', `PyPI package is ready to publish as ${targetVersion}`, [...evidence, `credentialEnv=${credentialEnv}`]); } +async function pypiPublishCheck(targetVersion, options = {}) { + const pyproject = readText('python/pyproject.toml'); + const packageName = pyproject.match(/^name\s*=\s*"([^"]+)"/m)?.[1] ?? 'unknown'; + return pypiPackageTargetStatus( + { packageName, version: pythonVersion() }, + targetVersion, + { checkRegistry: options.checkPypiRegistry === true }, + ); +} + async function paperChecks() { const claimReport = await verifyPaperClaims(); const publicationPackReport = await verifyPublicationPack(); @@ -688,7 +732,7 @@ export async function verifyReleaseReadiness(options = {}) { await browserPublicationCheck(), await externalEvidenceCheck(), packageDryRunCheck(targetVersion), - pypiPublishCheck(targetVersion), + await pypiPublishCheck(targetVersion, options), ]; const failures = checks.flatMap(row => row.failures.map(failure => `${row.id}: ${failure}`)); const blockers = checks.flatMap(row => row.blockers.map(blocker => `${row.id}: ${blocker}`)); From c50f8fedf6394c93959ee2b3891f560b959f7e15 Mon Sep 17 00:00:00 2001 From: Evilander <98758891+Evilander@users.noreply.github.com> Date: Wed, 13 May 2026 19:30:50 -0500 Subject: [PATCH 2/2] Test PyPI release readiness registry state --- tests/release-readiness-pypi.test.js | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/release-readiness-pypi.test.js diff --git a/tests/release-readiness-pypi.test.js b/tests/release-readiness-pypi.test.js new file mode 100644 index 0000000..cb5539e --- /dev/null +++ b/tests/release-readiness-pypi.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { pypiPackageTargetStatus } from '../scripts/verify-release-readiness.mjs'; + +describe('PyPI release readiness', () => { + it('keeps publish readiness pending when registry checks are disabled and credentials are absent', async () => { + const report = await pypiPackageTargetStatus( + { packageName: 'audrey-memory', version: '1.0.0' }, + '1.0.0', + { env: {} }, + ); + + expect(report.status).toBe('pending'); + expect(report.blockers.join('\n')).toContain('Provide runtime PyPI publish credentials'); + }); + + it('keeps publish readiness pending when the target version is unpublished and credentials are absent', async () => { + const report = await pypiPackageTargetStatus( + { packageName: 'audrey-memory', version: '1.0.0' }, + '1.0.0', + { checkRegistry: true, env: {}, fetchImpl: async () => ({ ok: false, status: 404 }) }, + ); + + expect(report.status).toBe('pending'); + expect(report.evidence).toContain('registry=audrey-memory==1.0.0:unpublished'); + expect(report.blockers.join('\n')).toContain('Provide runtime PyPI publish credentials'); + }); + + it('passes publish readiness when the target version is already on PyPI', async () => { + const report = await pypiPackageTargetStatus( + { packageName: 'audrey-memory', version: '1.0.0' }, + '1.0.0', + { checkRegistry: true, env: {}, fetchImpl: async () => ({ ok: true, status: 200 }) }, + ); + + expect(report.status).toBe('passed'); + expect(report.evidence).toContain('registry=audrey-memory==1.0.0'); + }); +});