From 0176c71b27d8ba891bf803508bafeae0aeca733c Mon Sep 17 00:00:00 2001 From: ripark Date: Tue, 7 Jul 2026 18:27:18 -0700 Subject: [PATCH 01/23] First pass at making it so users, other than azd team members, can approve releases provided they match a simple set of rules. --- .github/scripts/ext-registry-check.js | 554 +++++ .github/scripts/jsconfig.json | 41 + .github/scripts/package-lock.json | 1831 +++++++++++++++++ .github/scripts/package.json | 22 + .../scripts/test/ext-registry-check.test.js | 657 ++++++ .github/workflows/ext-registry-check.yml | 35 + 6 files changed, 3140 insertions(+) create mode 100644 .github/scripts/ext-registry-check.js create mode 100644 .github/scripts/jsconfig.json create mode 100644 .github/scripts/package-lock.json create mode 100644 .github/scripts/package.json create mode 100644 .github/scripts/test/ext-registry-check.test.js create mode 100644 .github/workflows/ext-registry-check.yml diff --git a/.github/scripts/ext-registry-check.js b/.github/scripts/ext-registry-check.js new file mode 100644 index 00000000000..8b5516f2b45 --- /dev/null +++ b/.github/scripts/ext-registry-check.js @@ -0,0 +1,554 @@ +const { isDeepStrictEqual } = require('node:util'); + +// GitHub Actions entry point. +module.exports = run; + +// Test-only helpers exposed on the action entry point. +module.exports.forTests = { + getRegistryJson, + isApprovedByCoreTeam, + isAllowedRegistryJsonUpdate, + isCreatedByCoreTeam, + coreExtensionApprovers, + diffRegistry, +} + +/** + * Users that, when they approve, bypass any checks in this file. + */ +function coreExtensionApprovers() { + return new Set([ + "hemarina", + "JeffreyCA", + "RickWinter", + // TODO: bring me back from the dead! + // "richardpark-msft", + "tg-msft", + "vhvb1989", + ]); +} + +const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; + +// GitHub action types + +/** + * @typedef {typeof import('@actions/github').context} Context + * @typedef {ReturnType} Octokit + * @typedef {typeof import('@actions/core')} Core + * + * Response item types inferred from Octokit methods. + * @typedef {Awaited>['data'][number]} Review + * @typedef {Awaited>['data'][number]} PullRequestFile + */ + +// registry.json's types + +/** + * @typedef {object} Provider + * @property {string} name + * @property {string} type + * @property {string} [description] + * + * @typedef {object} ExtensionVersion + * @property {string} version + * @property {string[]} [capabilities] + * @property {Provider[]} [providers] + * + * @typedef {object} Extension + * @property {string} id + * @property {string} [namespace] + * @property {string} [displayName] + * @property {string} [description] + * @property {ExtensionVersion[]} versions + * + * @typedef {object} RegistryJson + * @property {Extension[]} extensions + */ + +/** + * @typedef {NonNullable} PullRequest + */ + +/** + * @param {{ github: Octokit, context: Context, core: Core, coreTeam?: Set, registryBaseRef?: string }} args + */ +async function run({ github: octokit, context, core, coreTeam = coreExtensionApprovers(), registryBaseRef }) { + try { + assertHasPullRequest(context); + const baseRef = registryBaseRef ?? context.payload.pull_request['base']?.sha ?? 'main'; + + // no extra checks needed if a core team member authored the PR. + if (isCreatedByCoreTeam({ context, core, coreTeam })) { + core.info(`PR was created by a core team, no further checks needed`) + return; + } + + // no extra checks needed if a core team member has already approved it. + if (await isApprovedByCoreTeam({ octokit, context, core, coreTeam })) { + core.info(`PR was approved by a core team member, no further checks needed`) + return; + } + + // Non-registry file changes require core-team review. + const changedFileReviewReasons = await getChangedFileReviewReasons({ + octokit, + context, + }); + + // Simple release-only registry changes can proceed without core-team review. + const registryReviewReasons = await isAllowedRegistryJsonUpdate({ + octokit, + context, + registryBaseRef: baseRef, + }); + + const reviewReasons = changedFileReviewReasons.concat(registryReviewReasons); + + if (reviewReasons.length === 0) { + core.info(`PR registry changes do not require core team review (no changes in capabilities, providers)`) + return; + } + + core.setFailed( + "PR changes the extension registry in a way that requires core team review:\n" + + reviewReasons.map((r) => `- ${r}`).join("\n") + + "\n\nTo fix:\n" + + `1. Have any core team member review and approve this PR. Core team members: (${[...coreExtensionApprovers()].join(", ")})\n` + + `2. After approval, re-run this build step so it'll re-evaluate the PR - no commits or pushes needed.` + ); + } catch (err) { + core.setFailed(`Internal failure in script: ${err instanceof Error ? err.message : err}`); + } +} + +/** + * @param {{ octokit: Octokit, context: Context, core: Core, coreTeam: Set }} args + * @returns {Promise} true if it is approved, false otherwise. + */ +async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { + if (coreTeam == null || coreTeam.size === 0) { + throw new Error("Invalid parameter - coreteam must be populated"); + } + + assertHasPullRequest(context); + + const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { + ...context.repo, + pull_number: context.payload.pull_request.number, + }); + + // users can have multiple reviews (ie, they requested changes, then they approved), so we'll + // make sure we get their absolutely latest review state. + + // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, + // and Map.set keeps the last entry per key - so this is "latest review per core-team user". + /** @type {Map} */ + const latestByUser = new Map(); + + for (const review of reviews) { + if (review.user != null && coreTeam.has(review.user.login)) { + latestByUser.set(review.user.login, review.state); + } + } + + // GitHub will take care of blocking the PR if reviewers did a request-changes, for instance. + const coreApprovals = [...latestByUser].filter(([, v]) => v === 'APPROVED').map(([k]) => k); + + if (coreApprovals != null && coreApprovals.length > 0) { + core.info(`PR approved by member(s) of the AZD team (${coreApprovals.join(",")})`) + return true; + } + + return false; +} + +/** + * @param {{ context: Context, core: Core, coreTeam: Set }} args + * @returns {boolean} true if the PR author is a member of the core team, false otherwise. + */ +function isCreatedByCoreTeam({ context, core, coreTeam }) { + if (coreTeam == null || coreTeam.size === 0) { + throw new Error("Invalid parameter - coreteam must be populated"); + } + + assertHasPullRequest(context); + + const author = context.payload.pull_request['user']?.login; + + if (author != null && coreTeam.has(author)) { + core.info(`PR was created by a member of the AZD team (${author})`); + return true; + } + + return false; +} + +/** + * Checks whether the registry update is simple enough to proceed without core-team review. + * + * @param {{ octokit: Octokit, context: Context, registryBaseRef?: string }} args + * @returns {Promise} the reasons core team review is needed; empty means the change is approved + */ +async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef = 'main' }) { + assertHasPullRequest(context); + const pr = context.payload.pull_request; + + const mainRegistry = await getRegistryJson({ + octokit, + owner: context.repo.owner, + repo: context.repo.repo, + ref: registryBaseRef, + }); + + const head = pr['head']; + const ref = head?.sha ?? head?.ref; + if (!ref) { + throw new Error('Unable to determine PR head ref for registry.json update check'); + } + + const prRegistry = await getRegistryJson({ + octokit, + owner: head?.repo?.owner?.login ?? context.repo.owner, + repo: head?.repo?.name ?? context.repo.repo, + ref, + }); + + return diffRegistry(mainRegistry, prRegistry); +} + +/** + * Checks whether the PR changed only registry.json. + * + * @param {{ octokit: Octokit, context: Context }} args + * @returns {Promise} + */ +async function getChangedFileReviewReasons({ octokit, context }) { + const changedFiles = await getChangedFiles({ octokit, context }); + return diffChangedFiles(changedFiles); +} + +/** + * Fetches the list of files changed by the PR. + * + * @param {{ octokit: Octokit, context: Context }} args + * @returns {Promise} + */ +async function getChangedFiles({ octokit, context }) { + assertHasPullRequest(context); + + return await octokit.paginate(octokit.rest.pulls.listFiles, { + ...context.repo, + pull_number: context.payload.pull_request.number, + }); +} + +/** + * @param {PullRequestFile[]} changedFiles + * @returns {string[]} + */ +function diffChangedFiles(changedFiles) { + const unexpectedFiles = changedFiles + .filter((file) => file.filename !== REGISTRY_JSON_PATH || file.previous_filename != null) + .map((file) => file.filename); + + if (unexpectedFiles.length === 0) { + return []; + } + + return [ + `PR changes files outside ${REGISTRY_JSON_PATH}; registry auto-approval only applies to registry-only PRs: ${unexpectedFiles.join(', ')}`, + ]; +} + +/** + * Fetches and parses cli/azd/extensions/registry.json at a given ref. + * + * @param {{ octokit: Octokit, owner: string, repo: string, ref: string }} args + * @returns {Promise} + */ +async function getRegistryJson({ octokit, owner, repo, ref }) { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path: REGISTRY_JSON_PATH, + ref, + mediaType: { + format: 'raw', + }, + }); + + if (typeof data !== 'string') { + throw new Error(`Unable to load ${REGISTRY_JSON_PATH} from ${owner}/${repo}@${ref}`); + } + + return JSON.parse(data); +} + +/** + * Diffs the base (main) registry against the registry proposed by a PR and decides + * whether the change is safe enough to auto-approve, or whether a core team member + * needs to review it. + * + * New releases can be auto-approved when they keep the previous release's + * capabilities and providers, and only add a new release to an existing extension. + * + * @param {RegistryJson} baseRegistry registry.json as it exists on main + * @param {RegistryJson} prRegistry registry.json as proposed by the PR + * @returns {string[]} the reasons core team review is needed; empty means the change is approved + */ +function diffRegistry(baseRegistry, prRegistry) { + /** @type {string[]} */ + const reasons = []; + + const baseExtensions = new Map((baseRegistry.extensions ?? []).map((e) => [e.id, e])); + const prExtensions = new Map((prRegistry.extensions ?? []).map((e) => [e.id, e])); + + // brand new extensions can't be auto-approved. + for (const id of prExtensions.keys()) { + if (!baseExtensions.has(id)) { + reasons.push(`extension '${id}' is new; new extensions cannot be auto-approved`); + } + } + + // removing an existing extension can't be auto-approved. + for (const id of baseExtensions.keys()) { + if (!prExtensions.has(id)) { + reasons.push(`extension '${id}' was removed; removing extensions cannot be auto-approved`); + } + } + + for (const [id, prExtension] of prExtensions) { + const baseExtension = baseExtensions.get(id); + + if (baseExtension == null) { + continue; // already reported as a new extension above + } + + if (baseExtension.namespace !== prExtension.namespace) { + reasons.push(`extension '${id}' namespace changed; namespace changes cannot be auto-approved`); + } + + const baseVersions = new Map((baseExtension.versions ?? []).map((v) => [v.version, v])); + const prVersions = new Map((prExtension.versions ?? []).map((v) => [v.version, v])); + + reasons.push(...diffPublishedReleases(id, baseVersions, prVersions)); + reasons.push(...diffNewReleases(id, baseExtension.versions ?? [], baseVersions, prVersions)); + } + + return reasons; +} + +/** + * @param {string} id + * @param {Map} baseVersions + * @param {Map} prVersions + * @returns {string[]} + */ +function diffPublishedReleases(id, baseVersions, prVersions) { + /** @type {string[]} */ + const reasons = []; + + for (const [version, baseVersion] of baseVersions) { + const prVersion = prVersions.get(version); + if (prVersion == null) { + reasons.push(`extension '${id}' release '${version}' was removed; published releases are immutable`); + continue; + } + + if (!sameCapabilities(baseVersion, prVersion)) { + reasons.push(`extension '${id}' release '${version}' changes capabilities; published capability declarations require core review`); + } + + if (!sameProviders(baseVersion, prVersion)) { + reasons.push(`extension '${id}' release '${version}' changes providers; published provider declarations require core review`); + } + + if (!isDeepStrictEqual(baseVersion, prVersion)) { + reasons.push(`extension '${id}' release '${version}' was modified; published releases are immutable`); + } + } + + return reasons; +} + +/** + * @param {string} id + * @param {ExtensionVersion[]} baseVersionList + * @param {Map} baseVersions + * @param {Map} prVersions + * @returns {string[]} + */ +function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { + /** @type {string[]} */ + const reasons = []; + const previousRelease = latestVersionBySemver(baseVersionList); + + for (const [version, prVersion] of prVersions) { + if (baseVersions.has(version)) { + continue; + } + + if (previousRelease == null) { + reasons.push(`extension '${id}' release '${version}' has no previous release to compare against`); + continue; + } + + if (!sameCapabilities(previousRelease, prVersion)) { + reasons.push( + `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}'`, + ); + } + + if (!sameProviders(previousRelease, prVersion)) { + reasons.push( + `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}'`, + ); + } + } + + return reasons; +} + +/** + * @param {ExtensionVersion} a + * @param {ExtensionVersion} b + * @returns {boolean} + */ +function sameCapabilities(a, b) { + return isDeepStrictEqual((a.capabilities ?? []).sort(), (b.capabilities ?? []).sort()); +} + +/** + * @param {ExtensionVersion} a + * @param {ExtensionVersion} b + * @returns {boolean} + */ +function sameProviders(a, b) { + return isDeepStrictEqual(providerIdentities(a.providers ?? []), providerIdentities(b.providers ?? [])); +} + +/** + * Reduces providers to their behavioral identity (name + type), sorted, so that a + * cosmetic description tweak doesn't force a core-team review, while any change to + * what the extension actually registers does. + * + * @param {Provider[]} providers + * @returns {{ name: string, type: string }[]} + */ +function providerIdentities(providers) { + return providers + .map((p) => ({ name: p.name, type: p.type })) + .sort((x, y) => x.name.localeCompare(y.name) || x.type.localeCompare(y.type)); +} + +/** + * @param {ExtensionVersion[]} versions + * @returns {ExtensionVersion | undefined} + */ +function latestVersionBySemver(versions) { + if (versions.length === 0) { + return undefined; + } + + return versions.reduce((latest, candidate) => + compareSemver(candidate.version, latest.version) > 0 ? candidate : latest + ); +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ +function compareSemver(a, b) { + const parsedA = parseSemver(a); + const parsedB = parseSemver(b); + + for (const key of /** @type {const} */ (['major', 'minor', 'patch'])) { + if (parsedA[key] !== parsedB[key]) { + return parsedA[key] < parsedB[key] ? -1 : 1; + } + } + + return comparePrerelease(parsedA.prerelease, parsedB.prerelease); +} + +/** + * @param {string} version + * @returns {{ major: number, minor: number, patch: number, prerelease: string }} + */ +function parseSemver(version) { + const withoutBuild = version.split('+')[0] ?? ''; + const coreAndPrerelease = withoutBuild.split('-'); + const core = coreAndPrerelease[0] ?? ''; + const prerelease = coreAndPrerelease[1] ?? ''; + const [major = 0, minor = 0, patch = 0] = core.split('.').map((n) => Number.parseInt(n, 10) || 0); + + return { major, minor, patch, prerelease }; +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ +function comparePrerelease(a, b) { + if (a === b) { + return 0; + } + if (a === '') { + return 1; + } + if (b === '') { + return -1; + } + + const aFields = a.split('.'); + const bFields = b.split('.'); + const fieldCount = Math.max(aFields.length, bFields.length); + + for (let i = 0; i < fieldCount; i++) { + const aField = aFields[i]; + const bField = bFields[i]; + + if (aField === undefined) { + return -1; + } + if (bField === undefined) { + return 1; + } + + const aNumeric = /^\d+$/.test(aField); + const bNumeric = /^\d+$/.test(bField); + + if (aNumeric && bNumeric) { + const diff = Number.parseInt(aField, 10) - Number.parseInt(bField, 10); + if (diff !== 0) { + return diff < 0 ? -1 : 1; + } + } else if (aNumeric) { + return -1; + } else if (bNumeric) { + return 1; + } else if (aField !== bField) { + return aField < bField ? -1 : 1; + } + } + + return 0; +} + +/** + * Asserts that we're being invoked for a pull request (and is also a typeguard) + * + * @param {Context} context + * @returns {asserts context is Context & { payload: { pull_request: PullRequest } }} + */ +function assertHasPullRequest(context) { + if (context.payload.pull_request == null) { + throw new Error('No pull_request found in event payload. Workflow targeting should only target pull requests.'); + } +} + + diff --git a/.github/scripts/jsconfig.json b/.github/scripts/jsconfig.json new file mode 100644 index 00000000000..1127ecc6345 --- /dev/null +++ b/.github/scripts/jsconfig.json @@ -0,0 +1,41 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "rootDir": ".", + // File Layout + "noEmit": true, + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + // For nodejs: + // "lib": ["esnext"], + "types": [ + "node" + ], + "checkJs": true, + // and npm install -D @types/node + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + // Style Options + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +} diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json new file mode 100644 index 00000000000..9308bcab937 --- /dev/null +++ b/.github/scripts/package-lock.json @@ -0,0 +1,1831 @@ +{ + "name": "scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scripts", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } + }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/github": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-9.1.1.tgz", + "integrity": "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^3.0.2", + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "@octokit/request": "^10.0.7", + "@octokit/request-error": "^7.1.0", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/github/node_modules/@actions/http-client": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz", + "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.11.tgz", + "integrity": "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 00000000000..761524b18fc --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,22 @@ +{ + "name": "scripts", + "version": "1.0.0", + "description": "", + "main": "pr-approval-foundry-extensions-shared.js", + "scripts": { + "test": "vitest run" + }, + "keywords": [], + "author": "", + "license": "MIT", + "private": true, + "type": "commonjs", + "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js new file mode 100644 index 00000000000..d39959f384a --- /dev/null +++ b/.github/scripts/test/ext-registry-check.test.js @@ -0,0 +1,657 @@ +import { execFileSync } from 'node:child_process'; +import { describe, it, expect, vi } from 'vitest'; +import run from '../ext-registry-check.js'; + +/** + * @typedef {typeof import('@actions/github').context} Context + * @typedef {ReturnType} Octokit + * @typedef {typeof import('@actions/core')} Core + * + * @typedef {object} Provider + * @property {string} name + * @property {string} type + * @property {string} [description] + * + * @typedef {object} ExtensionVersion + * @property {string} version + * @property {string[]} [capabilities] + * @property {Provider[]} [providers] + * @property {Record} [artifacts] + * @property {string} [usage] + * + * @typedef {object} Extension + * @property {string} id + * @property {string} [namespace] + * @property {string} [displayName] + * @property {string} [description] + * @property {ExtensionVersion[]} versions + * + * @typedef {object} RegistryJson + * @property {Extension[]} extensions + */ + +const { + diffRegistry, + isAllowedRegistryJsonUpdate, +} = run.forTests; + +/** + * @param {object} [opts] + * @param {string[]} [opts.capabilities] + * @param {{ name: string, type: string, description?: string }[]} [opts.providers] + * @param {string} [opts.version] + * @returns {ExtensionVersion} + */ +function version({ version = '1.0.0', capabilities = ['custom-commands'], providers = [{ name: 'p', type: 'service-target' }] } = {}) { + return { version, capabilities, providers, artifacts: {} }; +} + +/** + * @param {object} [opts] + * @param {string} [opts.id] + * @param {ExtensionVersion[]} [opts.versions] + * @returns {Extension} + */ +function extension({ id = 'ext.one', versions = [version()] } = {}) { + return { id, namespace: 'ns', displayName: 'Ext One', description: 'desc', versions }; +} + +/** + * @param {Extension[]} extensions + * @returns {RegistryJson} + */ +function registry(extensions) { + return { extensions }; +} + +describe('diffRegistry', () => { + it('approves an identical registry (no changes)', () => { + const base = registry([extension()]); + const pr = registry([extension()]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('approves adding a new release with the same capabilities and providers', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ versions: [version({ version: '1.0.0' }), version({ version: '1.1.0' })] }), + ]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('approves a new release when only a provider description changes (cosmetic)', () => { + const base = registry([ + extension({ versions: [version({ version: '1.0.0', providers: [{ name: 'p', type: 'service-target', description: 'a' }] })] }), + ]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0', providers: [{ name: 'p', type: 'service-target', description: 'a' }] }), + version({ version: '1.1.0', providers: [{ name: 'p', type: 'service-target', description: 'b (reworded)' }] }), + ], + }), + ]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('approves extension display metadata changes', () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([{ ...extension({ id: 'ext.one' }), displayName: 'Renamed', description: 'Updated copy' }]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('fails when a brand new extension is added', () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([extension({ id: 'ext.one' }), extension({ id: 'ext.two' })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("'ext.two' is new")); + }); + + it('fails when an existing extension is removed', () => { + const base = registry([extension({ id: 'ext.one' }), extension({ id: 'ext.two' })]); + const pr = registry([extension({ id: 'ext.one' })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("'ext.two' was removed")); + }); + + it('fails when a new release changes capabilities', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['custom-commands'] })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0', capabilities: ['custom-commands'] }), + version({ version: '1.1.0', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('changes capabilities')); + }); + + it('fails when a new release changes providers (name or type)', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0', providers: [{ name: 'p', type: 'service-target' }] })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0', providers: [{ name: 'p', type: 'service-target' }] }), + version({ version: '1.1.0', providers: [{ name: 'p', type: 'host' }] }), + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('changes providers')); + }); + + it('uses the latest semver release as the baseline for new release capability checks', () => { + const base = registry([ + extension({ + versions: [ + version({ version: '2.0.0', capabilities: ['custom-commands', 'lifecycle-events'] }), + version({ version: '1.9.0', capabilities: ['custom-commands'] }), + ], + }), + ]); + const pr = registry([ + extension({ + versions: [ + version({ version: '2.0.0', capabilities: ['custom-commands', 'lifecycle-events'] }), + version({ version: '1.9.0', capabilities: ['custom-commands'] }), + version({ version: '2.1.0', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + ]); + + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('treats registry versions as oldest-to-newest semver order, including prerelease labels', () => { + const base = registry([ + extension({ + id: 'azure.ai.agents', + versions: [ + version({ version: '0.1.9-preview', capabilities: ['custom-commands'] }), + version({ version: '0.1.10-preview', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + extension({ + id: 'microsoft.foundry', + versions: [ + version({ version: '1.0.0-beta.2', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.3', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + ]); + const pr = registry([ + extension({ + id: 'azure.ai.agents', + versions: [ + version({ version: '0.1.9-preview', capabilities: ['custom-commands'] }), + version({ version: '0.1.10-preview', capabilities: ['custom-commands', 'lifecycle-events'] }), + version({ version: '0.1.11-preview', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + extension({ + id: 'microsoft.foundry', + versions: [ + version({ version: '1.0.0-beta.2', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.3', capabilities: ['custom-commands', 'lifecycle-events'] }), + version({ version: '1.0.0-beta.4', capabilities: ['custom-commands', 'lifecycle-events'] }), + ], + }), + ]); + + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('fails when an already-published release is modified', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['custom-commands'] })] })]); + const pr = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['something-else'] })] })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("release '1.0.0' was modified")); + }); + + it('fails when an already-published release changes providers', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0', providers: [{ name: 'p', type: 'service-target' }] })] })]); + const pr = registry([extension({ versions: [version({ version: '1.0.0', providers: [{ name: 'p', type: 'host' }] })] })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("release '1.0.0' changes providers")); + }); + + it('fails when an already-published release is removed', () => { + const base = registry([ + extension({ versions: [version({ version: '1.0.0' }), version({ version: '1.1.0' })] }), + ]); + const pr = registry([extension({ versions: [version({ version: '1.1.0' })] })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("release '1.0.0' was removed")); + }); + + it('fails when an extension namespace changes', () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([{ ...extension({ id: 'ext.one' }), namespace: 'other' }]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('namespace changed')); + }); + + it('fails when an existing release metadata field changes', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [{ ...version({ version: '1.0.0' }), usage: 'azd ext ' }], + }), + ]); + const reasons = diffRegistry(base, pr); + + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("release '1.0.0' was modified")); + }); +}); + +describe('isAllowedRegistryJsonUpdate', () => { + it('loads main and PR registry.json and applies the registry policy', async () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([{ ...extension({ id: 'ext.one' }), namespace: 'other' }]); + const octokit = createRegistryOctokit({ base, pr }); + const context = createRegistryContext(); + + await expect(isAllowedRegistryJsonUpdate({ octokit, context })).resolves.toContainEqual(expect.stringContaining('namespace changed')); + expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'Azure', + repo: 'azure-dev', + ref: 'main', + })); + expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'fork-owner', + repo: 'azure-dev-fork', + ref: 'abc123', + })); + }); + + it('can load the base registry from a supplied commit-ish', async () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([{ ...extension({ id: 'ext.one' }), namespace: 'other' }]); + const octokit = createRegistryOctokit({ base, pr }); + const context = createRegistryContext(); + + await expect(isAllowedRegistryJsonUpdate({ + octokit, + context, + registryBaseRef: 'base-before-pr', + })).resolves.toContainEqual(expect.stringContaining('namespace changed')); + expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'Azure', + repo: 'azure-dev', + ref: 'base-before-pr', + })); + }); + + it('requires review when an existing release changes capabilities', async () => { + const base = registry([extension({ id: 'ext.one', versions: [version({ capabilities: ['custom-commands'] })] })]); + const pr = registry([extension({ id: 'ext.one', versions: [version({ capabilities: ['lifecycle-events'] })] })]); + const octokit = createRegistryOctokit({ base, pr }); + + const reasons = await isAllowedRegistryJsonUpdate({ + octokit, + context: createRegistryContext(), + }); + + expect(reasons).toContainEqual(expect.stringContaining('changes capabilities')); + }); +}); + +describe('run', () => { + it('fails fast when an empty core team is injected', async () => { + const core = createNoopCore(); + + await run({ + github: createRegistryOctokit({ base: registry([]), pr: registry([]) }), + context: createRegistryContext(), + core, + coreTeam: new Set(), + }); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid parameter - coreteam must be populated')); + }); + + it('allows a simple registry-only PR without core team review', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension({ versions: [version({ version: '1.0.0' })] })]), + pr: registry([extension({ versions: [version({ version: '1.0.0' }), version({ version: '1.1.0' })] })]), + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.objectContaining({ + pull_number: 1, + })); + }); + + it('skips changed-file review when a core team member authored the PR', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + }); + const context = createRegistryContext({ author: 'core-member' }); + + await run({ + github: octokit, + context, + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).not.toHaveBeenCalled(); + expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); + }); + + it('uses the pull request base sha as the registry comparison base by default', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ base: registry([extension()]), pr: registry([extension()]) }); + const context = createRegistryContext(); + + await run({ + github: octokit, + context, + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'Azure', + repo: 'azure-dev', + ref: 'base-before-pr', + })); + }); + + it('requires review when the PR changes files outside registry.json', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('files outside cli/azd/extensions/registry.json')); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('cli/azd/extensions/README.md')); + }); +}); + +/** + * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[] }} args + * @returns {Octokit} + */ +function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }] }) { + const octokit = { + rest: { + pulls: { + listReviews: vi.fn(), + listFiles: vi.fn(), + }, + repos: { + getContent: vi.fn(({ ref }) => Promise.resolve({ + data: JSON.stringify(ref === 'abc123' ? pr : base), + })), + }, + }, + paginate: vi.fn((endpoint) => { + if (endpoint === octokit.rest.pulls.listFiles) { + return Promise.resolve(files); + } + + return Promise.resolve([]); + }), + }; + + return /** @type {Octokit} */ (/** @type {unknown} */ (octokit)); +} + +/** + * @param {object} [opts] + * @param {string} [opts.author] + * @returns {Context} + */ +function createRegistryContext({ author = 'contributor' } = {}) { + return /** @type {Context} */ (/** @type {unknown} */ ({ + repo: { owner: 'Azure', repo: 'azure-dev' }, + payload: { + pull_request: { + number: 1, + base: { sha: 'base-before-pr' }, + head: { + sha: 'abc123', + repo: { + name: 'azure-dev-fork', + owner: { login: 'fork-owner' }, + }, + }, + user: { login: author, id: 1, type: 'User' }, + }, + }, + })); +} + +const LIVE_TEST_OWNER = 'Azure'; +const LIVE_TEST_REPO = 'azure-dev'; +const RUN_LIVE_TESTS = process.env['RUN_LIVE_TESTS'] === '1'; +const liveDescribe = RUN_LIVE_TESTS ? describe : describe.skip; + +if (!RUN_LIVE_TESTS) { + process.stderr.write( + `[live] Skipping live PR scenario test(s). ` + + `Set RUN_LIVE_TESTS=1 to run them against ${LIVE_TEST_OWNER}/${LIVE_TEST_REPO}:\n` + + '\n' + ); +} + +function getLiveGithubToken() { + const envToken = process.env['GH_TOKEN'] || process.env['GITHUB_TOKEN']; + + if (envToken) return envToken; + + try { + return execFileSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +async function createLiveOctokit() { + const token = getLiveGithubToken(); + + if (!token) { + throw new Error('[live] tests require GH_TOKEN, GITHUB_TOKEN, or `gh auth token`'); + } + + const { getOctokit } = await import('@actions/github'); + return getOctokit(token); +} + +/** + * @param {Octokit} octokit + * @param {number} prNumber + * @returns {Promise} + */ +async function createLiveContext(octokit, prNumber) { + const { data: pr } = await octokit.rest.pulls.get({ + owner: LIVE_TEST_OWNER, + repo: LIVE_TEST_REPO, + pull_number: prNumber, + }); + + return /** @type {Context} */ (/** @type {unknown} */ ({ + repo: { owner: LIVE_TEST_OWNER, repo: LIVE_TEST_REPO }, + payload: { + pull_request: { + number: pr.number, + head: { + sha: pr.head.sha, + repo: { + name: pr.head.repo?.name, + owner: { + login: pr.head.repo?.owner?.login, + }, + }, + }, + user: { + id: pr.user?.id, + type: pr.user?.type, + login: pr.user?.login, + }, + }, + }, + })); +} + +// these tests do some read-only checking against real PRs in GitHub. Use this is +// if you're just not sure if we're doing the right kind of mocking above and need +// to try against the real deal, with a real octokit instance. +liveDescribe('[live] registry diff PR scenarios', () => { + /** + * Returns the base-branch commit to compare against for the live PR sample. + * Live samples are intentionally limited to closed-unmerged PRs and + * squash-merged PRs. + * + * @param {Octokit} octokit + * @param {number} prNumber + * @returns {Promise} + */ + async function getLiveRegistryBaseRef(octokit, prNumber) { + const { data: pr } = await octokit.rest.pulls.get({ + owner: LIVE_TEST_OWNER, + repo: LIVE_TEST_REPO, + pull_number: prNumber, + }); + + if (pr.state !== 'closed') { + throw new Error(`Live PR sample ${prNumber} must be closed or merged`); + } + + if (pr.merged_at == null) { + if (!pr.base.sha) { + throw new Error(`Unable to determine the base commit for PR ${prNumber}`); + } + + return pr.base.sha; + } + + if (!pr.merge_commit_sha) { + throw new Error(`Unable to determine the squash merge commit for PR ${prNumber}`); + } + + const { data: mergeCommit } = await octokit.rest.repos.getCommit({ + owner: LIVE_TEST_OWNER, + repo: LIVE_TEST_REPO, + ref: pr.merge_commit_sha, + }); + + if (mergeCommit.parents.length !== 1) { + throw new Error(`Live PR sample ${prNumber} must be squash-merged`); + } + + const parent = mergeCommit.parents[0]; + if (!parent?.sha) { + throw new Error(`Unable to determine the base commit before PR ${prNumber}`); + } + + return parent.sha; + } + + + /** + * @param {{ number: number, noReviewRequired: boolean, coreTeam?: Set }} sample + */ + async function runTestAgainstLivePr(sample) { + const octokit = await createLiveOctokit(); + const context = await createLiveContext(octokit, sample.number); + const registryBaseRef = await getLiveRegistryBaseRef(octokit, sample.number); + const core = createNoopCore(); + + if (sample.coreTeam) { + await run({ github: octokit, context, core, coreTeam: sample.coreTeam, registryBaseRef }); + } else { + await run({ github: octokit, context, core, registryBaseRef }); + } + + if (sample.noReviewRequired) { + expect(core.setFailed).not.toHaveBeenCalled(); + } else { + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires core team review')); + } + } + + // NOTE: some of these are just PRs, not even release PRs, but they have the right metadata. + + describe("core approval bypass", () => { + // https://github.com/Azure/azure-dev/pull/9027 + it('[live] PR 9027 => core team member is the author', async () => { + await runTestAgainstLivePr({ number: 9027, noReviewRequired: true }); + }, 90_000); + + // https://github.com/Azure/azure-dev/pull/8958 + it('[live] PR 8958 => core team member approved', async () => { + await runTestAgainstLivePr({ number: 8958, noReviewRequired: true }); + }, 90_000); + }) + + // https://github.com/Azure/azure-dev/pull/8620 + it('[live] PR 8620 => no review required: registry diff allows unchanged extension declarations', async () => { + await runTestAgainstLivePr({ number: 8620, noReviewRequired: true }); + }, 90_000); + + // https://github.com/Azure/azure-dev/pull/8958 + it('[live] PR 8958 => approval required because some registry metadata change without core approval', async () => { + await runTestAgainstLivePr({ number: 8958, noReviewRequired: false, coreTeam: new Set(['the fakest developer ever']) }); + }, 90_000); + + // https://github.com/Azure/azure-dev/pull/8972 + it('[live] PR 8972 => core team approval required because the PR changes another file', async () => { + await runTestAgainstLivePr({ number: 8972, noReviewRequired: false }); + }, 90_000); +}); + +/** @returns {Core} */ +function createNoopCore() { + const core = { + info: vi.fn(), + warning: vi.fn(), + /** @param {string} message */ + setFailed: vi.fn(), + }; + + return /** @type {Core} */ (/** @type {unknown} */ (core)); +} diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml new file mode 100644 index 00000000000..c3689d94817 --- /dev/null +++ b/.github/workflows/ext-registry-check.yml @@ -0,0 +1,35 @@ +name: ext-registry-check + +on: + pull_request: + paths: + - "cli/azd/extensions/registry.json" + branches: [main, azd-auto-approve-simple-updates] + types: [opened, edited, synchronize, labeled, unlabeled, reopened, ready_for_review] + +# If two events are triggered within a short time in the same PR, cancel the run of the oldest event +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + extension-registry-check: + name: Extension registry auto-approve check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check extension registry update + uses: actions/github-script@v9 + with: + script: | + const script = require('./.github/scripts/ext-registry-check.js'); + await script({ github, context, core }); + \ No newline at end of file From 0093972d76ef17098cae64bc6c2e4c89d7032254 Mon Sep 17 00:00:00 2001 From: ripark Date: Wed, 8 Jul 2026 20:22:01 -0700 Subject: [PATCH 02/23] Also print out the capabilities/providers that are different --- .github/scripts/ext-registry-check.js | 54 ++++++++++++------- .../scripts/test/ext-registry-check.test.js | 52 ++++++++++++++++++ 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.github/scripts/ext-registry-check.js b/.github/scripts/ext-registry-check.js index 8b5516f2b45..4e46806687c 100644 --- a/.github/scripts/ext-registry-check.js +++ b/.github/scripts/ext-registry-check.js @@ -356,12 +356,14 @@ function diffPublishedReleases(id, baseVersions, prVersions) { continue; } - if (!sameCapabilities(baseVersion, prVersion)) { - reasons.push(`extension '${id}' release '${version}' changes capabilities; published capability declarations require core review`); + const capabilityChanges = diffArrays(baseVersion.capabilities ?? [], prVersion.capabilities ?? []); + if (capabilityChanges.length > 0) { + reasons.push(`extension '${id}' release '${version}' changes capabilities (${capabilityChanges.join('; ')}); published capability declarations require core review`); } - if (!sameProviders(baseVersion, prVersion)) { - reasons.push(`extension '${id}' release '${version}' changes providers; published provider declarations require core review`); + const providerChanges = diffArrays(providerIdentityLabels(baseVersion), providerIdentityLabels(prVersion)); + if (providerChanges.length > 0) { + reasons.push(`extension '${id}' release '${version}' changes providers (${providerChanges.join('; ')}); published provider declarations require core review`); } if (!isDeepStrictEqual(baseVersion, prVersion)) { @@ -394,15 +396,17 @@ function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { continue; } - if (!sameCapabilities(previousRelease, prVersion)) { + const capabilityChanges = diffArrays(previousRelease.capabilities ?? [], prVersion.capabilities ?? []); + if (capabilityChanges.length > 0) { reasons.push( - `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}'`, + `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}' (${capabilityChanges.join('; ')})`, ); } - if (!sameProviders(previousRelease, prVersion)) { + const providerChanges = diffArrays(providerIdentityLabels(previousRelease), providerIdentityLabels(prVersion)); + if (providerChanges.length > 0) { reasons.push( - `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}'`, + `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, ); } } @@ -411,21 +415,35 @@ function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { } /** - * @param {ExtensionVersion} a - * @param {ExtensionVersion} b - * @returns {boolean} + * @param {string[]} baseItems + * @param {string[]} prItems + * @returns {string[]} */ -function sameCapabilities(a, b) { - return isDeepStrictEqual((a.capabilities ?? []).sort(), (b.capabilities ?? []).sort()); +function diffArrays(baseItems, prItems) { + const baseSet = new Set(baseItems); + const prSet = new Set(prItems); + const added = [...prSet].filter((item) => !baseSet.has(item)).sort(); + const removed = [...baseSet].filter((item) => !prSet.has(item)).sort(); + /** @type {string[]} */ + const changes = []; + + if (added.length > 0) { + changes.push(`added: ${added.join(', ')}`); + } + + if (removed.length > 0) { + changes.push(`removed: ${removed.join(', ')}`); + } + + return changes; } /** - * @param {ExtensionVersion} a - * @param {ExtensionVersion} b - * @returns {boolean} + * @param {ExtensionVersion} version + * @returns {string[]} */ -function sameProviders(a, b) { - return isDeepStrictEqual(providerIdentities(a.providers ?? []), providerIdentities(b.providers ?? [])); +function providerIdentityLabels(version) { + return providerIdentities(version.providers ?? []).map((provider) => `${provider.name} (${provider.type})`); } /** diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index d39959f384a..2eac6418e01 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -129,6 +129,23 @@ describe('diffRegistry', () => { const reasons = diffRegistry(base, pr); expect(reasons).not.toEqual([]); expect(reasons).toContainEqual(expect.stringContaining('changes capabilities')); + expect(reasons).toContainEqual(expect.stringContaining('added: lifecycle-events')); + }); + + it('lists added and removed capabilities in review reasons', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['custom-commands', 'lifecycle-events'] })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0', capabilities: ['custom-commands', 'lifecycle-events'] }), + version({ version: '1.1.0', capabilities: ['resource-group'] }), + ], + }), + ]); + const reasons = diffRegistry(base, pr); + + expect(reasons).toContainEqual(expect.stringContaining('added: resource-group')); + expect(reasons).toContainEqual(expect.stringContaining('removed: custom-commands, lifecycle-events')); }); it('fails when a new release changes providers (name or type)', () => { @@ -144,6 +161,41 @@ describe('diffRegistry', () => { const reasons = diffRegistry(base, pr); expect(reasons).not.toEqual([]); expect(reasons).toContainEqual(expect.stringContaining('changes providers')); + expect(reasons).toContainEqual(expect.stringContaining('added: p (host)')); + expect(reasons).toContainEqual(expect.stringContaining('removed: p (service-target)')); + }); + + it('lists added and removed providers in review reasons', () => { + const base = registry([extension({ + versions: [version({ + version: '1.0.0', providers: [ + { name: 'p', type: 'service-target' }, + { name: 'old', type: 'host' }, + ] + })] + })]); + const pr = registry([ + extension({ + versions: [ + version({ + version: '1.0.0', providers: [ + { name: 'p', type: 'service-target' }, + { name: 'old', type: 'host' }, + ] + }), + version({ + version: '1.1.0', providers: [ + { name: 'p', type: 'service-target' }, + { name: 'new', type: 'host' }, + ] + }), + ], + }), + ]); + const reasons = diffRegistry(base, pr); + + expect(reasons).toContainEqual(expect.stringContaining('added: new (host)')); + expect(reasons).toContainEqual(expect.stringContaining('removed: old (host)')); }); it('uses the latest semver release as the baseline for new release capability checks', () => { From f1ed02c42ac41c727890f972ce4660f75d347aa6 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 11:15:37 -0700 Subject: [PATCH 03/23] Make sure we're checking that the latest approval lines up with the latest _sha_ as well, otherwise any approval, at any time, even if no longer valid, would let things go through. --- .github/scripts/ext-registry-check.js | 20 ++++-- .../scripts/test/ext-registry-check.test.js | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.github/scripts/ext-registry-check.js b/.github/scripts/ext-registry-check.js index 4e46806687c..4b1afa9c061 100644 --- a/.github/scripts/ext-registry-check.js +++ b/.github/scripts/ext-registry-check.js @@ -138,25 +138,35 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { pull_number: context.payload.pull_request.number, }); - // users can have multiple reviews (ie, they requested changes, then they approved), so we'll + const headSha = context.payload.pull_request['head']?.sha; + if (!headSha) { + throw new Error('Unable to determine PR head sha for approval freshness check'); + } + + // users can have multiple reviews (ie, they requested changes, then they approved), so we'll // make sure we get their absolutely latest review state. // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, // and Map.set keeps the last entry per key - so this is "latest review per core-team user". - /** @type {Map} */ + /** @type {Map} */ const latestByUser = new Map(); for (const review of reviews) { if (review.user != null && coreTeam.has(review.user.login)) { - latestByUser.set(review.user.login, review.state); + latestByUser.set(review.user.login, { + state: review.state, + commitId: review.commit_id, + }); } } // GitHub will take care of blocking the PR if reviewers did a request-changes, for instance. - const coreApprovals = [...latestByUser].filter(([, v]) => v === 'APPROVED').map(([k]) => k); + const coreApprovals = [...latestByUser] + .filter(([, review]) => review.state === 'APPROVED' && review.commitId === headSha) + .map(([login]) => login); if (coreApprovals != null && coreApprovals.length > 0) { - core.info(`PR approved by member(s) of the AZD team (${coreApprovals.join(",")})`) + core.info(`PR head commit approved by member(s) of the AZD team (${coreApprovals.join(",")})`) return true; } diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index 2eac6418e01..4820379a682 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -417,6 +417,59 @@ describe('run', () => { expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); }); + it('skips changed-file review when a core team member approved the current head commit', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + reviews: [ + { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'abc123' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).not.toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.anything()); + expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); + }); + + it('requires review when a core team approval is for an older head commit', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + reviews: [ + { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'older-commit' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('files outside cli/azd/extensions/registry.json')); + expect(octokit.paginate).toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.objectContaining({ + pull_number: 1, + })); + }); + it('uses the pull request base sha as the registry comparison base by default', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), pr: registry([extension()]) }); @@ -461,10 +514,10 @@ describe('run', () => { }); /** - * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[] }} args + * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[], reviews?: { user: { login: string }, state: string, commit_id: string }[] }} args * @returns {Octokit} */ -function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }] }) { +function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }], reviews = [] }) { const octokit = { rest: { pulls: { @@ -482,6 +535,10 @@ function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensi return Promise.resolve(files); } + if (endpoint === octokit.rest.pulls.listReviews) { + return Promise.resolve(reviews); + } + return Promise.resolve([]); }), }; From 251d72d5287ad2a0ac57d5fa0e4932222f48124d Mon Sep 17 00:00:00 2001 From: Richard Park <51494936+richardpark-msft@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:10:22 -0700 Subject: [PATCH 04/23] Reduce our permissions - the defaults in here are _way_ more than needed At most, we read the PR! Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ext-registry-check.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml index c3689d94817..32397320c7c 100644 --- a/.github/workflows/ext-registry-check.yml +++ b/.github/workflows/ext-registry-check.yml @@ -13,8 +13,7 @@ concurrency: cancel-in-progress: true permissions: - pull-requests: write - issues: write + pull-requests: read contents: read jobs: From b8eb58f2a390ea54598e8be6537ce19b4b9081e6 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 12:34:50 -0700 Subject: [PATCH 05/23] - Moving our script to match what we're doing in other Azure repos (specifically, azure-rest-api-specs). - Adding a comment into the script,. in case someone finds it first and isn't sure how it fits into the picture. - Removing the main attribute. We're a package, but not really, and that's okay. --- .github/scripts/ext-registry-check.js | 4 + .github/scripts/package.json | 1 - .github/scripts/src/ext-registry-check.js | 582 ++++++++++++++++++ .../scripts/test/ext-registry-check.test.js | 2 +- .github/workflows/ext-registry-check.yml | 2 +- 5 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/src/ext-registry-check.js diff --git a/.github/scripts/ext-registry-check.js b/.github/scripts/ext-registry-check.js index 4b1afa9c061..f801f325428 100644 --- a/.github/scripts/ext-registry-check.js +++ b/.github/scripts/ext-registry-check.js @@ -1,3 +1,7 @@ +// This is the workhorse behind the .github/workflows/ext-registry-check.yml workflow, which checks to see +// if a registry.json update is "safe" and can just be approved by any member of the team, or extended +// team of extension authors, or if it requires _specific_ review. +// const { isDeepStrictEqual } = require('node:util'); // GitHub Actions entry point. diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 761524b18fc..7ac8e9957a2 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -2,7 +2,6 @@ "name": "scripts", "version": "1.0.0", "description": "", - "main": "pr-approval-foundry-extensions-shared.js", "scripts": { "test": "vitest run" }, diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js new file mode 100644 index 00000000000..4b1afa9c061 --- /dev/null +++ b/.github/scripts/src/ext-registry-check.js @@ -0,0 +1,582 @@ +const { isDeepStrictEqual } = require('node:util'); + +// GitHub Actions entry point. +module.exports = run; + +// Test-only helpers exposed on the action entry point. +module.exports.forTests = { + getRegistryJson, + isApprovedByCoreTeam, + isAllowedRegistryJsonUpdate, + isCreatedByCoreTeam, + coreExtensionApprovers, + diffRegistry, +} + +/** + * Users that, when they approve, bypass any checks in this file. + */ +function coreExtensionApprovers() { + return new Set([ + "hemarina", + "JeffreyCA", + "RickWinter", + // TODO: bring me back from the dead! + // "richardpark-msft", + "tg-msft", + "vhvb1989", + ]); +} + +const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; + +// GitHub action types + +/** + * @typedef {typeof import('@actions/github').context} Context + * @typedef {ReturnType} Octokit + * @typedef {typeof import('@actions/core')} Core + * + * Response item types inferred from Octokit methods. + * @typedef {Awaited>['data'][number]} Review + * @typedef {Awaited>['data'][number]} PullRequestFile + */ + +// registry.json's types + +/** + * @typedef {object} Provider + * @property {string} name + * @property {string} type + * @property {string} [description] + * + * @typedef {object} ExtensionVersion + * @property {string} version + * @property {string[]} [capabilities] + * @property {Provider[]} [providers] + * + * @typedef {object} Extension + * @property {string} id + * @property {string} [namespace] + * @property {string} [displayName] + * @property {string} [description] + * @property {ExtensionVersion[]} versions + * + * @typedef {object} RegistryJson + * @property {Extension[]} extensions + */ + +/** + * @typedef {NonNullable} PullRequest + */ + +/** + * @param {{ github: Octokit, context: Context, core: Core, coreTeam?: Set, registryBaseRef?: string }} args + */ +async function run({ github: octokit, context, core, coreTeam = coreExtensionApprovers(), registryBaseRef }) { + try { + assertHasPullRequest(context); + const baseRef = registryBaseRef ?? context.payload.pull_request['base']?.sha ?? 'main'; + + // no extra checks needed if a core team member authored the PR. + if (isCreatedByCoreTeam({ context, core, coreTeam })) { + core.info(`PR was created by a core team, no further checks needed`) + return; + } + + // no extra checks needed if a core team member has already approved it. + if (await isApprovedByCoreTeam({ octokit, context, core, coreTeam })) { + core.info(`PR was approved by a core team member, no further checks needed`) + return; + } + + // Non-registry file changes require core-team review. + const changedFileReviewReasons = await getChangedFileReviewReasons({ + octokit, + context, + }); + + // Simple release-only registry changes can proceed without core-team review. + const registryReviewReasons = await isAllowedRegistryJsonUpdate({ + octokit, + context, + registryBaseRef: baseRef, + }); + + const reviewReasons = changedFileReviewReasons.concat(registryReviewReasons); + + if (reviewReasons.length === 0) { + core.info(`PR registry changes do not require core team review (no changes in capabilities, providers)`) + return; + } + + core.setFailed( + "PR changes the extension registry in a way that requires core team review:\n" + + reviewReasons.map((r) => `- ${r}`).join("\n") + + "\n\nTo fix:\n" + + `1. Have any core team member review and approve this PR. Core team members: (${[...coreExtensionApprovers()].join(", ")})\n` + + `2. After approval, re-run this build step so it'll re-evaluate the PR - no commits or pushes needed.` + ); + } catch (err) { + core.setFailed(`Internal failure in script: ${err instanceof Error ? err.message : err}`); + } +} + +/** + * @param {{ octokit: Octokit, context: Context, core: Core, coreTeam: Set }} args + * @returns {Promise} true if it is approved, false otherwise. + */ +async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { + if (coreTeam == null || coreTeam.size === 0) { + throw new Error("Invalid parameter - coreteam must be populated"); + } + + assertHasPullRequest(context); + + const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { + ...context.repo, + pull_number: context.payload.pull_request.number, + }); + + const headSha = context.payload.pull_request['head']?.sha; + if (!headSha) { + throw new Error('Unable to determine PR head sha for approval freshness check'); + } + + // users can have multiple reviews (ie, they requested changes, then they approved), so we'll + // make sure we get their absolutely latest review state. + + // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, + // and Map.set keeps the last entry per key - so this is "latest review per core-team user". + /** @type {Map} */ + const latestByUser = new Map(); + + for (const review of reviews) { + if (review.user != null && coreTeam.has(review.user.login)) { + latestByUser.set(review.user.login, { + state: review.state, + commitId: review.commit_id, + }); + } + } + + // GitHub will take care of blocking the PR if reviewers did a request-changes, for instance. + const coreApprovals = [...latestByUser] + .filter(([, review]) => review.state === 'APPROVED' && review.commitId === headSha) + .map(([login]) => login); + + if (coreApprovals != null && coreApprovals.length > 0) { + core.info(`PR head commit approved by member(s) of the AZD team (${coreApprovals.join(",")})`) + return true; + } + + return false; +} + +/** + * @param {{ context: Context, core: Core, coreTeam: Set }} args + * @returns {boolean} true if the PR author is a member of the core team, false otherwise. + */ +function isCreatedByCoreTeam({ context, core, coreTeam }) { + if (coreTeam == null || coreTeam.size === 0) { + throw new Error("Invalid parameter - coreteam must be populated"); + } + + assertHasPullRequest(context); + + const author = context.payload.pull_request['user']?.login; + + if (author != null && coreTeam.has(author)) { + core.info(`PR was created by a member of the AZD team (${author})`); + return true; + } + + return false; +} + +/** + * Checks whether the registry update is simple enough to proceed without core-team review. + * + * @param {{ octokit: Octokit, context: Context, registryBaseRef?: string }} args + * @returns {Promise} the reasons core team review is needed; empty means the change is approved + */ +async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef = 'main' }) { + assertHasPullRequest(context); + const pr = context.payload.pull_request; + + const mainRegistry = await getRegistryJson({ + octokit, + owner: context.repo.owner, + repo: context.repo.repo, + ref: registryBaseRef, + }); + + const head = pr['head']; + const ref = head?.sha ?? head?.ref; + if (!ref) { + throw new Error('Unable to determine PR head ref for registry.json update check'); + } + + const prRegistry = await getRegistryJson({ + octokit, + owner: head?.repo?.owner?.login ?? context.repo.owner, + repo: head?.repo?.name ?? context.repo.repo, + ref, + }); + + return diffRegistry(mainRegistry, prRegistry); +} + +/** + * Checks whether the PR changed only registry.json. + * + * @param {{ octokit: Octokit, context: Context }} args + * @returns {Promise} + */ +async function getChangedFileReviewReasons({ octokit, context }) { + const changedFiles = await getChangedFiles({ octokit, context }); + return diffChangedFiles(changedFiles); +} + +/** + * Fetches the list of files changed by the PR. + * + * @param {{ octokit: Octokit, context: Context }} args + * @returns {Promise} + */ +async function getChangedFiles({ octokit, context }) { + assertHasPullRequest(context); + + return await octokit.paginate(octokit.rest.pulls.listFiles, { + ...context.repo, + pull_number: context.payload.pull_request.number, + }); +} + +/** + * @param {PullRequestFile[]} changedFiles + * @returns {string[]} + */ +function diffChangedFiles(changedFiles) { + const unexpectedFiles = changedFiles + .filter((file) => file.filename !== REGISTRY_JSON_PATH || file.previous_filename != null) + .map((file) => file.filename); + + if (unexpectedFiles.length === 0) { + return []; + } + + return [ + `PR changes files outside ${REGISTRY_JSON_PATH}; registry auto-approval only applies to registry-only PRs: ${unexpectedFiles.join(', ')}`, + ]; +} + +/** + * Fetches and parses cli/azd/extensions/registry.json at a given ref. + * + * @param {{ octokit: Octokit, owner: string, repo: string, ref: string }} args + * @returns {Promise} + */ +async function getRegistryJson({ octokit, owner, repo, ref }) { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path: REGISTRY_JSON_PATH, + ref, + mediaType: { + format: 'raw', + }, + }); + + if (typeof data !== 'string') { + throw new Error(`Unable to load ${REGISTRY_JSON_PATH} from ${owner}/${repo}@${ref}`); + } + + return JSON.parse(data); +} + +/** + * Diffs the base (main) registry against the registry proposed by a PR and decides + * whether the change is safe enough to auto-approve, or whether a core team member + * needs to review it. + * + * New releases can be auto-approved when they keep the previous release's + * capabilities and providers, and only add a new release to an existing extension. + * + * @param {RegistryJson} baseRegistry registry.json as it exists on main + * @param {RegistryJson} prRegistry registry.json as proposed by the PR + * @returns {string[]} the reasons core team review is needed; empty means the change is approved + */ +function diffRegistry(baseRegistry, prRegistry) { + /** @type {string[]} */ + const reasons = []; + + const baseExtensions = new Map((baseRegistry.extensions ?? []).map((e) => [e.id, e])); + const prExtensions = new Map((prRegistry.extensions ?? []).map((e) => [e.id, e])); + + // brand new extensions can't be auto-approved. + for (const id of prExtensions.keys()) { + if (!baseExtensions.has(id)) { + reasons.push(`extension '${id}' is new; new extensions cannot be auto-approved`); + } + } + + // removing an existing extension can't be auto-approved. + for (const id of baseExtensions.keys()) { + if (!prExtensions.has(id)) { + reasons.push(`extension '${id}' was removed; removing extensions cannot be auto-approved`); + } + } + + for (const [id, prExtension] of prExtensions) { + const baseExtension = baseExtensions.get(id); + + if (baseExtension == null) { + continue; // already reported as a new extension above + } + + if (baseExtension.namespace !== prExtension.namespace) { + reasons.push(`extension '${id}' namespace changed; namespace changes cannot be auto-approved`); + } + + const baseVersions = new Map((baseExtension.versions ?? []).map((v) => [v.version, v])); + const prVersions = new Map((prExtension.versions ?? []).map((v) => [v.version, v])); + + reasons.push(...diffPublishedReleases(id, baseVersions, prVersions)); + reasons.push(...diffNewReleases(id, baseExtension.versions ?? [], baseVersions, prVersions)); + } + + return reasons; +} + +/** + * @param {string} id + * @param {Map} baseVersions + * @param {Map} prVersions + * @returns {string[]} + */ +function diffPublishedReleases(id, baseVersions, prVersions) { + /** @type {string[]} */ + const reasons = []; + + for (const [version, baseVersion] of baseVersions) { + const prVersion = prVersions.get(version); + if (prVersion == null) { + reasons.push(`extension '${id}' release '${version}' was removed; published releases are immutable`); + continue; + } + + const capabilityChanges = diffArrays(baseVersion.capabilities ?? [], prVersion.capabilities ?? []); + if (capabilityChanges.length > 0) { + reasons.push(`extension '${id}' release '${version}' changes capabilities (${capabilityChanges.join('; ')}); published capability declarations require core review`); + } + + const providerChanges = diffArrays(providerIdentityLabels(baseVersion), providerIdentityLabels(prVersion)); + if (providerChanges.length > 0) { + reasons.push(`extension '${id}' release '${version}' changes providers (${providerChanges.join('; ')}); published provider declarations require core review`); + } + + if (!isDeepStrictEqual(baseVersion, prVersion)) { + reasons.push(`extension '${id}' release '${version}' was modified; published releases are immutable`); + } + } + + return reasons; +} + +/** + * @param {string} id + * @param {ExtensionVersion[]} baseVersionList + * @param {Map} baseVersions + * @param {Map} prVersions + * @returns {string[]} + */ +function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { + /** @type {string[]} */ + const reasons = []; + const previousRelease = latestVersionBySemver(baseVersionList); + + for (const [version, prVersion] of prVersions) { + if (baseVersions.has(version)) { + continue; + } + + if (previousRelease == null) { + reasons.push(`extension '${id}' release '${version}' has no previous release to compare against`); + continue; + } + + const capabilityChanges = diffArrays(previousRelease.capabilities ?? [], prVersion.capabilities ?? []); + if (capabilityChanges.length > 0) { + reasons.push( + `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}' (${capabilityChanges.join('; ')})`, + ); + } + + const providerChanges = diffArrays(providerIdentityLabels(previousRelease), providerIdentityLabels(prVersion)); + if (providerChanges.length > 0) { + reasons.push( + `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, + ); + } + } + + return reasons; +} + +/** + * @param {string[]} baseItems + * @param {string[]} prItems + * @returns {string[]} + */ +function diffArrays(baseItems, prItems) { + const baseSet = new Set(baseItems); + const prSet = new Set(prItems); + const added = [...prSet].filter((item) => !baseSet.has(item)).sort(); + const removed = [...baseSet].filter((item) => !prSet.has(item)).sort(); + /** @type {string[]} */ + const changes = []; + + if (added.length > 0) { + changes.push(`added: ${added.join(', ')}`); + } + + if (removed.length > 0) { + changes.push(`removed: ${removed.join(', ')}`); + } + + return changes; +} + +/** + * @param {ExtensionVersion} version + * @returns {string[]} + */ +function providerIdentityLabels(version) { + return providerIdentities(version.providers ?? []).map((provider) => `${provider.name} (${provider.type})`); +} + +/** + * Reduces providers to their behavioral identity (name + type), sorted, so that a + * cosmetic description tweak doesn't force a core-team review, while any change to + * what the extension actually registers does. + * + * @param {Provider[]} providers + * @returns {{ name: string, type: string }[]} + */ +function providerIdentities(providers) { + return providers + .map((p) => ({ name: p.name, type: p.type })) + .sort((x, y) => x.name.localeCompare(y.name) || x.type.localeCompare(y.type)); +} + +/** + * @param {ExtensionVersion[]} versions + * @returns {ExtensionVersion | undefined} + */ +function latestVersionBySemver(versions) { + if (versions.length === 0) { + return undefined; + } + + return versions.reduce((latest, candidate) => + compareSemver(candidate.version, latest.version) > 0 ? candidate : latest + ); +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ +function compareSemver(a, b) { + const parsedA = parseSemver(a); + const parsedB = parseSemver(b); + + for (const key of /** @type {const} */ (['major', 'minor', 'patch'])) { + if (parsedA[key] !== parsedB[key]) { + return parsedA[key] < parsedB[key] ? -1 : 1; + } + } + + return comparePrerelease(parsedA.prerelease, parsedB.prerelease); +} + +/** + * @param {string} version + * @returns {{ major: number, minor: number, patch: number, prerelease: string }} + */ +function parseSemver(version) { + const withoutBuild = version.split('+')[0] ?? ''; + const coreAndPrerelease = withoutBuild.split('-'); + const core = coreAndPrerelease[0] ?? ''; + const prerelease = coreAndPrerelease[1] ?? ''; + const [major = 0, minor = 0, patch = 0] = core.split('.').map((n) => Number.parseInt(n, 10) || 0); + + return { major, minor, patch, prerelease }; +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ +function comparePrerelease(a, b) { + if (a === b) { + return 0; + } + if (a === '') { + return 1; + } + if (b === '') { + return -1; + } + + const aFields = a.split('.'); + const bFields = b.split('.'); + const fieldCount = Math.max(aFields.length, bFields.length); + + for (let i = 0; i < fieldCount; i++) { + const aField = aFields[i]; + const bField = bFields[i]; + + if (aField === undefined) { + return -1; + } + if (bField === undefined) { + return 1; + } + + const aNumeric = /^\d+$/.test(aField); + const bNumeric = /^\d+$/.test(bField); + + if (aNumeric && bNumeric) { + const diff = Number.parseInt(aField, 10) - Number.parseInt(bField, 10); + if (diff !== 0) { + return diff < 0 ? -1 : 1; + } + } else if (aNumeric) { + return -1; + } else if (bNumeric) { + return 1; + } else if (aField !== bField) { + return aField < bField ? -1 : 1; + } + } + + return 0; +} + +/** + * Asserts that we're being invoked for a pull request (and is also a typeguard) + * + * @param {Context} context + * @returns {asserts context is Context & { payload: { pull_request: PullRequest } }} + */ +function assertHasPullRequest(context) { + if (context.payload.pull_request == null) { + throw new Error('No pull_request found in event payload. Workflow targeting should only target pull requests.'); + } +} + + diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index 4820379a682..9d522ec551a 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { describe, it, expect, vi } from 'vitest'; -import run from '../ext-registry-check.js'; +import run from '../src/ext-registry-check.js'; /** * @typedef {typeof import('@actions/github').context} Context diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml index 32397320c7c..4f5a14474c6 100644 --- a/.github/workflows/ext-registry-check.yml +++ b/.github/workflows/ext-registry-check.yml @@ -29,6 +29,6 @@ jobs: uses: actions/github-script@v9 with: script: | - const script = require('./.github/scripts/ext-registry-check.js'); + const script = require('./.github/scripts/src/ext-registry-check.js'); await script({ github, context, core }); \ No newline at end of file From d1cd560878ff59daff68fb623094107e952550ff Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 14:30:11 -0700 Subject: [PATCH 06/23] Intermediate test change - want to see what rights I have to query a group in our organization for the list of maintainers. --- .github/scripts/src/ext-registry-check.js | 117 ++++++---- .../scripts/test/ext-registry-check.test.js | 221 ++++++++++++++++-- 2 files changed, 278 insertions(+), 60 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index 4b1afa9c061..18e7b991ad6 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -6,28 +6,14 @@ module.exports = run; // Test-only helpers exposed on the action entry point. module.exports.forTests = { getRegistryJson, + getCoreReviewers, isApprovedByCoreTeam, isAllowedRegistryJsonUpdate, isCreatedByCoreTeam, - coreExtensionApprovers, diffRegistry, } -/** - * Users that, when they approve, bypass any checks in this file. - */ -function coreExtensionApprovers() { - return new Set([ - "hemarina", - "JeffreyCA", - "RickWinter", - // TODO: bring me back from the dead! - // "richardpark-msft", - "tg-msft", - "vhvb1989", - ]); -} - +const CORE_REVIEW_TEAM_SLUG = 'azure-dev-extregistry-maintain'; const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; // GitHub action types @@ -73,30 +59,31 @@ const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; /** * @param {{ github: Octokit, context: Context, core: Core, coreTeam?: Set, registryBaseRef?: string }} args */ -async function run({ github: octokit, context, core, coreTeam = coreExtensionApprovers(), registryBaseRef }) { +async function run({ github: octokit, context, core, coreTeam, registryBaseRef }) { try { assertHasPullRequest(context); const baseRef = registryBaseRef ?? context.payload.pull_request['base']?.sha ?? 'main'; + const coreReviewers = coreTeam ?? await getCoreReviewers({ octokit, context, core }); - // no extra checks needed if a core team member authored the PR. - if (isCreatedByCoreTeam({ context, core, coreTeam })) { - core.info(`PR was created by a core team, no further checks needed`) + // no extra checks needed if a registry maintainer authored the PR. + if (isCreatedByCoreTeam({ context, core, coreTeam: coreReviewers })) { + core.info(`PR was created by a registry maintainer, no further checks needed`) return; } - // no extra checks needed if a core team member has already approved it. - if (await isApprovedByCoreTeam({ octokit, context, core, coreTeam })) { - core.info(`PR was approved by a core team member, no further checks needed`) + // no extra checks needed if a registry maintainer has already approved it. + if (await isApprovedByCoreTeam({ octokit, context, core, coreTeam: coreReviewers })) { + core.info(`PR was approved by a registry maintainer, no further checks needed`) return; } - // Non-registry file changes require core-team review. + // Non-registry file changes require core review. const changedFileReviewReasons = await getChangedFileReviewReasons({ octokit, context, }); - // Simple release-only registry changes can proceed without core-team review. + // Simple release-only registry changes can proceed without core review. const registryReviewReasons = await isAllowedRegistryJsonUpdate({ octokit, context, @@ -106,15 +93,15 @@ async function run({ github: octokit, context, core, coreTeam = coreExtensionApp const reviewReasons = changedFileReviewReasons.concat(registryReviewReasons); if (reviewReasons.length === 0) { - core.info(`PR registry changes do not require core team review (no changes in capabilities, providers)`) + core.info(`PR registry changes do not require core review (no changes in capabilities, providers)`) return; } core.setFailed( - "PR changes the extension registry in a way that requires core team review:\n" + + "Core review required for this extension registry change:\n" + reviewReasons.map((r) => `- ${r}`).join("\n") + "\n\nTo fix:\n" + - `1. Have any core team member review and approve this PR. Core team members: (${[...coreExtensionApprovers()].join(", ")})\n` + + `1. Have a member of @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG} review and approve this PR.\n` + `2. After approval, re-run this build step so it'll re-evaluate the PR - no commits or pushes needed.` ); } catch (err) { @@ -122,6 +109,38 @@ async function run({ github: octokit, context, core, coreTeam = coreExtensionApp } } +/** + * @param {{ octokit: Octokit, context: Context, core: Core }} args + * @returns {Promise>} + */ +async function getCoreReviewers({ octokit, context, core }) { + let members; + try { + members = await octokit.paginate(octokit.rest.teams.listMembersInOrg, { + org: context.repo.owner, + team_slug: CORE_REVIEW_TEAM_SLUG, + per_page: 100, + }); + } catch (err) { + throw new Error( + `Unable to load registry maintainers from @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}; ` + + `the workflow token may not have permission to read organization team membership: ${err instanceof Error ? err.message : err}` + ); + } + + const logins = members + .map((member) => member.login) + .filter((login) => typeof login === 'string' && login.length > 0) + .sort(); + + if (logins.length === 0) { + throw new Error(`No registry maintainers found in @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}`); + } + + core.info(`Loaded ${logins.length} registry maintainer(s) from @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}: ${logins.join(', ')}`); + return new Set(logins); +} + /** * @param {{ octokit: Octokit, context: Context, core: Core, coreTeam: Set }} args * @returns {Promise} true if it is approved, false otherwise. @@ -143,16 +162,25 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { throw new Error('Unable to determine PR head sha for approval freshness check'); } - // users can have multiple reviews (ie, they requested changes, then they approved), so we'll - // make sure we get their absolutely latest review state. + // Users can have multiple reviews (ie, they requested changes, then they approved), so we'll + // make sure we get their absolutely latest *decisive* review state. + + // COMMENTED / PENDING reviews are not a verdict - GitHub itself never treats them as changing a + // reviewer's approval standing, so we ignore them here too. Otherwise a maintainer who approves + // the head commit and then merely leaves a comment (a new COMMENTED review row) would look like + // they had withdrawn their approval. We keep APPROVED / CHANGES_REQUESTED / DISMISSED so that a + // later request-changes (or dismissal) from the same maintainer correctly overrides an earlier + // approval - we don't want to skip review for a PR that a maintainer both approved and then + // requested changes on. + const IGNORED_REVIEW_STATES = new Set(['COMMENTED', 'PENDING']); // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, - // and Map.set keeps the last entry per key - so this is "latest review per core-team user". + // and Map.set keeps the last entry per key - so this is "latest decisive review per registry maintainer". /** @type {Map} */ const latestByUser = new Map(); for (const review of reviews) { - if (review.user != null && coreTeam.has(review.user.login)) { + if (review.user != null && coreTeam.has(review.user.login) && !IGNORED_REVIEW_STATES.has(review.state)) { latestByUser.set(review.user.login, { state: review.state, commitId: review.commit_id, @@ -160,13 +188,12 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { } } - // GitHub will take care of blocking the PR if reviewers did a request-changes, for instance. const coreApprovals = [...latestByUser] .filter(([, review]) => review.state === 'APPROVED' && review.commitId === headSha) .map(([login]) => login); if (coreApprovals != null && coreApprovals.length > 0) { - core.info(`PR head commit approved by member(s) of the AZD team (${coreApprovals.join(",")})`) + core.info(`PR head commit approved by registry maintainer(s) (${coreApprovals.join(",")})`) return true; } @@ -175,7 +202,7 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { /** * @param {{ context: Context, core: Core, coreTeam: Set }} args - * @returns {boolean} true if the PR author is a member of the core team, false otherwise. + * @returns {boolean} true if the PR author is a registry maintainer, false otherwise. */ function isCreatedByCoreTeam({ context, core, coreTeam }) { if (coreTeam == null || coreTeam.size === 0) { @@ -198,7 +225,7 @@ function isCreatedByCoreTeam({ context, core, coreTeam }) { * Checks whether the registry update is simple enough to proceed without core-team review. * * @param {{ octokit: Octokit, context: Context, registryBaseRef?: string }} args - * @returns {Promise} the reasons core team review is needed; empty means the change is approved + * @returns {Promise} the reasons core review is needed; empty means the change is approved */ async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef = 'main' }) { assertHasPullRequest(context); @@ -267,7 +294,7 @@ function diffChangedFiles(changedFiles) { } return [ - `PR changes files outside ${REGISTRY_JSON_PATH}; registry auto-approval only applies to registry-only PRs: ${unexpectedFiles.join(', ')}`, + `PR changes files outside ${REGISTRY_JSON_PATH}; core review required for non-registry-only PRs: ${unexpectedFiles.join(', ')}`, ]; } @@ -297,15 +324,15 @@ async function getRegistryJson({ octokit, owner, repo, ref }) { /** * Diffs the base (main) registry against the registry proposed by a PR and decides - * whether the change is safe enough to auto-approve, or whether a core team member + * whether the change can proceed without core review, or whether a core reviewer * needs to review it. * - * New releases can be auto-approved when they keep the previous release's + * New releases can proceed without core review when they keep the previous release's * capabilities and providers, and only add a new release to an existing extension. * * @param {RegistryJson} baseRegistry registry.json as it exists on main * @param {RegistryJson} prRegistry registry.json as proposed by the PR - * @returns {string[]} the reasons core team review is needed; empty means the change is approved + * @returns {string[]} the reasons core review is needed; empty means the change is approved */ function diffRegistry(baseRegistry, prRegistry) { /** @type {string[]} */ @@ -314,17 +341,17 @@ function diffRegistry(baseRegistry, prRegistry) { const baseExtensions = new Map((baseRegistry.extensions ?? []).map((e) => [e.id, e])); const prExtensions = new Map((prRegistry.extensions ?? []).map((e) => [e.id, e])); - // brand new extensions can't be auto-approved. + // brand new extensions require core review. for (const id of prExtensions.keys()) { if (!baseExtensions.has(id)) { - reasons.push(`extension '${id}' is new; new extensions cannot be auto-approved`); + reasons.push(`extension '${id}' is new; new extensions require core review`); } } - // removing an existing extension can't be auto-approved. + // removing an existing extension requires core review. for (const id of baseExtensions.keys()) { if (!prExtensions.has(id)) { - reasons.push(`extension '${id}' was removed; removing extensions cannot be auto-approved`); + reasons.push(`extension '${id}' was removed; removing extensions requires core review`); } } @@ -336,7 +363,7 @@ function diffRegistry(baseRegistry, prRegistry) { } if (baseExtension.namespace !== prExtension.namespace) { - reasons.push(`extension '${id}' namespace changed; namespace changes cannot be auto-approved`); + reasons.push(`extension '${id}' namespace changed; namespace changes require core review`); } const baseVersions = new Map((baseExtension.versions ?? []).map((v) => [v.version, v])); diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index 9d522ec551a..fb3afdd7d32 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -32,7 +32,9 @@ import run from '../src/ext-registry-check.js'; const { diffRegistry, + getCoreReviewers, isAllowedRegistryJsonUpdate, + isApprovedByCoreTeam, } = run.forTests; /** @@ -360,7 +362,7 @@ describe('isAllowedRegistryJsonUpdate', () => { }); describe('run', () => { - it('fails fast when an empty core team is injected', async () => { + it('fails fast when an empty core review team is injected', async () => { const core = createNoopCore(); await run({ @@ -373,7 +375,36 @@ describe('run', () => { expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid parameter - coreteam must be populated')); }); - it('allows a simple registry-only PR without core team review', async () => { + it('loads registry maintainers from the GitHub team when no core review team is injected', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + teamMembers: [ + { login: 'registry-maintainer' }, + ], + }); + const context = createRegistryContext({ author: 'registry-maintainer' }); + + await run({ + github: octokit, + context, + core, + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).toHaveBeenCalledWith(octokit.rest.teams.listMembersInOrg, expect.objectContaining({ + org: 'Azure', + team_slug: 'azure-dev-extregistry-maintain', + })); + expect(octokit.paginate).not.toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.anything()); + }); + + it('allows a simple registry-only PR without core review', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension({ versions: [version({ version: '1.0.0' })] })]), @@ -393,7 +424,7 @@ describe('run', () => { })); }); - it('skips changed-file review when a core team member authored the PR', async () => { + it('skips changed-file review when a registry maintainer authored the PR', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), @@ -417,7 +448,7 @@ describe('run', () => { expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); }); - it('skips changed-file review when a core team member approved the current head commit', async () => { + it('skips changed-file review when a registry maintainer approved the current head commit', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), @@ -443,7 +474,7 @@ describe('run', () => { expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); }); - it('requires review when a core team approval is for an older head commit', async () => { + it('requires review when a registry maintainer approval is for an older head commit', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), @@ -470,6 +501,91 @@ describe('run', () => { })); }); + it('skips changed-file review when a maintainer approved the head commit and later only commented', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + // A trailing COMMENTED review is not a verdict and must not shadow the earlier approval. + reviews: [ + { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'abc123' }, + { user: { login: 'core-member' }, state: 'COMMENTED', commit_id: 'abc123' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).not.toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.anything()); + expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); + }); + + it('requires review when a maintainer approved the head commit and later requested changes', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + // Approval followed by a later request-changes (same head) is a withdrawal - do not bypass. + reviews: [ + { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'abc123' }, + { user: { login: 'core-member' }, state: 'CHANGES_REQUESTED', commit_id: 'abc123' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('files outside cli/azd/extensions/registry.json')); + expect(octokit.paginate).toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.objectContaining({ + pull_number: 1, + })); + }); + + it('skips changed-file review when a maintainer requested changes and then approved the head commit', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([extension()]), + pr: registry([extension()]), + files: [ + { filename: 'cli/azd/extensions/registry.json' }, + { filename: 'cli/azd/extensions/README.md' }, + ], + // Latest decisive review is the approval, so the change is bypassed. + reviews: [ + { user: { login: 'core-member' }, state: 'CHANGES_REQUESTED', commit_id: 'abc123' }, + { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'abc123' }, + ], + }); + + await run({ + github: octokit, + context: createRegistryContext(), + core, + coreTeam: new Set(['core-member']), + }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(octokit.paginate).not.toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.anything()); + expect(octokit.rest.repos.getContent).not.toHaveBeenCalled(); + }); + it('uses the pull request base sha as the registry comparison base by default', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), pr: registry([extension()]) }); @@ -513,17 +629,42 @@ describe('run', () => { }); }); +describe('getCoreReviewers', () => { + it('loads logins from the registry maintainer GitHub team', async () => { + const core = createNoopCore(); + const octokit = createRegistryOctokit({ + base: registry([]), + pr: registry([]), + teamMembers: [ + { login: 'tg-msft' }, + { login: 'azd-bot' }, + ], + }); + + await expect(getCoreReviewers({ + octokit, + context: createRegistryContext(), + core, + })).resolves.toEqual(new Set(['azd-bot', 'tg-msft'])); + + expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Loaded 2 registry maintainer(s)')); + }); +}); + /** - * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[], reviews?: { user: { login: string }, state: string, commit_id: string }[] }} args + * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[], reviews?: { user: { login: string }, state: string, commit_id: string }[], teamMembers?: { login?: string }[] }} args * @returns {Octokit} */ -function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }], reviews = [] }) { +function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }], reviews = [], teamMembers = [] }) { const octokit = { rest: { pulls: { listReviews: vi.fn(), listFiles: vi.fn(), }, + teams: { + listMembersInOrg: vi.fn(), + }, repos: { getContent: vi.fn(({ ref }) => Promise.resolve({ data: JSON.stringify(ref === 'abc123' ? pr : base), @@ -539,6 +680,10 @@ function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensi return Promise.resolve(reviews); } + if (endpoint === octokit.rest.teams.listMembersInOrg) { + return Promise.resolve(teamMembers); + } + return Promise.resolve([]); }), }; @@ -613,17 +758,18 @@ async function createLiveOctokit() { /** * @param {Octokit} octokit * @param {number} prNumber + * @param {{ owner?: string, repo?: string }} [target] * @returns {Promise} */ -async function createLiveContext(octokit, prNumber) { +async function createLiveContext(octokit, prNumber, { owner = LIVE_TEST_OWNER, repo = LIVE_TEST_REPO } = {}) { const { data: pr } = await octokit.rest.pulls.get({ - owner: LIVE_TEST_OWNER, - repo: LIVE_TEST_REPO, + owner, + repo, pull_number: prNumber, }); return /** @type {Context} */ (/** @type {unknown} */ ({ - repo: { owner: LIVE_TEST_OWNER, repo: LIVE_TEST_REPO }, + repo: { owner, repo }, payload: { pull_request: { number: pr.number, @@ -719,7 +865,7 @@ liveDescribe('[live] registry diff PR scenarios', () => { if (sample.noReviewRequired) { expect(core.setFailed).not.toHaveBeenCalled(); } else { - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires core team review')); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Core review required')); } } @@ -727,12 +873,12 @@ liveDescribe('[live] registry diff PR scenarios', () => { describe("core approval bypass", () => { // https://github.com/Azure/azure-dev/pull/9027 - it('[live] PR 9027 => core team member is the author', async () => { + it('[live] PR 9027 => core reviewer is the author', async () => { await runTestAgainstLivePr({ number: 9027, noReviewRequired: true }); }, 90_000); // https://github.com/Azure/azure-dev/pull/8958 - it('[live] PR 8958 => core team member approved', async () => { + it('[live] PR 8958 => core reviewer approved', async () => { await runTestAgainstLivePr({ number: 8958, noReviewRequired: true }); }, 90_000); }) @@ -748,11 +894,56 @@ liveDescribe('[live] registry diff PR scenarios', () => { }, 90_000); // https://github.com/Azure/azure-dev/pull/8972 - it('[live] PR 8972 => core team approval required because the PR changes another file', async () => { + it('[live] PR 8972 => core review required because the PR changes another file', async () => { await runTestAgainstLivePr({ number: 8972, noReviewRequired: false }); }, 90_000); }); +// Point this at a PR you're experimenting with (e.g. approve it, then leave a COMMENTED review, or +// request changes) to verify the maintainer-approval short-circuit against GitHub's live review +// state. It compares isApprovedByCoreTeam's verdict to what you expect. Requires RUN_LIVE_TESTS=1 +// plus: +// LIVE_APPROVAL_PR - PR number to inspect +// LIVE_APPROVAL_EXPECTED - 'true' or 'false' (expected isApprovedByCoreTeam result) +// LIVE_APPROVAL_TEAM - optional comma-separated maintainer logins to treat as the core team +// (defaults to the live registry maintainer team via getCoreReviewers) +// LIVE_APPROVAL_REPO - optional 'owner/name' (defaults to Azure/azure-dev) +const LIVE_APPROVAL_PR = process.env['LIVE_APPROVAL_PR']; +const approvalLiveDescribe = RUN_LIVE_TESTS && LIVE_APPROVAL_PR ? describe : describe.skip; + +approvalLiveDescribe('[live] maintainer approval verdict', () => { + it('[live] isApprovedByCoreTeam matches the expected verdict for LIVE_APPROVAL_PR', async () => { + const prNumber = Number(LIVE_APPROVAL_PR); + if (!Number.isInteger(prNumber)) { + throw new Error(`LIVE_APPROVAL_PR must be a PR number, got: ${LIVE_APPROVAL_PR}`); + } + + const expectedRaw = process.env['LIVE_APPROVAL_EXPECTED']; + if (expectedRaw !== 'true' && expectedRaw !== 'false') { + throw new Error(`LIVE_APPROVAL_EXPECTED must be 'true' or 'false', got: ${expectedRaw}`); + } + const expected = expectedRaw === 'true'; + + const [owner = LIVE_TEST_OWNER, repo = LIVE_TEST_REPO] = (process.env['LIVE_APPROVAL_REPO'] || '').split('/'); + + const octokit = await createLiveOctokit(); + const context = await createLiveContext(octokit, prNumber, { owner, repo }); + const core = createNoopCore(); + + const teamEnv = (process.env['LIVE_APPROVAL_TEAM'] || '') + .split(',') + .map((login) => login.trim()) + .filter((login) => login.length > 0); + const coreTeam = teamEnv.length > 0 + ? new Set(teamEnv) + : await getCoreReviewers({ octokit, context, core }); + + const approved = await isApprovedByCoreTeam({ octokit, context, core, coreTeam }); + + expect(approved).toBe(expected); + }, 90_000); +}); + /** @returns {Core} */ function createNoopCore() { const core = { From a04b4bd26ebf2b02b1278b30a13c528460362842 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 14:33:52 -0700 Subject: [PATCH 07/23] Remove the root one. --- .github/scripts/ext-registry-check.js | 586 -------------------------- 1 file changed, 586 deletions(-) delete mode 100644 .github/scripts/ext-registry-check.js diff --git a/.github/scripts/ext-registry-check.js b/.github/scripts/ext-registry-check.js deleted file mode 100644 index f801f325428..00000000000 --- a/.github/scripts/ext-registry-check.js +++ /dev/null @@ -1,586 +0,0 @@ -// This is the workhorse behind the .github/workflows/ext-registry-check.yml workflow, which checks to see -// if a registry.json update is "safe" and can just be approved by any member of the team, or extended -// team of extension authors, or if it requires _specific_ review. -// -const { isDeepStrictEqual } = require('node:util'); - -// GitHub Actions entry point. -module.exports = run; - -// Test-only helpers exposed on the action entry point. -module.exports.forTests = { - getRegistryJson, - isApprovedByCoreTeam, - isAllowedRegistryJsonUpdate, - isCreatedByCoreTeam, - coreExtensionApprovers, - diffRegistry, -} - -/** - * Users that, when they approve, bypass any checks in this file. - */ -function coreExtensionApprovers() { - return new Set([ - "hemarina", - "JeffreyCA", - "RickWinter", - // TODO: bring me back from the dead! - // "richardpark-msft", - "tg-msft", - "vhvb1989", - ]); -} - -const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; - -// GitHub action types - -/** - * @typedef {typeof import('@actions/github').context} Context - * @typedef {ReturnType} Octokit - * @typedef {typeof import('@actions/core')} Core - * - * Response item types inferred from Octokit methods. - * @typedef {Awaited>['data'][number]} Review - * @typedef {Awaited>['data'][number]} PullRequestFile - */ - -// registry.json's types - -/** - * @typedef {object} Provider - * @property {string} name - * @property {string} type - * @property {string} [description] - * - * @typedef {object} ExtensionVersion - * @property {string} version - * @property {string[]} [capabilities] - * @property {Provider[]} [providers] - * - * @typedef {object} Extension - * @property {string} id - * @property {string} [namespace] - * @property {string} [displayName] - * @property {string} [description] - * @property {ExtensionVersion[]} versions - * - * @typedef {object} RegistryJson - * @property {Extension[]} extensions - */ - -/** - * @typedef {NonNullable} PullRequest - */ - -/** - * @param {{ github: Octokit, context: Context, core: Core, coreTeam?: Set, registryBaseRef?: string }} args - */ -async function run({ github: octokit, context, core, coreTeam = coreExtensionApprovers(), registryBaseRef }) { - try { - assertHasPullRequest(context); - const baseRef = registryBaseRef ?? context.payload.pull_request['base']?.sha ?? 'main'; - - // no extra checks needed if a core team member authored the PR. - if (isCreatedByCoreTeam({ context, core, coreTeam })) { - core.info(`PR was created by a core team, no further checks needed`) - return; - } - - // no extra checks needed if a core team member has already approved it. - if (await isApprovedByCoreTeam({ octokit, context, core, coreTeam })) { - core.info(`PR was approved by a core team member, no further checks needed`) - return; - } - - // Non-registry file changes require core-team review. - const changedFileReviewReasons = await getChangedFileReviewReasons({ - octokit, - context, - }); - - // Simple release-only registry changes can proceed without core-team review. - const registryReviewReasons = await isAllowedRegistryJsonUpdate({ - octokit, - context, - registryBaseRef: baseRef, - }); - - const reviewReasons = changedFileReviewReasons.concat(registryReviewReasons); - - if (reviewReasons.length === 0) { - core.info(`PR registry changes do not require core team review (no changes in capabilities, providers)`) - return; - } - - core.setFailed( - "PR changes the extension registry in a way that requires core team review:\n" + - reviewReasons.map((r) => `- ${r}`).join("\n") + - "\n\nTo fix:\n" + - `1. Have any core team member review and approve this PR. Core team members: (${[...coreExtensionApprovers()].join(", ")})\n` + - `2. After approval, re-run this build step so it'll re-evaluate the PR - no commits or pushes needed.` - ); - } catch (err) { - core.setFailed(`Internal failure in script: ${err instanceof Error ? err.message : err}`); - } -} - -/** - * @param {{ octokit: Octokit, context: Context, core: Core, coreTeam: Set }} args - * @returns {Promise} true if it is approved, false otherwise. - */ -async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { - if (coreTeam == null || coreTeam.size === 0) { - throw new Error("Invalid parameter - coreteam must be populated"); - } - - assertHasPullRequest(context); - - const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { - ...context.repo, - pull_number: context.payload.pull_request.number, - }); - - const headSha = context.payload.pull_request['head']?.sha; - if (!headSha) { - throw new Error('Unable to determine PR head sha for approval freshness check'); - } - - // users can have multiple reviews (ie, they requested changes, then they approved), so we'll - // make sure we get their absolutely latest review state. - - // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, - // and Map.set keeps the last entry per key - so this is "latest review per core-team user". - /** @type {Map} */ - const latestByUser = new Map(); - - for (const review of reviews) { - if (review.user != null && coreTeam.has(review.user.login)) { - latestByUser.set(review.user.login, { - state: review.state, - commitId: review.commit_id, - }); - } - } - - // GitHub will take care of blocking the PR if reviewers did a request-changes, for instance. - const coreApprovals = [...latestByUser] - .filter(([, review]) => review.state === 'APPROVED' && review.commitId === headSha) - .map(([login]) => login); - - if (coreApprovals != null && coreApprovals.length > 0) { - core.info(`PR head commit approved by member(s) of the AZD team (${coreApprovals.join(",")})`) - return true; - } - - return false; -} - -/** - * @param {{ context: Context, core: Core, coreTeam: Set }} args - * @returns {boolean} true if the PR author is a member of the core team, false otherwise. - */ -function isCreatedByCoreTeam({ context, core, coreTeam }) { - if (coreTeam == null || coreTeam.size === 0) { - throw new Error("Invalid parameter - coreteam must be populated"); - } - - assertHasPullRequest(context); - - const author = context.payload.pull_request['user']?.login; - - if (author != null && coreTeam.has(author)) { - core.info(`PR was created by a member of the AZD team (${author})`); - return true; - } - - return false; -} - -/** - * Checks whether the registry update is simple enough to proceed without core-team review. - * - * @param {{ octokit: Octokit, context: Context, registryBaseRef?: string }} args - * @returns {Promise} the reasons core team review is needed; empty means the change is approved - */ -async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef = 'main' }) { - assertHasPullRequest(context); - const pr = context.payload.pull_request; - - const mainRegistry = await getRegistryJson({ - octokit, - owner: context.repo.owner, - repo: context.repo.repo, - ref: registryBaseRef, - }); - - const head = pr['head']; - const ref = head?.sha ?? head?.ref; - if (!ref) { - throw new Error('Unable to determine PR head ref for registry.json update check'); - } - - const prRegistry = await getRegistryJson({ - octokit, - owner: head?.repo?.owner?.login ?? context.repo.owner, - repo: head?.repo?.name ?? context.repo.repo, - ref, - }); - - return diffRegistry(mainRegistry, prRegistry); -} - -/** - * Checks whether the PR changed only registry.json. - * - * @param {{ octokit: Octokit, context: Context }} args - * @returns {Promise} - */ -async function getChangedFileReviewReasons({ octokit, context }) { - const changedFiles = await getChangedFiles({ octokit, context }); - return diffChangedFiles(changedFiles); -} - -/** - * Fetches the list of files changed by the PR. - * - * @param {{ octokit: Octokit, context: Context }} args - * @returns {Promise} - */ -async function getChangedFiles({ octokit, context }) { - assertHasPullRequest(context); - - return await octokit.paginate(octokit.rest.pulls.listFiles, { - ...context.repo, - pull_number: context.payload.pull_request.number, - }); -} - -/** - * @param {PullRequestFile[]} changedFiles - * @returns {string[]} - */ -function diffChangedFiles(changedFiles) { - const unexpectedFiles = changedFiles - .filter((file) => file.filename !== REGISTRY_JSON_PATH || file.previous_filename != null) - .map((file) => file.filename); - - if (unexpectedFiles.length === 0) { - return []; - } - - return [ - `PR changes files outside ${REGISTRY_JSON_PATH}; registry auto-approval only applies to registry-only PRs: ${unexpectedFiles.join(', ')}`, - ]; -} - -/** - * Fetches and parses cli/azd/extensions/registry.json at a given ref. - * - * @param {{ octokit: Octokit, owner: string, repo: string, ref: string }} args - * @returns {Promise} - */ -async function getRegistryJson({ octokit, owner, repo, ref }) { - const { data } = await octokit.rest.repos.getContent({ - owner, - repo, - path: REGISTRY_JSON_PATH, - ref, - mediaType: { - format: 'raw', - }, - }); - - if (typeof data !== 'string') { - throw new Error(`Unable to load ${REGISTRY_JSON_PATH} from ${owner}/${repo}@${ref}`); - } - - return JSON.parse(data); -} - -/** - * Diffs the base (main) registry against the registry proposed by a PR and decides - * whether the change is safe enough to auto-approve, or whether a core team member - * needs to review it. - * - * New releases can be auto-approved when they keep the previous release's - * capabilities and providers, and only add a new release to an existing extension. - * - * @param {RegistryJson} baseRegistry registry.json as it exists on main - * @param {RegistryJson} prRegistry registry.json as proposed by the PR - * @returns {string[]} the reasons core team review is needed; empty means the change is approved - */ -function diffRegistry(baseRegistry, prRegistry) { - /** @type {string[]} */ - const reasons = []; - - const baseExtensions = new Map((baseRegistry.extensions ?? []).map((e) => [e.id, e])); - const prExtensions = new Map((prRegistry.extensions ?? []).map((e) => [e.id, e])); - - // brand new extensions can't be auto-approved. - for (const id of prExtensions.keys()) { - if (!baseExtensions.has(id)) { - reasons.push(`extension '${id}' is new; new extensions cannot be auto-approved`); - } - } - - // removing an existing extension can't be auto-approved. - for (const id of baseExtensions.keys()) { - if (!prExtensions.has(id)) { - reasons.push(`extension '${id}' was removed; removing extensions cannot be auto-approved`); - } - } - - for (const [id, prExtension] of prExtensions) { - const baseExtension = baseExtensions.get(id); - - if (baseExtension == null) { - continue; // already reported as a new extension above - } - - if (baseExtension.namespace !== prExtension.namespace) { - reasons.push(`extension '${id}' namespace changed; namespace changes cannot be auto-approved`); - } - - const baseVersions = new Map((baseExtension.versions ?? []).map((v) => [v.version, v])); - const prVersions = new Map((prExtension.versions ?? []).map((v) => [v.version, v])); - - reasons.push(...diffPublishedReleases(id, baseVersions, prVersions)); - reasons.push(...diffNewReleases(id, baseExtension.versions ?? [], baseVersions, prVersions)); - } - - return reasons; -} - -/** - * @param {string} id - * @param {Map} baseVersions - * @param {Map} prVersions - * @returns {string[]} - */ -function diffPublishedReleases(id, baseVersions, prVersions) { - /** @type {string[]} */ - const reasons = []; - - for (const [version, baseVersion] of baseVersions) { - const prVersion = prVersions.get(version); - if (prVersion == null) { - reasons.push(`extension '${id}' release '${version}' was removed; published releases are immutable`); - continue; - } - - const capabilityChanges = diffArrays(baseVersion.capabilities ?? [], prVersion.capabilities ?? []); - if (capabilityChanges.length > 0) { - reasons.push(`extension '${id}' release '${version}' changes capabilities (${capabilityChanges.join('; ')}); published capability declarations require core review`); - } - - const providerChanges = diffArrays(providerIdentityLabels(baseVersion), providerIdentityLabels(prVersion)); - if (providerChanges.length > 0) { - reasons.push(`extension '${id}' release '${version}' changes providers (${providerChanges.join('; ')}); published provider declarations require core review`); - } - - if (!isDeepStrictEqual(baseVersion, prVersion)) { - reasons.push(`extension '${id}' release '${version}' was modified; published releases are immutable`); - } - } - - return reasons; -} - -/** - * @param {string} id - * @param {ExtensionVersion[]} baseVersionList - * @param {Map} baseVersions - * @param {Map} prVersions - * @returns {string[]} - */ -function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { - /** @type {string[]} */ - const reasons = []; - const previousRelease = latestVersionBySemver(baseVersionList); - - for (const [version, prVersion] of prVersions) { - if (baseVersions.has(version)) { - continue; - } - - if (previousRelease == null) { - reasons.push(`extension '${id}' release '${version}' has no previous release to compare against`); - continue; - } - - const capabilityChanges = diffArrays(previousRelease.capabilities ?? [], prVersion.capabilities ?? []); - if (capabilityChanges.length > 0) { - reasons.push( - `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}' (${capabilityChanges.join('; ')})`, - ); - } - - const providerChanges = diffArrays(providerIdentityLabels(previousRelease), providerIdentityLabels(prVersion)); - if (providerChanges.length > 0) { - reasons.push( - `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, - ); - } - } - - return reasons; -} - -/** - * @param {string[]} baseItems - * @param {string[]} prItems - * @returns {string[]} - */ -function diffArrays(baseItems, prItems) { - const baseSet = new Set(baseItems); - const prSet = new Set(prItems); - const added = [...prSet].filter((item) => !baseSet.has(item)).sort(); - const removed = [...baseSet].filter((item) => !prSet.has(item)).sort(); - /** @type {string[]} */ - const changes = []; - - if (added.length > 0) { - changes.push(`added: ${added.join(', ')}`); - } - - if (removed.length > 0) { - changes.push(`removed: ${removed.join(', ')}`); - } - - return changes; -} - -/** - * @param {ExtensionVersion} version - * @returns {string[]} - */ -function providerIdentityLabels(version) { - return providerIdentities(version.providers ?? []).map((provider) => `${provider.name} (${provider.type})`); -} - -/** - * Reduces providers to their behavioral identity (name + type), sorted, so that a - * cosmetic description tweak doesn't force a core-team review, while any change to - * what the extension actually registers does. - * - * @param {Provider[]} providers - * @returns {{ name: string, type: string }[]} - */ -function providerIdentities(providers) { - return providers - .map((p) => ({ name: p.name, type: p.type })) - .sort((x, y) => x.name.localeCompare(y.name) || x.type.localeCompare(y.type)); -} - -/** - * @param {ExtensionVersion[]} versions - * @returns {ExtensionVersion | undefined} - */ -function latestVersionBySemver(versions) { - if (versions.length === 0) { - return undefined; - } - - return versions.reduce((latest, candidate) => - compareSemver(candidate.version, latest.version) > 0 ? candidate : latest - ); -} - -/** - * @param {string} a - * @param {string} b - * @returns {number} - */ -function compareSemver(a, b) { - const parsedA = parseSemver(a); - const parsedB = parseSemver(b); - - for (const key of /** @type {const} */ (['major', 'minor', 'patch'])) { - if (parsedA[key] !== parsedB[key]) { - return parsedA[key] < parsedB[key] ? -1 : 1; - } - } - - return comparePrerelease(parsedA.prerelease, parsedB.prerelease); -} - -/** - * @param {string} version - * @returns {{ major: number, minor: number, patch: number, prerelease: string }} - */ -function parseSemver(version) { - const withoutBuild = version.split('+')[0] ?? ''; - const coreAndPrerelease = withoutBuild.split('-'); - const core = coreAndPrerelease[0] ?? ''; - const prerelease = coreAndPrerelease[1] ?? ''; - const [major = 0, minor = 0, patch = 0] = core.split('.').map((n) => Number.parseInt(n, 10) || 0); - - return { major, minor, patch, prerelease }; -} - -/** - * @param {string} a - * @param {string} b - * @returns {number} - */ -function comparePrerelease(a, b) { - if (a === b) { - return 0; - } - if (a === '') { - return 1; - } - if (b === '') { - return -1; - } - - const aFields = a.split('.'); - const bFields = b.split('.'); - const fieldCount = Math.max(aFields.length, bFields.length); - - for (let i = 0; i < fieldCount; i++) { - const aField = aFields[i]; - const bField = bFields[i]; - - if (aField === undefined) { - return -1; - } - if (bField === undefined) { - return 1; - } - - const aNumeric = /^\d+$/.test(aField); - const bNumeric = /^\d+$/.test(bField); - - if (aNumeric && bNumeric) { - const diff = Number.parseInt(aField, 10) - Number.parseInt(bField, 10); - if (diff !== 0) { - return diff < 0 ? -1 : 1; - } - } else if (aNumeric) { - return -1; - } else if (bNumeric) { - return 1; - } else if (aField !== bField) { - return aField < bField ? -1 : 1; - } - } - - return 0; -} - -/** - * Asserts that we're being invoked for a pull request (and is also a typeguard) - * - * @param {Context} context - * @returns {asserts context is Context & { payload: { pull_request: PullRequest } }} - */ -function assertHasPullRequest(context) { - if (context.payload.pull_request == null) { - throw new Error('No pull_request found in event payload. Workflow targeting should only target pull requests.'); - } -} - - From 5f50bf0f0d77686a1b9760468252727a3e17a734 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 15:20:34 -0700 Subject: [PATCH 08/23] Going back to the hardcoded list, for now, as reading from an organization group requires more permissions for a small, mostly static, list of people. --- .github/scripts/src/ext-registry-check.js | 48 ++++++++++------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index 18e7b991ad6..b6633809e40 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -63,7 +63,7 @@ async function run({ github: octokit, context, core, coreTeam, registryBaseRef } try { assertHasPullRequest(context); const baseRef = registryBaseRef ?? context.payload.pull_request['base']?.sha ?? 'main'; - const coreReviewers = coreTeam ?? await getCoreReviewers({ octokit, context, core }); + const coreReviewers = coreTeam ?? getCoreReviewers({ core }); // no extra checks needed if a registry maintainer authored the PR. if (isCreatedByCoreTeam({ context, core, coreTeam: coreReviewers })) { @@ -110,34 +110,28 @@ async function run({ github: octokit, context, core, coreTeam, registryBaseRef } } /** - * @param {{ octokit: Octokit, context: Context, core: Core }} args - * @returns {Promise>} + * Registry maintainers whose PRs skip the extra checks and whose approval + * clears a PR for merge. + * + * This is intentionally a hard-coded list: the workflow's GITHUB_TOKEN can't read + * organization team membership (@${owner}/azure-dev-extregistry-maintain), and the + * membership is fairly static. Keep this in sync with that team. + * + * @param {{ core: Core }} args + * @returns {Set} */ -async function getCoreReviewers({ octokit, context, core }) { - let members; - try { - members = await octokit.paginate(octokit.rest.teams.listMembersInOrg, { - org: context.repo.owner, - team_slug: CORE_REVIEW_TEAM_SLUG, - per_page: 100, - }); - } catch (err) { - throw new Error( - `Unable to load registry maintainers from @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}; ` + - `the workflow token may not have permission to read organization team membership: ${err instanceof Error ? err.message : err}` - ); - } - - const logins = members - .map((member) => member.login) - .filter((login) => typeof login === 'string' && login.length > 0) - .sort(); - - if (logins.length === 0) { - throw new Error(`No registry maintainers found in @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}`); - } +function getCoreReviewers({ core }) { + const logins = [ + 'hemarina', + 'JeffreyCA', + 'RickWinter', + // TODO: bring me back from the dead! + // 'richardpark-msft', + 'tg-msft', + 'vhvb1989', + ]; - core.info(`Loaded ${logins.length} registry maintainer(s) from @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG}: ${logins.join(', ')}`); + core.info(`Loaded ${logins.length} registry maintainer(s): ${logins.join(', ')}`); return new Set(logins); } From acece2d4f047b2443fba0a00c5daeecc7ab4524b Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 15:29:34 -0700 Subject: [PATCH 09/23] Some more cleanup since we're back to the hardcoded list of team members. --- .github/scripts/src/ext-registry-check.js | 2 +- .../scripts/test/ext-registry-check.test.js | 48 +++++-------------- .github/workflows/ext-registry-check.yml | 4 +- 3 files changed, 17 insertions(+), 37 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index b6633809e40..ec5845477f4 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -114,7 +114,7 @@ async function run({ github: octokit, context, core, coreTeam, registryBaseRef } * clears a PR for merge. * * This is intentionally a hard-coded list: the workflow's GITHUB_TOKEN can't read - * organization team membership (@${owner}/azure-dev-extregistry-maintain), and the + * organization team membership (@Azure/azure-dev-extregistry-maintain), and the * membership is fairly static. Keep this in sync with that team. * * @param {{ core: Core }} args diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index fb3afdd7d32..a4e654be190 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -375,7 +375,7 @@ describe('run', () => { expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid parameter - coreteam must be populated')); }); - it('loads registry maintainers from the GitHub team when no core review team is injected', async () => { + it('uses the hardcoded registry maintainer list when no core review team is injected', async () => { const core = createNoopCore(); const octokit = createRegistryOctokit({ base: registry([extension()]), @@ -384,11 +384,8 @@ describe('run', () => { { filename: 'cli/azd/extensions/registry.json' }, { filename: 'cli/azd/extensions/README.md' }, ], - teamMembers: [ - { login: 'registry-maintainer' }, - ], }); - const context = createRegistryContext({ author: 'registry-maintainer' }); + const context = createRegistryContext({ author: 'tg-msft' }); await run({ github: octokit, @@ -397,10 +394,6 @@ describe('run', () => { }); expect(core.setFailed).not.toHaveBeenCalled(); - expect(octokit.paginate).toHaveBeenCalledWith(octokit.rest.teams.listMembersInOrg, expect.objectContaining({ - org: 'Azure', - team_slug: 'azure-dev-extregistry-maintain', - })); expect(octokit.paginate).not.toHaveBeenCalledWith(octokit.rest.pulls.listFiles, expect.anything()); }); @@ -538,8 +531,9 @@ describe('run', () => { { filename: 'cli/azd/extensions/registry.json' }, { filename: 'cli/azd/extensions/README.md' }, ], - // Approval followed by a later request-changes (same head) is a withdrawal - do not bypass. reviews: [ + // basically, the user approved it, but then (on the same commit), requested changes. + // the ordering here will be correct, but we need to make sure we note that they are NOT approved. { user: { login: 'core-member' }, state: 'APPROVED', commit_id: 'abc123' }, { user: { login: 'core-member' }, state: 'CHANGES_REQUESTED', commit_id: 'abc123' }, ], @@ -630,41 +624,29 @@ describe('run', () => { }); describe('getCoreReviewers', () => { - it('loads logins from the registry maintainer GitHub team', async () => { + it('returns the hardcoded registry maintainer logins', () => { const core = createNoopCore(); - const octokit = createRegistryOctokit({ - base: registry([]), - pr: registry([]), - teamMembers: [ - { login: 'tg-msft' }, - { login: 'azd-bot' }, - ], - }); - await expect(getCoreReviewers({ - octokit, - context: createRegistryContext(), - core, - })).resolves.toEqual(new Set(['azd-bot', 'tg-msft'])); + const reviewers = getCoreReviewers({ core }); - expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Loaded 2 registry maintainer(s)')); + expect(reviewers).toBeInstanceOf(Set); + expect(reviewers.size).toBeGreaterThan(0); + expect(reviewers.has('tg-msft')).toBe(true); + expect(core.info).toHaveBeenCalledWith(expect.stringContaining(`Loaded ${reviewers.size} registry maintainer(s)`)); }); }); /** - * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[], reviews?: { user: { login: string }, state: string, commit_id: string }[], teamMembers?: { login?: string }[] }} args + * @param {{ base: RegistryJson, pr: RegistryJson, files?: { filename: string, previous_filename?: string }[], reviews?: { user: { login: string }, state: string, commit_id: string }[] }} args * @returns {Octokit} */ -function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }], reviews = [], teamMembers = [] }) { +function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensions/registry.json' }], reviews = [] }) { const octokit = { rest: { pulls: { listReviews: vi.fn(), listFiles: vi.fn(), }, - teams: { - listMembersInOrg: vi.fn(), - }, repos: { getContent: vi.fn(({ ref }) => Promise.resolve({ data: JSON.stringify(ref === 'abc123' ? pr : base), @@ -680,10 +662,6 @@ function createRegistryOctokit({ base, pr, files = [{ filename: 'cli/azd/extensi return Promise.resolve(reviews); } - if (endpoint === octokit.rest.teams.listMembersInOrg) { - return Promise.resolve(teamMembers); - } - return Promise.resolve([]); }), }; @@ -936,7 +914,7 @@ approvalLiveDescribe('[live] maintainer approval verdict', () => { .filter((login) => login.length > 0); const coreTeam = teamEnv.length > 0 ? new Set(teamEnv) - : await getCoreReviewers({ octokit, context, core }); + : getCoreReviewers({ core }); const approved = await isApprovedByCoreTeam({ octokit, context, core, coreTeam }); diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml index 4f5a14474c6..2776b612534 100644 --- a/.github/workflows/ext-registry-check.yml +++ b/.github/workflows/ext-registry-check.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "cli/azd/extensions/registry.json" + # NOTE, if you're doing some testing on this workflow you're welcome to use `azd-auto-approve-simple-updates` + # as your branch name branches: [main, azd-auto-approve-simple-updates] types: [opened, edited, synchronize, labeled, unlabeled, reopened, ready_for_review] @@ -18,7 +20,7 @@ permissions: jobs: extension-registry-check: - name: Extension registry auto-approve check + name: Extension registry "core team approval required" check runs-on: ubuntu-latest timeout-minutes: 5 steps: From ae4fa8a9543b8111d95674c1af7efb3a1109b9a4 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 17:45:18 -0700 Subject: [PATCH 10/23] - Slimming down the AI doc comments into something more reasonable. - There was an interesting possiblity where a user could add duplicate trees (for instance, add in multiple extensions with the same ID at the same level), which could lead to interesting scenarios where our loading logic is different than azd's, which lets you bypass/sneak data past us. Now we just treat duplicates of those "unique items" as errors. - Removing some of the live test scratchpad stuff I had for checking out PRs. We'e got equivalents in our CI testing now. The other live tests can stick around, however, and just be active only on demand. --- .github/scripts/src/ext-registry-check.js | 48 +++++++++----- .../scripts/test/ext-registry-check.test.js | 66 ++++++------------- 2 files changed, 50 insertions(+), 64 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index ec5845477f4..48fb8271a57 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -157,24 +157,19 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { } // Users can have multiple reviews (ie, they requested changes, then they approved), so we'll - // make sure we get their absolutely latest *decisive* review state. - - // COMMENTED / PENDING reviews are not a verdict - GitHub itself never treats them as changing a - // reviewer's approval standing, so we ignore them here too. Otherwise a maintainer who approves - // the head commit and then merely leaves a comment (a new COMMENTED review row) would look like - // they had withdrawn their approval. We keep APPROVED / CHANGES_REQUESTED / DISMISSED so that a - // later request-changes (or dismissal) from the same maintainer correctly overrides an earlier - // approval - we don't want to skip review for a PR that a maintainer both approved and then - // requested changes on. - const IGNORED_REVIEW_STATES = new Set(['COMMENTED', 'PENDING']); - - // NOTE: api docs indicate reviews always come back in chronological order, according to their docs, - // and Map.set keeps the last entry per key - so this is "latest decisive review per registry maintainer". + // make sure we get their absolutely latest *decisive* review state. There's a bit of trickiness + // that you can have multiple states (order preserved) associated with the same commit SHA + // (for instance, you approve a PR, then request changes, etc..) + const END_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + + // NOTE: reviews come back in chronological order (see "List reviews for a pull request": + // https://docs.github.com/en/rest/pulls/reviews#list-reviews-for-a-pull-request), which is + // critical for us since we have to actually know the last state of the review. /** @type {Map} */ const latestByUser = new Map(); for (const review of reviews) { - if (review.user != null && coreTeam.has(review.user.login) && !IGNORED_REVIEW_STATES.has(review.state)) { + if (review.user != null && coreTeam.has(review.user.login) && END_STATES.has(review.state)) { latestByUser.set(review.user.login, { state: review.state, commitId: review.commit_id, @@ -332,8 +327,25 @@ function diffRegistry(baseRegistry, prRegistry) { /** @type {string[]} */ const reasons = []; - const baseExtensions = new Map((baseRegistry.extensions ?? []).map((e) => [e.id, e])); - const prExtensions = new Map((prRegistry.extensions ?? []).map((e) => [e.id, e])); + /** + * Builds a Map keyed by `k(item)`, throwing on duplicate keys. + * @template T, K + * @param {Iterable} items + * @param {(item: T) => K} k + */ + function toMap(items, k) { + /** @type {Map} */ + const m = new Map(); + for (const item of items ?? []) { + const key = k(item); + if (m.has(key)) throw new Error(`duplicate key: ${key}`); + m.set(key, item); + } + return m; // inferred Map + } + + const baseExtensions = toMap(baseRegistry.extensions, (e) => e.id); + const prExtensions = toMap(prRegistry.extensions, (e) => e.id); // brand new extensions require core review. for (const id of prExtensions.keys()) { @@ -360,8 +372,8 @@ function diffRegistry(baseRegistry, prRegistry) { reasons.push(`extension '${id}' namespace changed; namespace changes require core review`); } - const baseVersions = new Map((baseExtension.versions ?? []).map((v) => [v.version, v])); - const prVersions = new Map((prExtension.versions ?? []).map((v) => [v.version, v])); + const baseVersions = toMap(baseExtension.versions, (v) => v.version); + const prVersions = toMap(prExtension.versions, (v) => v.version); reasons.push(...diffPublishedReleases(id, baseVersions, prVersions)); reasons.push(...diffNewReleases(id, baseExtension.versions ?? [], baseVersions, prVersions)); diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index a4e654be190..1a6d8766f5a 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -34,7 +34,6 @@ const { diffRegistry, getCoreReviewers, isAllowedRegistryJsonUpdate, - isApprovedByCoreTeam, } = run.forTests; /** @@ -307,6 +306,26 @@ describe('diffRegistry', () => { expect(reasons).not.toEqual([]); expect(reasons).toContainEqual(expect.stringContaining("release '1.0.0' was modified")); }); + + it('throws when the PR registry has duplicate extension ids', () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([extension({ id: 'ext.one' }), extension({ id: 'ext.one' })]); + expect(() => diffRegistry(base, pr)).toThrow('duplicate key: ext.one'); + }); + + it('throws when the base registry has duplicate extension ids', () => { + const base = registry([extension({ id: 'ext.one' }), extension({ id: 'ext.one' })]); + const pr = registry([extension({ id: 'ext.one' })]); + expect(() => diffRegistry(base, pr)).toThrow('duplicate key: ext.one'); + }); + + it('throws when an extension has duplicate version entries', () => { + const base = registry([extension({ id: 'ext.one', versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ id: 'ext.one', versions: [version({ version: '1.0.0' }), version({ version: '1.0.0' })] }), + ]); + expect(() => diffRegistry(base, pr)).toThrow('duplicate key: 1.0.0'); + }); }); describe('isAllowedRegistryJsonUpdate', () => { @@ -877,51 +896,6 @@ liveDescribe('[live] registry diff PR scenarios', () => { }, 90_000); }); -// Point this at a PR you're experimenting with (e.g. approve it, then leave a COMMENTED review, or -// request changes) to verify the maintainer-approval short-circuit against GitHub's live review -// state. It compares isApprovedByCoreTeam's verdict to what you expect. Requires RUN_LIVE_TESTS=1 -// plus: -// LIVE_APPROVAL_PR - PR number to inspect -// LIVE_APPROVAL_EXPECTED - 'true' or 'false' (expected isApprovedByCoreTeam result) -// LIVE_APPROVAL_TEAM - optional comma-separated maintainer logins to treat as the core team -// (defaults to the live registry maintainer team via getCoreReviewers) -// LIVE_APPROVAL_REPO - optional 'owner/name' (defaults to Azure/azure-dev) -const LIVE_APPROVAL_PR = process.env['LIVE_APPROVAL_PR']; -const approvalLiveDescribe = RUN_LIVE_TESTS && LIVE_APPROVAL_PR ? describe : describe.skip; - -approvalLiveDescribe('[live] maintainer approval verdict', () => { - it('[live] isApprovedByCoreTeam matches the expected verdict for LIVE_APPROVAL_PR', async () => { - const prNumber = Number(LIVE_APPROVAL_PR); - if (!Number.isInteger(prNumber)) { - throw new Error(`LIVE_APPROVAL_PR must be a PR number, got: ${LIVE_APPROVAL_PR}`); - } - - const expectedRaw = process.env['LIVE_APPROVAL_EXPECTED']; - if (expectedRaw !== 'true' && expectedRaw !== 'false') { - throw new Error(`LIVE_APPROVAL_EXPECTED must be 'true' or 'false', got: ${expectedRaw}`); - } - const expected = expectedRaw === 'true'; - - const [owner = LIVE_TEST_OWNER, repo = LIVE_TEST_REPO] = (process.env['LIVE_APPROVAL_REPO'] || '').split('/'); - - const octokit = await createLiveOctokit(); - const context = await createLiveContext(octokit, prNumber, { owner, repo }); - const core = createNoopCore(); - - const teamEnv = (process.env['LIVE_APPROVAL_TEAM'] || '') - .split(',') - .map((login) => login.trim()) - .filter((login) => login.length > 0); - const coreTeam = teamEnv.length > 0 - ? new Set(teamEnv) - : getCoreReviewers({ core }); - - const approved = await isApprovedByCoreTeam({ octokit, context, core, coreTeam }); - - expect(approved).toBe(expected); - }, 90_000); -}); - /** @returns {Core} */ function createNoopCore() { const core = { From fc2a256459e0c080d6054f0b6a7717207c215dbb Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 18:18:00 -0700 Subject: [PATCH 11/23] - Do a quick check on the artifact URls, make sure they're not going off to some random location (we only use release artifacts on our official repo) - Remove some of the artifacts from attempting to use a GitHub org review team, until we sort out permission setup with either a PAT or app. --- .github/scripts/src/ext-registry-check.js | 43 ++++++++++++++++++- .../scripts/test/ext-registry-check.test.js | 43 ++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index 48fb8271a57..ff36da22404 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -13,8 +13,8 @@ module.exports.forTests = { diffRegistry, } -const CORE_REVIEW_TEAM_SLUG = 'azure-dev-extregistry-maintain'; const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; +const ALLOWED_ARTIFACT_URL_PREFIX = 'https://github.com/Azure/azure-dev/releases'; // GitHub action types @@ -36,10 +36,14 @@ const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; * @property {string} type * @property {string} [description] * + * @typedef {object} Artifact + * @property {string} url + * * @typedef {object} ExtensionVersion * @property {string} version * @property {string[]} [capabilities] * @property {Provider[]} [providers] + * @property {Object} [artifacts] * * @typedef {object} Extension * @property {string} id @@ -101,7 +105,7 @@ async function run({ github: octokit, context, core, coreTeam, registryBaseRef } "Core review required for this extension registry change:\n" + reviewReasons.map((r) => `- ${r}`).join("\n") + "\n\nTo fix:\n" + - `1. Have a member of @${context.repo.owner}/${CORE_REVIEW_TEAM_SLUG} review and approve this PR.\n` + + `1. Have one of these registry maintainers review and approve this PR: ${[...coreReviewers].join(', ')}.\n` + `2. After approval, re-run this build step so it'll re-evaluate the PR - no commits or pushes needed.` ); } catch (err) { @@ -452,6 +456,41 @@ function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, ); } + + reasons.push(...validateArtifactURLs(id, prVersion)); + } + + return reasons; +} + +/** + * Flags any artifact whose download URL is not hosted under the official azure-dev + * releases location, so a new release can't point auto-approval at an arbitrary blob. + * + * A missing or non-string URL is malformed registry data, so we throw outright rather + * than routing it to review. + * + * @param {string} id + * @param {ExtensionVersion} version + * @returns {string[]} + * @throws {Error} if an artifact has no string URL + */ +function validateArtifactURLs(id, version) { + /** @type {string[]} */ + const reasons = []; + + for (const [platform, artifact] of Object.entries(version.artifacts ?? {})) { + const url = artifact?.url; + if (typeof url !== 'string') { + throw new Error( + `extension '${id}' release '${version.version}' artifact '${platform}' has no string URL (got ${JSON.stringify(url)})`, + ); + } + if (!url.startsWith(ALLOWED_ARTIFACT_URL_PREFIX)) { + reasons.push( + `extension '${id}' release '${version.version}' artifact '${platform}' has a URL outside ${ALLOWED_ARTIFACT_URL_PREFIX} (${url}); release artifacts must be hosted there`, + ); + } } return reasons; diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index 1a6d8766f5a..1750faeed15 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -16,7 +16,7 @@ import run from '../src/ext-registry-check.js'; * @property {string} version * @property {string[]} [capabilities] * @property {Provider[]} [providers] - * @property {Record} [artifacts] + * @property {Object} [artifacts] * @property {string} [usage] * * @typedef {object} Extension @@ -326,6 +326,47 @@ describe('diffRegistry', () => { ]); expect(() => diffRegistry(base, pr)).toThrow('duplicate key: 1.0.0'); }); + + it('fails when a new release artifact URL is hosted outside the azure-dev releases location', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': { url: 'https://evil.example.com/x.zip' } } }, + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases')); + }); + + it('approves a new release whose artifact URLs are hosted under the azure-dev releases location', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': { url: 'https://github.com/Azure/azure-dev/releases/download/ext_1.1.0/x.zip' } } }, + ], + }), + ]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('throws when a new release artifact is missing a URL', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': /** @type {any} */ ({ entryPoint: 'x' }) } }, + ], + }), + ]); + expect(() => diffRegistry(base, pr)).toThrow("artifact 'linux/amd64' has no string URL (got undefined)"); + }); }); describe('isAllowedRegistryJsonUpdate', () => { From 7f9cc858def25b38ffe07ee5d7be82067a861b87 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 18:26:51 -0700 Subject: [PATCH 12/23] Adding in CI for my...CI. --- .github/workflows/scripts-ci.yml | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/scripts-ci.yml diff --git a/.github/workflows/scripts-ci.yml b/.github/workflows/scripts-ci.yml new file mode 100644 index 00000000000..fbcc5a7e4cb --- /dev/null +++ b/.github/workflows/scripts-ci.yml @@ -0,0 +1,39 @@ +name: scripts-ci + +on: + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/scripts-ci.yml" + branches: [main] + +# If two events are triggered within a short time in the same PR, cancel the run of the oldest event +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + vitest: + name: Run test suite for our GitHub action support scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: .github/scripts + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test From ddbf850f09f1209bbcecebd769f952ad397c6401 Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 19:29:41 -0700 Subject: [PATCH 13/23] Add in some more checks for fields that might show up in extensions. TBH, most of them aren't used, but just being thorough. --- .github/scripts/src/ext-registry-check.js | 57 ++++++++++++++++++- .../scripts/test/ext-registry-check.test.js | 23 +++++++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index ff36da22404..f3f616de6db 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -16,6 +16,12 @@ module.exports.forTests = { const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; const ALLOWED_ARTIFACT_URL_PREFIX = 'https://github.com/Azure/azure-dev/releases'; +// Extension-level fields that may change without core review, since they're cosmetic. +// Everything else on an extension object (aside from `versions`, which has its own release +// rules) must be identical between the base and PR registries. Note, we're not trying to +// understand those other fields, just preserve the status quo. +const ALLOWED_EXTENSION_METADATA_CHANGES = new Set(['displayName', 'description', 'tags']); + // GitHub action types /** @@ -50,6 +56,7 @@ const ALLOWED_ARTIFACT_URL_PREFIX = 'https://github.com/Azure/azure-dev/releases * @property {string} [namespace] * @property {string} [displayName] * @property {string} [description] + * @property {string[]} [tags] * @property {ExtensionVersion[]} versions * * @typedef {object} RegistryJson @@ -372,9 +379,7 @@ function diffRegistry(baseRegistry, prRegistry) { continue; // already reported as a new extension above } - if (baseExtension.namespace !== prExtension.namespace) { - reasons.push(`extension '${id}' namespace changed; namespace changes require core review`); - } + reasons.push(...diffExtensionMetadata(id, baseExtension, prExtension)); const baseVersions = toMap(baseExtension.versions, (v) => v.version); const prVersions = toMap(prExtension.versions, (v) => v.version); @@ -386,6 +391,52 @@ function diffRegistry(baseRegistry, prRegistry) { return reasons; } +/** + * Compares the extension-level metadata (everything except `versions`) between the base + * and PR registries. Any difference requires core review, except for the cosmetic fields + * in ALLOWED_EXTENSION_METADATA_CHANGES. We don't track or know about specific fields: + * anything not in the allowlist is expected to be identical. + * + * @param {string} id + * @param {Extension} baseExtension + * @param {Extension} prExtension + * @returns {string[]} + */ +function diffExtensionMetadata(id, baseExtension, prExtension) { + const baseMetadata = extensionMetadata(baseExtension); + const prMetadata = extensionMetadata(prExtension); + + const changedFields = [ + ...new Set([...Object.keys(baseMetadata), ...Object.keys(prMetadata)]), + ] + .filter((field) => !isDeepStrictEqual(baseMetadata[field], prMetadata[field])) + .sort(); + + if (changedFields.length === 0) { + return []; + } + + return [ + `extension '${id}' changes metadata that requires core review (${changedFields.join(', ')}); only ${[...ALLOWED_EXTENSION_METADATA_CHANGES].join(', ')} may change without core review`, + ]; +} + +/** + * Returns a copy of an extension without `versions` (which has its own release rules) or + * any allowlisted cosmetic field. + * + * @param {Extension} extension + * @returns {Record} + */ +function extensionMetadata(extension) { + return Object.fromEntries( + Object.entries(extension).filter( + // we compare versions elsewhere. + ([name]) => name !== 'versions' && !ALLOWED_EXTENSION_METADATA_CHANGES.has(name), + ) + ); +} + /** * @param {string} id * @param {Map} baseVersions diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index 1750faeed15..f7b0be4166f 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -24,6 +24,7 @@ import run from '../src/ext-registry-check.js'; * @property {string} [namespace] * @property {string} [displayName] * @property {string} [description] + * @property {string[]} [tags] * @property {ExtensionVersion[]} versions * * @typedef {object} RegistryJson @@ -291,7 +292,23 @@ describe('diffRegistry', () => { const pr = registry([{ ...extension({ id: 'ext.one' }), namespace: 'other' }]); const reasons = diffRegistry(base, pr); expect(reasons).not.toEqual([]); - expect(reasons).toContainEqual(expect.stringContaining('namespace changed')); + expect(reasons).toContainEqual(expect.stringContaining('changes metadata that requires core review')); + expect(reasons).toContainEqual(expect.stringContaining('namespace')); + }); + + it('approves extension tag changes', () => { + const base = registry([{ ...extension({ id: 'ext.one' }), tags: ['ai'] }]); + const pr = registry([{ ...extension({ id: 'ext.one' }), tags: ['ai', 'foundry'] }]); + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('fails when any non-allowlisted extension metadata changes', () => { + const base = registry([extension({ id: 'ext.one' })]); + const pr = registry([/** @type {Extension} */ ({ ...extension({ id: 'ext.one' }), platform: 'windows' })]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('changes metadata that requires core review')); + expect(reasons).toContainEqual(expect.stringContaining('platform')); }); it('fails when an existing release metadata field changes', () => { @@ -376,7 +393,7 @@ describe('isAllowedRegistryJsonUpdate', () => { const octokit = createRegistryOctokit({ base, pr }); const context = createRegistryContext(); - await expect(isAllowedRegistryJsonUpdate({ octokit, context })).resolves.toContainEqual(expect.stringContaining('namespace changed')); + await expect(isAllowedRegistryJsonUpdate({ octokit, context })).resolves.toContainEqual(expect.stringContaining('changes metadata that requires core review')); expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: 'Azure', repo: 'azure-dev', @@ -399,7 +416,7 @@ describe('isAllowedRegistryJsonUpdate', () => { octokit, context, registryBaseRef: 'base-before-pr', - })).resolves.toContainEqual(expect.stringContaining('namespace changed')); + })).resolves.toContainEqual(expect.stringContaining('changes metadata that requires core review')); expect(octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: 'Azure', repo: 'azure-dev', From e164a67d1f048ab4565b4664809a1ffdbaaca17b Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 19:38:51 -0700 Subject: [PATCH 14/23] Temporarily add my test branch in again for the CI workflow testing. --- .github/workflows/scripts-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scripts-ci.yml b/.github/workflows/scripts-ci.yml index fbcc5a7e4cb..fb4fb5d22d0 100644 --- a/.github/workflows/scripts-ci.yml +++ b/.github/workflows/scripts-ci.yml @@ -5,7 +5,9 @@ on: paths: - ".github/scripts/**" - ".github/workflows/scripts-ci.yml" - branches: [main] + # NOTE, if you're doing some testing on this workflow you're welcome to use `azd-auto-approve-simple-updates` + # as your branch name + branches: [main, azd-auto-approve-simple-updates] # If two events are triggered within a short time in the same PR, cancel the run of the oldest event concurrency: From 87f8c9e1d6094f0db82ef3f92891a34bd3f701bf Mon Sep 17 00:00:00 2001 From: ripark Date: Thu, 9 Jul 2026 20:09:44 -0700 Subject: [PATCH 15/23] - Check if they're only adding a single release,a and that it truly is newer - ie, they're not adding a release that has a semver lower than the current latest release (mostly a mistake, not really a vector for attack). --- .github/scripts/src/ext-registry-check.js | 129 ++++++++++++++---- .../scripts/test/ext-registry-check.test.js | 77 +++++++++++ 2 files changed, 180 insertions(+), 26 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index f3f616de6db..9d11a87b98f 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -1,3 +1,6 @@ +// This script runs as part of the .github/workflows/ext-registry-check.yml. It lets the core azd team ensure that complicated +// registry updates (changes to capabilities, or providers) and other assorted changes always fall under core team review, while +// simple changes (simple version bump, no changes to important fields) can go by with just a simple approval from any developer. const { isDeepStrictEqual } = require('node:util'); // GitHub Actions entry point. @@ -60,6 +63,7 @@ const ALLOWED_EXTENSION_METADATA_CHANGES = new Set(['displayName', 'description' * @property {ExtensionVersion[]} versions * * @typedef {object} RegistryJson + * @property {string} [schemaVersion] * @property {Extension[]} extensions */ @@ -355,6 +359,8 @@ function diffRegistry(baseRegistry, prRegistry) { return m; // inferred Map } + reasons.push(...diffRegistryMetadata(baseRegistry, prRegistry)); + const baseExtensions = toMap(baseRegistry.extensions, (e) => e.id); const prExtensions = toMap(prRegistry.extensions, (e) => e.id); @@ -391,6 +397,44 @@ function diffRegistry(baseRegistry, prRegistry) { return reasons; } +/** + * Compares the registry-level metadata (everything except `extensions`, which has its own + * diffing rules) between the base and PR registries. Any difference requires core review, + * since these root fields (for example `schemaVersion`) govern how azd loads the entire + * registry and can make it unusable. We don't track or know about specific fields: anything + * outside `extensions` is expected to be identical. + * + * @param {RegistryJson} baseRegistry + * @param {RegistryJson} prRegistry + * @returns {string[]} + */ +function diffRegistryMetadata(baseRegistry, prRegistry) { + const baseMetadata = registryMetadata(baseRegistry); + const prMetadata = registryMetadata(prRegistry); + + const changedFields = changedMetadataFields(baseMetadata, prMetadata); + + if (changedFields.length === 0) { + return []; + } + + return [ + `registry changes top-level metadata that requires core review (${changedFields.join(', ')}); only extension changes may proceed without core review`, + ]; +} + +/** + * Returns a copy of the registry without `extensions` (which has its own diffing rules). + * + * @param {RegistryJson} registry + * @returns {Record} + */ +function registryMetadata(registry) { + return Object.fromEntries( + Object.entries(registry).filter(([name]) => name !== 'extensions'), + ); +} + /** * Compares the extension-level metadata (everything except `versions`) between the base * and PR registries. Any difference requires core review, except for the cosmetic fields @@ -406,11 +450,7 @@ function diffExtensionMetadata(id, baseExtension, prExtension) { const baseMetadata = extensionMetadata(baseExtension); const prMetadata = extensionMetadata(prExtension); - const changedFields = [ - ...new Set([...Object.keys(baseMetadata), ...Object.keys(prMetadata)]), - ] - .filter((field) => !isDeepStrictEqual(baseMetadata[field], prMetadata[field])) - .sort(); + const changedFields = changedMetadataFields(baseMetadata, prMetadata); if (changedFields.length === 0) { return []; @@ -437,6 +477,19 @@ function extensionMetadata(extension) { ); } +/** + * @param {Record} baseMetadata + * @param {Record} prMetadata + * @returns {string[]} + */ +function changedMetadataFields(baseMetadata, prMetadata) { + return [ + ...new Set([...Object.keys(baseMetadata), ...Object.keys(prMetadata)]), + ] + .filter((field) => !isDeepStrictEqual(baseMetadata[field], prMetadata[field])) + .sort(); +} + /** * @param {string} id * @param {Map} baseVersions @@ -484,33 +537,57 @@ function diffNewReleases(id, baseVersionList, baseVersions, prVersions) { const reasons = []; const previousRelease = latestVersionBySemver(baseVersionList); - for (const [version, prVersion] of prVersions) { - if (baseVersions.has(version)) { - continue; - } + const newReleases = [...prVersions].filter(([version]) => !baseVersions.has(version)); - if (previousRelease == null) { - reasons.push(`extension '${id}' release '${version}' has no previous release to compare against`); - continue; - } + if (newReleases.length === 0) { + return reasons; + } - const capabilityChanges = diffArrays(previousRelease.capabilities ?? [], prVersion.capabilities ?? []); - if (capabilityChanges.length > 0) { - reasons.push( - `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}' (${capabilityChanges.join('; ')})`, - ); - } + // A simple version bump adds exactly one release. Anything more (or a first-ever release with + // no baseline to compare against) needs a human to look at it. + if (newReleases.length > 1) { + const added = newReleases.map(([version]) => version).sort(); + reasons.push( + `extension '${id}' adds ${newReleases.length} new releases (${added.join(', ')}); only a single new release may be added without core review`, + ); + return reasons; + } - const providerChanges = diffArrays(providerIdentityLabels(previousRelease), providerIdentityLabels(prVersion)); - if (providerChanges.length > 0) { - reasons.push( - `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, - ); - } + const newRelease = newReleases[0]; + if (newRelease == null) { + return reasons; + } + const [version, prVersion] = newRelease; + + if (previousRelease == null) { + reasons.push(`extension '${id}' release '${version}' has no previous release to compare against`); + return reasons; + } - reasons.push(...validateArtifactURLs(id, prVersion)); + // The new release must move the extension forward; re-adding an older version (or one that ties + // the current latest) isn't a simple bump and could downgrade what azd resolves. + if (compareSemver(version, previousRelease.version) <= 0) { + reasons.push( + `extension '${id}' release '${version}' is not newer than the current latest release '${previousRelease.version}'; only forward version bumps may proceed without core review`, + ); } + const capabilityChanges = diffArrays(previousRelease.capabilities ?? [], prVersion.capabilities ?? []); + if (capabilityChanges.length > 0) { + reasons.push( + `extension '${id}' release '${version}' changes capabilities from the previous release '${previousRelease.version}' (${capabilityChanges.join('; ')})`, + ); + } + + const providerChanges = diffArrays(providerIdentityLabels(previousRelease), providerIdentityLabels(prVersion)); + if (providerChanges.length > 0) { + reasons.push( + `extension '${id}' release '${version}' changes providers from the previous release '${previousRelease.version}' (${providerChanges.join('; ')})`, + ); + } + + reasons.push(...validateArtifactURLs(id, prVersion)); + return reasons; } diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index f7b0be4166f..e2599eda266 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -261,6 +261,32 @@ describe('diffRegistry', () => { expect(diffRegistry(base, pr)).toEqual([]); }); + it('approves a GA release after beta releases for the same version', () => { + const base = registry([ + extension({ + id: 'azure.ai.agents', + versions: [ + version({ version: '1.0.0-beta.2', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.3', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.4', capabilities: ['custom-commands'] }), + ], + }), + ]); + const pr = registry([ + extension({ + id: 'azure.ai.agents', + versions: [ + version({ version: '1.0.0-beta.2', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.3', capabilities: ['custom-commands'] }), + version({ version: '1.0.0-beta.4', capabilities: ['custom-commands'] }), + version({ version: '1.0.0', capabilities: ['custom-commands'] }), + ], + }), + ]); + + expect(diffRegistry(base, pr)).toEqual([]); + }); + it('fails when an already-published release is modified', () => { const base = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['custom-commands'] })] })]); const pr = registry([extension({ versions: [version({ version: '1.0.0', capabilities: ['something-else'] })] })]); @@ -384,6 +410,57 @@ describe('diffRegistry', () => { ]); expect(() => diffRegistry(base, pr)).toThrow("artifact 'linux/amd64' has no string URL (got undefined)"); }); + + it('fails when a registry-level metadata field changes', () => { + const base = { ...registry([extension()]), schemaVersion: '1.0' }; + const pr = { ...registry([extension()]), schemaVersion: '2.0' }; + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('changes top-level metadata that requires core review')); + expect(reasons).toContainEqual(expect.stringContaining('schemaVersion')); + }); + + it('fails when a new registry-level metadata field is added', () => { + const base = registry([extension()]); + const pr = { ...registry([extension()]), schemaVersion: '1.0' }; + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('changes top-level metadata that requires core review')); + expect(reasons).toContainEqual(expect.stringContaining('schemaVersion')); + }); + + it('approves an identical registry that carries top-level metadata', () => { + const base = { ...registry([extension()]), schemaVersion: '1.0' }; + const pr = { ...registry([extension()]), schemaVersion: '1.0' }; + expect(diffRegistry(base, pr)).toEqual([]); + }); + + it('fails when more than one new release is added at once', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + version({ version: '1.1.0' }), + version({ version: '1.2.0' }), + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('adds 2 new releases (1.1.0, 1.2.0)')); + expect(reasons).toContainEqual(expect.stringContaining('only a single new release may be added without core review')); + }); + + it('fails when the new release is older than the current latest release', () => { + const base = registry([extension({ versions: [version({ version: '2.0.0' })] })]); + const pr = registry([ + extension({ versions: [version({ version: '2.0.0' }), version({ version: '1.9.0' })] }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining("release '1.9.0' is not newer than the current latest release '2.0.0'")); + }); }); describe('isAllowedRegistryJsonUpdate', () => { From 2a5e092d50e7101803c394f1b61be1727fded3c5 Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 09:43:25 -0700 Subject: [PATCH 16/23] Using the base branch SHA (doesn't affect testing yet since we're still triggering on my test brnach name) --- .github/workflows/ext-registry-check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml index 2776b612534..c7edf14613c 100644 --- a/.github/workflows/ext-registry-check.yml +++ b/.github/workflows/ext-registry-check.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} - name: Check extension registry update uses: actions/github-script@v9 From 9523ca0e2322f1c857d68989f134373e75696479 Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 09:56:24 -0700 Subject: [PATCH 17/23] Updating to use the latest setup-node action and also bumping up the floor to node 24, which is the same version the GitHub action runner uses. --- .github/workflows/scripts-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts-ci.yml b/.github/workflows/scripts-ci.yml index fb4fb5d22d0..052bd535c46 100644 --- a/.github/workflows/scripts-ci.yml +++ b/.github/workflows/scripts-ci.yml @@ -30,9 +30,11 @@ jobs: uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + # 24 is the version that the GitHub actions script I'm running should also be + # running under. + node-version: 24 - name: Install dependencies run: npm ci From dfbe69cb8bf06c39745a72933d8c932fb2ae166d Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 10:20:15 -0700 Subject: [PATCH 18/23] Add a coverage target. Technically, I'm only using this in vscode so the test view can provide coverage, but no reason to make it clear that it could be used on the comamnd line, if you desired. --- .github/scripts/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 7ac8e9957a2..1e15473582d 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "description": "", "scripts": { - "test": "vitest run" + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "keywords": [], "author": "", From 2b06fece6a9c41b36097fbec8755697fd5ccd9f3 Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 10:30:49 -0700 Subject: [PATCH 19/23] Do checking against the release tarballs, make sure we're not letting them pick an arbitrary location that uses relative paths, etc. We get this for free just by using the URL class. --- .github/scripts/src/ext-registry-check.js | 25 +++++++++++++++++-- .../scripts/test/ext-registry-check.test.js | 17 ++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index 9d11a87b98f..cbef04ad6cf 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -17,7 +17,12 @@ module.exports.forTests = { } const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json'; -const ALLOWED_ARTIFACT_URL_PREFIX = 'https://github.com/Azure/azure-dev/releases'; + +// We only allow URLs that point to our GitHub releases page. +// NOTE: this script is only for production registry.json - nightlies go to a non-releases spot, etc... +const ALLOWED_ARTIFACT_URL_ORIGIN = 'https://github.com'; +const ALLOWED_ARTIFACT_URL_PATH_PREFIX = '/Azure/azure-dev/releases/download/'; +const ALLOWED_ARTIFACT_URL_PREFIX = `${ALLOWED_ARTIFACT_URL_ORIGIN}${ALLOWED_ARTIFACT_URL_PATH_PREFIX}`; // Extension-level fields that may change without core review, since they're cosmetic. // Everything else on an extension object (aside from `versions`, which has its own release @@ -614,7 +619,7 @@ function validateArtifactURLs(id, version) { `extension '${id}' release '${version.version}' artifact '${platform}' has no string URL (got ${JSON.stringify(url)})`, ); } - if (!url.startsWith(ALLOWED_ARTIFACT_URL_PREFIX)) { + if (!isAllowedArtifactURL(url)) { reasons.push( `extension '${id}' release '${version.version}' artifact '${platform}' has a URL outside ${ALLOWED_ARTIFACT_URL_PREFIX} (${url}); release artifacts must be hosted there`, ); @@ -624,6 +629,22 @@ function validateArtifactURLs(id, version) { return reasons; } +/** + * @param {string} value + * @returns {boolean} + */ +function isAllowedArtifactURL(value) { + let url; + try { + url = new URL(value); + } catch { + return false; + } + + return url.origin === ALLOWED_ARTIFACT_URL_ORIGIN && + url.pathname.startsWith(ALLOWED_ARTIFACT_URL_PATH_PREFIX); +} + /** * @param {string[]} baseItems * @param {string[]} prItems diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index e2599eda266..f24f956de66 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -382,7 +382,22 @@ describe('diffRegistry', () => { ]); const reasons = diffRegistry(base, pr); expect(reasons).not.toEqual([]); - expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases')); + expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases/download/')); + }); + + it('fails when a new release artifact URL resolves outside the azure-dev releases location', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': { url: 'https://github.com/Azure/azure-dev/releases/../../../attacker/repo/releases/download/v1/x.zip' } } }, + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases/download/')); }); it('approves a new release whose artifact URLs are hosted under the azure-dev releases location', () => { From f3b30738a54c4ad2ab0e6a438cb58398956c83ad Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 10:31:57 -0700 Subject: [PATCH 20/23] Inferred! Makes the doc comment just a little less verbose. --- .github/scripts/src/ext-registry-check.js | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index cbef04ad6cf..93e58516c32 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -631,7 +631,6 @@ function validateArtifactURLs(id, version) { /** * @param {string} value - * @returns {boolean} */ function isAllowedArtifactURL(value) { let url; From 96ea521bf3a6accbae101ff17f397c60be504aa7 Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 11:16:21 -0700 Subject: [PATCH 21/23] - Disallow any URL encoded characeters. We don't use for for our artifacts. - Add some more checking to make sure hold that invariant. --- .github/scripts/src/ext-registry-check.js | 15 ++++++---- .../scripts/test/ext-registry-check.test.js | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/scripts/src/ext-registry-check.js b/.github/scripts/src/ext-registry-check.js index 93e58516c32..79526af7935 100644 --- a/.github/scripts/src/ext-registry-check.js +++ b/.github/scripts/src/ext-registry-check.js @@ -201,6 +201,8 @@ async function isApprovedByCoreTeam({ octokit, context, core, coreTeam }) { .filter(([, review]) => review.state === 'APPROVED' && review.commitId === headSha) .map(([login]) => login); + // If the review state changes without a push, this workflow won't be retriggered; + // normal GitHub branch protection still blocks the PR when changes are requested. if (coreApprovals != null && coreApprovals.length > 0) { core.info(`PR head commit approved by registry maintainer(s) (${coreApprovals.join(",")})`) return true; @@ -633,15 +635,18 @@ function validateArtifactURLs(id, version) { * @param {string} value */ function isAllowedArtifactURL(value) { - let url; try { - url = new URL(value); + const decodedValue = decodeURIComponent(value); + if (decodedValue !== value) { + return false; + } + + const url = new URL(value); + return url.origin === ALLOWED_ARTIFACT_URL_ORIGIN && + url.pathname.startsWith(ALLOWED_ARTIFACT_URL_PATH_PREFIX); } catch { return false; } - - return url.origin === ALLOWED_ARTIFACT_URL_ORIGIN && - url.pathname.startsWith(ALLOWED_ARTIFACT_URL_PATH_PREFIX); } /** diff --git a/.github/scripts/test/ext-registry-check.test.js b/.github/scripts/test/ext-registry-check.test.js index f24f956de66..c154a6d4ae5 100644 --- a/.github/scripts/test/ext-registry-check.test.js +++ b/.github/scripts/test/ext-registry-check.test.js @@ -400,6 +400,36 @@ describe('diffRegistry', () => { expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases/download/')); }); + it('fails when a new release artifact URL uses encoded paths (like %2f, etc..) to go outside the azure-dev releases location', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': { url: 'https://github.com/Azure/azure-dev/releases/download/..%2f..%2fattacker/repo/releases/download/v1/x.zip' } } }, + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases/download/')); + }); + + it('fails when a new release artifact URL contains any percent-encoded characters', () => { + const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); + const pr = registry([ + extension({ + versions: [ + version({ version: '1.0.0' }), + { ...version({ version: '1.1.0' }), artifacts: { 'linux/amd64': { url: 'https://github.com/Azure/azure-dev/releases/download/ext_1.1.0/file%41.zip' } } }, + ], + }), + ]); + const reasons = diffRegistry(base, pr); + expect(reasons).not.toEqual([]); + expect(reasons).toContainEqual(expect.stringContaining('has a URL outside https://github.com/Azure/azure-dev/releases/download/')); + }); + it('approves a new release whose artifact URLs are hosted under the azure-dev releases location', () => { const base = registry([extension({ versions: [version({ version: '1.0.0' })] })]); const pr = registry([ From 43aa2c8031d6aa7c56509b1b79db4bef8667b4d3 Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 11:25:51 -0700 Subject: [PATCH 22/23] I'm good with this one, we can remove the branch now :) --- .github/workflows/scripts-ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/scripts-ci.yml b/.github/workflows/scripts-ci.yml index 052bd535c46..c2cdadb6cd6 100644 --- a/.github/workflows/scripts-ci.yml +++ b/.github/workflows/scripts-ci.yml @@ -5,9 +5,6 @@ on: paths: - ".github/scripts/**" - ".github/workflows/scripts-ci.yml" - # NOTE, if you're doing some testing on this workflow you're welcome to use `azd-auto-approve-simple-updates` - # as your branch name - branches: [main, azd-auto-approve-simple-updates] # If two events are triggered within a short time in the same PR, cancel the run of the oldest event concurrency: From 644c0fdf7fc6f74dfb52c2f18db1b16318dd7f0a Mon Sep 17 00:00:00 2001 From: ripark Date: Fri, 10 Jul 2026 13:29:02 -0700 Subject: [PATCH 23/23] Updating to using pull_request_target, which does come with sharp edges, but fixes the problem where our workflow definition could be altered in the _PR_ branch, and override the normal workflow definition. I've also added in a comment explaining that nuance. --- .github/workflows/ext-registry-check.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ext-registry-check.yml b/.github/workflows/ext-registry-check.yml index c7edf14613c..c09a044f826 100644 --- a/.github/workflows/ext-registry-check.yml +++ b/.github/workflows/ext-registry-check.yml @@ -1,12 +1,10 @@ name: ext-registry-check on: - pull_request: + pull_request_target: paths: - "cli/azd/extensions/registry.json" - # NOTE, if you're doing some testing on this workflow you're welcome to use `azd-auto-approve-simple-updates` - # as your branch name - branches: [main, azd-auto-approve-simple-updates] + branches: [main] types: [opened, edited, synchronize, labeled, unlabeled, reopened, ready_for_review] # If two events are triggered within a short time in the same PR, cancel the run of the oldest event @@ -19,6 +17,10 @@ permissions: contents: read jobs: + # NOTE: this is running with pull_request_target, which means that any check or code + # that runs in here should be strictly controlled to come from stable/verified places + # (for instance, our script below uses the .github folder from 'main', and not from the + # users' branch, we don't npm install any packages, etc..) extension-registry-check: name: Extension registry "core team approval required" check runs-on: ubuntu-latest