Add npm/PyPI package publishing access management (config + drift detection) (#169)
* Add npm/PyPI package publishing access management (config + drift detection) Declare expected npm org membership, per-package maintainers and trusted publishers, and PyPI project rosters in src/config/packageAccess.ts, with npm/pypi identity fields on members in users.ts. Deliberately outside the Pulumi resource graph: npm governance writes require an interactive 2FA challenge since August 2026 (read endpoints still work with a granular access token), and PyPI has no management API at all. Drift against the live npm registry is detected read-only by scripts/check-package-drift.ts (weekly via package-drift.yml, no-op when the NPM_READ_TOKEN secret is absent), which emits a remediation plan of npm CLI commands for a maintainer to run in one 2FA-approved session. PyPI procedures are documented as a manual runbook in the README. * Encode all slashes when escaping package names in registry URLs Fixes the CodeQL finding: String.replace with a string pattern only replaces the first occurrence. Use a shared helper with a global regex, following the npm CLI convention (keep '@' literal, encode every '/'). --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ca10c555cc
commit
f17de4044a
3 changed files with 766 additions and 0 deletions
313
scripts/check-package-drift.ts
Normal file
313
scripts/check-package-drift.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Compares live npm registry state against the expected state declared in
|
||||
* src/config/packageAccess.ts and prints a drift report plus a remediation
|
||||
* plan of npm CLI commands for a maintainer to run in a single interactive
|
||||
* 2FA-authenticated session (see "npm & PyPI Package Publishing Access" in
|
||||
* the README).
|
||||
*
|
||||
* Read-only: this script never mutates anything. All npm write operations
|
||||
* require an interactive 2FA challenge (since August 2026), which is why the
|
||||
* remediation is a human-executed plan instead of automation. PyPI has no
|
||||
* management API at all and is not checked here.
|
||||
*
|
||||
* Run with: NPM_TOKEN=<read-only granular access token> npx ts-node scripts/check-package-drift.ts
|
||||
*
|
||||
* - Without NPM_TOKEN: prints a skip notice and exits 0 (so CI without the
|
||||
* secret is a graceful no-op, mirroring the optional Discord credentials).
|
||||
* - With NPM_TOKEN: exits 1 when drift is found, 0 when clean.
|
||||
*/
|
||||
|
||||
import {
|
||||
NPM_ORG,
|
||||
NPM_PACKAGES,
|
||||
NPM_DEFAULT_POLICY,
|
||||
getNpmPackageAccess,
|
||||
getExpectedNpmOrgMembers,
|
||||
} from '../src/config/packageAccess';
|
||||
|
||||
const REGISTRY = 'https://registry.npmjs.org';
|
||||
const REQUEST_DELAY_MS = 150;
|
||||
|
||||
const token = process.env.NPM_TOKEN;
|
||||
|
||||
interface Drift {
|
||||
description: string;
|
||||
/** Remediation lines; lines starting with '#' are guidance, not commands */
|
||||
commands: readonly string[];
|
||||
}
|
||||
|
||||
const drifts: Drift[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function registryGet(path: string): Promise<{ status: number; body: unknown }> {
|
||||
const url = `${REGISTRY}${path}`;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt > 0) await sleep(2000 * attempt);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.status >= 500) {
|
||||
lastError = new Error(`HTTP ${response.status} from ${url}`);
|
||||
continue;
|
||||
}
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON body (e.g. empty 404) — leave as null
|
||||
}
|
||||
return { status: response.status, body };
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a package name for use as a registry URL path segment. Follows the
|
||||
* npm CLI's convention for scoped packages: keep the leading '@' literal and
|
||||
* percent-encode every '/'.
|
||||
*/
|
||||
function escapePackageName(packageName: string): string {
|
||||
return packageName.replace(/\//g, '%2F');
|
||||
}
|
||||
|
||||
function diffSets(
|
||||
expected: readonly string[],
|
||||
actual: readonly string[]
|
||||
): { missing: string[]; unexpected: string[] } {
|
||||
const expectedSet = new Set(expected);
|
||||
const actualSet = new Set(actual);
|
||||
return {
|
||||
missing: expected.filter((e) => !actualSet.has(e)).sort(),
|
||||
unexpected: actual.filter((a) => !expectedSet.has(a)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOrgMembership(): Promise<void> {
|
||||
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/user`);
|
||||
if (status !== 200 || typeof body !== 'object' || body === null) {
|
||||
warnings.push(
|
||||
`Could not read org roster (GET /-/org/${NPM_ORG}/user returned ${status}). ` +
|
||||
`The token likely lacks organization read access — org membership was NOT checked.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const roster = body as Record<string, string>;
|
||||
const actual = Object.keys(roster);
|
||||
if (actual.length === 0) {
|
||||
warnings.push(
|
||||
`Org roster came back empty — unauthenticated requests see an empty roster, ` +
|
||||
`so the token likely lacks organization access. Org membership was NOT checked.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { missing, unexpected } = diffSets(getExpectedNpmOrgMembers(), actual);
|
||||
for (const user of missing) {
|
||||
drifts.push({
|
||||
description: `Org member missing: "${user}" is expected in the "${NPM_ORG}" org but is not a member`,
|
||||
commands: [`npm org set ${NPM_ORG} ${user} developer`],
|
||||
});
|
||||
}
|
||||
for (const user of unexpected) {
|
||||
drifts.push({
|
||||
description:
|
||||
`Unexpected org member: "${user}" (role: ${roster[user]}) is in the "${NPM_ORG}" org ` +
|
||||
`but not declared in packageAccess.ts/users.ts`,
|
||||
commands: [
|
||||
`# Either add npm: '${user}' to the right member in src/config/users.ts (or UNMAPPED_NPM_USERS), or remove them:`,
|
||||
`npm org rm ${NPM_ORG} ${user}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function listOrgPackages(): Promise<string[]> {
|
||||
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/package`);
|
||||
if (status === 200 && typeof body === 'object' && body !== null) {
|
||||
return Object.keys(body as Record<string, string>).sort();
|
||||
}
|
||||
warnings.push(
|
||||
`Could not enumerate org packages (GET /-/org/${NPM_ORG}/package returned ${status}); ` +
|
||||
`falling back to the ${NPM_PACKAGES.length} explicitly declared packages.`
|
||||
);
|
||||
return NPM_PACKAGES.map((p) => p.package);
|
||||
}
|
||||
|
||||
/** Extract candidate trusted-publisher configs from a /trust response, defensively. */
|
||||
function extractTrustConfigs(body: unknown): Array<Record<string, unknown>> {
|
||||
if (body === null || typeof body !== 'object') return [];
|
||||
if (Array.isArray(body)) return body.filter((c) => typeof c === 'object' && c !== null);
|
||||
const obj = body as Record<string, unknown>;
|
||||
for (const key of ['objects', 'configurations', 'trustedPublishers', 'trust']) {
|
||||
if (Array.isArray(obj[key])) {
|
||||
return (obj[key] as unknown[]).filter(
|
||||
(c): c is Record<string, unknown> => typeof c === 'object' && c !== null
|
||||
);
|
||||
}
|
||||
}
|
||||
return [obj];
|
||||
}
|
||||
|
||||
async function checkPackage(packageName: string): Promise<void> {
|
||||
const expected = getNpmPackageAccess(packageName);
|
||||
const isExplicit = NPM_PACKAGES.some((p) => p.package === packageName);
|
||||
|
||||
// 1) Maintainers, from the public package document
|
||||
const { status, body } = await registryGet(`/${escapePackageName(packageName)}`);
|
||||
if (status !== 200 || typeof body !== 'object' || body === null) {
|
||||
warnings.push(`Could not read package document for ${packageName} (HTTP ${status}).`);
|
||||
return;
|
||||
}
|
||||
const packument = body as {
|
||||
maintainers?: Array<{ name?: string }>;
|
||||
'dist-tags'?: Record<string, string>;
|
||||
versions?: Record<string, { _npmUser?: { trustedPublisher?: unknown } }>;
|
||||
};
|
||||
|
||||
const actualMaintainers = (packument.maintainers ?? [])
|
||||
.map((m) => m.name)
|
||||
.filter((name): name is string => typeof name === 'string');
|
||||
const { missing, unexpected } = diffSets(expected.maintainers, actualMaintainers);
|
||||
for (const user of missing) {
|
||||
drifts.push({
|
||||
description: `Maintainer missing on ${packageName}: "${user}"`,
|
||||
commands: [`npm owner add ${user} ${packageName}`],
|
||||
});
|
||||
}
|
||||
for (const user of unexpected) {
|
||||
drifts.push({
|
||||
description:
|
||||
`Unexpected maintainer on ${packageName}: "${user}" ` +
|
||||
`(not in its ${isExplicit ? 'declared maintainers' : 'default-policy maintainers'})`,
|
||||
commands: [
|
||||
`# Either declare "${user}" for this package in src/config/packageAccess.ts, or remove them:`,
|
||||
`npm owner rm ${user} ${packageName}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Trusted publishing of the latest release (default policy)
|
||||
const latestVersion = packument['dist-tags']?.latest;
|
||||
const latest = latestVersion ? packument.versions?.[latestVersion] : undefined;
|
||||
if (NPM_DEFAULT_POLICY.requireTrustedPublishing && latest && !latest._npmUser?.trustedPublisher) {
|
||||
drifts.push({
|
||||
description:
|
||||
`${packageName}@${latestVersion} was not published via trusted publishing ` +
|
||||
`(policy: all org packages publish via OIDC)`,
|
||||
commands: [
|
||||
`# Configure a trusted publisher for ${packageName} (web UI: package Settings -> Trusted publishing,`,
|
||||
`# or \`npm trust\` on npm >= 11.15.0), then stop using publish tokens for it.`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Declared trusted-publisher configuration (explicit packages only)
|
||||
if (expected.trustedPublisher) {
|
||||
const want = expected.trustedPublisher;
|
||||
const trust = await registryGet(`/-/package/${escapePackageName(packageName)}/trust`);
|
||||
if (trust.status === 401 || trust.status === 403) {
|
||||
warnings.push(
|
||||
`Cannot read trusted-publisher config for ${packageName} (HTTP ${trust.status}); ` +
|
||||
`token lacks permission — declared publisher (${want.repository} ${want.workflow}) was NOT verified.`
|
||||
);
|
||||
} else if (trust.status === 404 || extractTrustConfigs(trust.body).length === 0) {
|
||||
drifts.push({
|
||||
description: `No trusted publisher configured on ${packageName} (expected ${want.repository} via ${want.workflow})`,
|
||||
commands: [
|
||||
`# Configure trusted publishing for ${packageName}: GitHub Actions, repository ${want.repository},`,
|
||||
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
|
||||
],
|
||||
});
|
||||
} else if (trust.status === 200) {
|
||||
const configs = extractTrustConfigs(trust.body);
|
||||
const matches = configs.some((c) => {
|
||||
const text = JSON.stringify(c);
|
||||
const repoOk =
|
||||
text.includes(want.repository) ||
|
||||
(text.includes(want.repository.split('/')[0]) &&
|
||||
text.includes(want.repository.split('/')[1]));
|
||||
const workflowFile = want.workflow.split('/').pop() ?? want.workflow;
|
||||
return repoOk && text.includes(workflowFile);
|
||||
});
|
||||
if (!matches) {
|
||||
drifts.push({
|
||||
description:
|
||||
`Trusted publisher mismatch on ${packageName}: expected ${want.repository} via ${want.workflow}, ` +
|
||||
`live config differs: ${JSON.stringify(configs)}`,
|
||||
commands: [
|
||||
`# Update trusted publishing for ${packageName} to repository ${want.repository},`,
|
||||
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!token) {
|
||||
console.log('NPM_TOKEN is not set — skipping npm package access drift check.');
|
||||
console.log(
|
||||
'Provide a read-only granular access token with organization read access to enable it.'
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Checking npm package access drift for org "${NPM_ORG}"...\n`);
|
||||
|
||||
await checkOrgMembership();
|
||||
|
||||
const packages = await listOrgPackages();
|
||||
console.log(`Checking ${packages.length} packages...`);
|
||||
const declared = new Set(NPM_PACKAGES.map((p) => p.package));
|
||||
const notInOrg = [...declared].filter((p) => !packages.includes(p));
|
||||
for (const pkg of notInOrg) {
|
||||
warnings.push(`Declared package ${pkg} was not found in the org package list.`);
|
||||
}
|
||||
for (const pkg of packages) {
|
||||
await checkPackage(pkg);
|
||||
await sleep(REQUEST_DELAY_MS);
|
||||
}
|
||||
|
||||
// Report
|
||||
if (warnings.length > 0) {
|
||||
console.log('\n--- Warnings (not drift) ---');
|
||||
for (const warning of warnings) console.log(` ! ${warning}`);
|
||||
}
|
||||
|
||||
if (drifts.length === 0) {
|
||||
console.log('\nNo drift detected. Live npm state matches src/config/packageAccess.ts.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n--- Drift report (${drifts.length} finding${drifts.length === 1 ? '' : 's'}) ---`);
|
||||
for (const drift of drifts) console.log(` ✗ ${drift.description}`);
|
||||
|
||||
console.log('\n--- Remediation plan ---');
|
||||
console.log('# Review, then run in ONE interactive npm session (logged in as an org owner).');
|
||||
console.log('# Approve a 2FA challenge on npmjs.com and choose "Don\'t ask again for 5 minutes"');
|
||||
console.log('# to batch these; add `sleep 2` between commands in longer batches.');
|
||||
console.log('# See "npm & PyPI Package Publishing Access" in the README.\n');
|
||||
for (const drift of drifts) {
|
||||
for (const command of drift.commands) console.log(command);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`Drift check failed: ${e}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -9,6 +9,14 @@ import { ROLES, buildRoleLookup, getRolesForPlatform } from '../src/config/roles
|
|||
import { ROLE_IDS, isValidRoleId } from '../src/config/roleIds';
|
||||
import { MEMBERS } from '../src/config/users';
|
||||
import { hasProvisionUserRole } from '../src/config/utils';
|
||||
import {
|
||||
NPM_ORG,
|
||||
NPM_PACKAGES,
|
||||
PYPI_PROJECTS,
|
||||
getExpectedNpmOrgMembers,
|
||||
getNpmPackageAccess,
|
||||
NPM_DEFAULT_POLICY,
|
||||
} from '../src/config/packageAccess';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -124,6 +132,46 @@ test('Some members in provisionUser roles have Google user fields', () => {
|
|||
return membersInProvisionRoles.length > 0 && provisioned.length > 0;
|
||||
});
|
||||
|
||||
// Test package registry access config
|
||||
test('NPM_ORG is modelcontextprotocol', () => NPM_ORG === 'modelcontextprotocol');
|
||||
test('NPM_PACKAGES is not empty and all packages are org-scoped', () =>
|
||||
NPM_PACKAGES.length > 0 && NPM_PACKAGES.every((p) => p.package.startsWith(`@${NPM_ORG}/`)));
|
||||
test('All NPM_PACKAGES have at least one maintainer', () =>
|
||||
NPM_PACKAGES.every((p) => p.maintainers.length > 0));
|
||||
test('npm usernames on members are unique', () => {
|
||||
const usernames = MEMBERS.filter((m) => m.npm).map((m) => m.npm);
|
||||
return usernames.length === new Set(usernames).size;
|
||||
});
|
||||
test('pypi usernames on members are unique', () => {
|
||||
const usernames = MEMBERS.filter((m) => m.pypi).map((m) => m.pypi);
|
||||
return usernames.length === new Set(usernames).size;
|
||||
});
|
||||
test('Expected npm org membership is non-empty, sorted, and unique', () => {
|
||||
const orgMembers = getExpectedNpmOrgMembers();
|
||||
const sorted = [...orgMembers].sort((a, b) => a.localeCompare(b));
|
||||
return (
|
||||
orgMembers.length > 0 &&
|
||||
orgMembers.length === new Set(orgMembers).size &&
|
||||
orgMembers.every((username, i) => username === sorted[i])
|
||||
);
|
||||
});
|
||||
test('getNpmPackageAccess falls back to the default policy', () => {
|
||||
const access = getNpmPackageAccess(`@${NPM_ORG}/some-undeclared-package`);
|
||||
return access.maintainers === NPM_DEFAULT_POLICY.maintainers && !access.trustedPublisher;
|
||||
});
|
||||
test('getNpmPackageAccess returns explicit entries', () => {
|
||||
const access = getNpmPackageAccess(`@${NPM_ORG}/sdk`);
|
||||
return !!access.trustedPublisher;
|
||||
});
|
||||
test('PYPI_PROJECTS includes the mcp project with accounts', () => {
|
||||
const mcp = PYPI_PROJECTS.find((p) => p.project === 'mcp');
|
||||
return !!mcp && mcp.accounts.length > 0;
|
||||
});
|
||||
test('PyPI project names are unique', () => {
|
||||
const names = PYPI_PROJECTS.map((p) => p.project);
|
||||
return names.length === new Set(names).size;
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
|
||||
import { ROLES, buildRoleLookup } from '../src/config/roles';
|
||||
import { REPOSITORY_ACCESS } from '../src/config/repoAccess';
|
||||
import {
|
||||
NPM_PACKAGES,
|
||||
PYPI_PROJECTS,
|
||||
UNMAPPED_NPM_USERS,
|
||||
UNMAPPED_PYPI_USERS,
|
||||
} from '../src/config/packageAccess';
|
||||
import { MEMBERS } from '../src/config/users';
|
||||
import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils';
|
||||
import type { RoleId } from '../src/config/roleIds';
|
||||
|
|
@ -193,6 +199,111 @@ for (const member of MEMBERS) {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate npm/PyPI identities and packageAccess.ts references
|
||||
console.log('Validating package registry references in packageAccess.ts...');
|
||||
{
|
||||
// npm/pypi usernames must be unique across members
|
||||
const npmUsers = new Map<string, string>();
|
||||
const pypiUsers = new Map<string, string>();
|
||||
for (const member of MEMBERS) {
|
||||
const memberId = member.github || member.email || 'unknown';
|
||||
for (const [field, seen] of [
|
||||
['npm', npmUsers],
|
||||
['pypi', pypiUsers],
|
||||
] as const) {
|
||||
const username = member[field];
|
||||
if (!username) continue;
|
||||
const existing = seen.get(username);
|
||||
if (existing) {
|
||||
console.error(
|
||||
`ERROR: ${field} username "${username}" is used by both "${existing}" and "${memberId}"`
|
||||
);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
seen.set(username, memberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmapped lists must not overlap with mapped members (stale entries)
|
||||
for (const username of UNMAPPED_NPM_USERS) {
|
||||
if (npmUsers.has(username)) {
|
||||
console.error(
|
||||
`ERROR: npm username "${username}" is in UNMAPPED_NPM_USERS but is already mapped ` +
|
||||
`to member "${npmUsers.get(username)}" — remove it from the unmapped list`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
for (const username of UNMAPPED_PYPI_USERS) {
|
||||
if (pypiUsers.has(username)) {
|
||||
console.error(
|
||||
`ERROR: PyPI username "${username}" is in UNMAPPED_PYPI_USERS but is already mapped ` +
|
||||
`to member "${pypiUsers.get(username)}" — remove it from the unmapped list`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Every npm maintainer referenced by a package must be a known account
|
||||
const knownNpm = new Set([...npmUsers.keys(), ...UNMAPPED_NPM_USERS]);
|
||||
const npmPackageNames = new Set<string>();
|
||||
for (const pkg of NPM_PACKAGES) {
|
||||
if (npmPackageNames.has(pkg.package)) {
|
||||
console.error(`ERROR: Package "${pkg.package}" is declared twice in packageAccess.ts`);
|
||||
hasErrors = true;
|
||||
}
|
||||
npmPackageNames.add(pkg.package);
|
||||
|
||||
if (!pkg.package.startsWith('@modelcontextprotocol/')) {
|
||||
console.error(`ERROR: Package "${pkg.package}" is not in the @modelcontextprotocol scope`);
|
||||
hasErrors = true;
|
||||
}
|
||||
for (const username of pkg.maintainers) {
|
||||
if (!knownNpm.has(username)) {
|
||||
console.error(
|
||||
`ERROR: Package "${pkg.package}" references npm user "${username}" which is not ` +
|
||||
`declared on any member in users.ts (npm field) or in UNMAPPED_NPM_USERS`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
pkg.trustedPublisher &&
|
||||
!pkg.trustedPublisher.repository.startsWith('modelcontextprotocol/')
|
||||
) {
|
||||
console.error(
|
||||
`ERROR: Package "${pkg.package}" declares trusted publisher repository ` +
|
||||
`"${pkg.trustedPublisher.repository}" outside the modelcontextprotocol GitHub org`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Every PyPI account referenced by a project must be a known account
|
||||
const knownPypi = new Set([...pypiUsers.keys(), ...UNMAPPED_PYPI_USERS]);
|
||||
const pypiProjectNames = new Set<string>();
|
||||
for (const project of PYPI_PROJECTS) {
|
||||
if (pypiProjectNames.has(project.project)) {
|
||||
console.error(
|
||||
`ERROR: PyPI project "${project.project}" is declared twice in packageAccess.ts`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
pypiProjectNames.add(project.project);
|
||||
|
||||
for (const username of project.accounts) {
|
||||
if (!knownPypi.has(username)) {
|
||||
console.error(
|
||||
`ERROR: PyPI project "${project.project}" references PyPI user "${username}" which is not ` +
|
||||
`declared on any member in users.ts (pypi field) or in UNMAPPED_PYPI_USERS`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parent role references in roles.ts
|
||||
console.log('Validating parent role references in roles.ts...');
|
||||
for (const role of ROLES) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue