Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# ── CLI extensions registry - anyone added to this can approve extension publishing, provided they pass the `ext-registry-check` workflow.
/cli/azd/extensions/registry.json @vhvb1989 @hemarina @JeffreyCA @tg-msft @RickWinter @richardpark-msft @XiaofuHuang @achauhan-scc @anchenyi @glharper @huimiu @hund030 @kingernupur @saanikaguptamicrosoft @therealjohn @trangevi @trrwilson
/cli/azd/extensions/registry.dev.json @vhvb1989 @hemarina @JeffreyCA @tg-msft @RickWinter @richardpark-msft @XiaofuHuang @achauhan-scc @anchenyi @glharper @huimiu @hund030 @kingernupur @saanikaguptamicrosoft @therealjohn @trangevi @trrwilson

# ── CLI extensions (AI) ──────────────────────────────────────────────────────
/cli/azd/extensions/azure.ai.agents/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030
Expand Down
100 changes: 61 additions & 39 deletions .github/scripts/src/ext-registry-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ module.exports.forTests = {
diffRegistry,
}

const REGISTRY_JSON_PATH = 'cli/azd/extensions/registry.json';
const REGISTRY_JSON_PATHS = new Set([
'cli/azd/extensions/registry.json',
'cli/azd/extensions/registry.dev.json',
]);

// 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}`;
Expand Down Expand Up @@ -97,18 +99,24 @@ async function run({ github: octokit, context, core, coreTeam, registryBaseRef }
return;
}

const changedFiles = await getChangedFiles({ octokit, context });

// Non-registry file changes require core review.
const changedFileReviewReasons = await getChangedFileReviewReasons({
octokit,
context,
});
const changedFileReviewReasons = diffChangedFiles(changedFiles);

// Simple release-only registry changes can proceed without core review.
const registryReviewReasons = await isAllowedRegistryJsonUpdate({
octokit,
context,
registryBaseRef: baseRef,
});
const changedRegistryPaths = [...REGISTRY_JSON_PATHS]
.filter((registryPath) => changedFiles.some((file) => file.filename === registryPath));
const registryReviewReasons = [];
for (const registryPath of changedRegistryPaths) {
const reasons = await isAllowedRegistryJsonUpdate({
octokit,
context,
registryBaseRef: baseRef,
registryPath,
});
registryReviewReasons.push(...reasons.map((reason) => `${registryPath}: ${reason}`));
}

const reviewReasons = changedFileReviewReasons.concat(registryReviewReasons);

Expand Down Expand Up @@ -235,10 +243,15 @@ 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
* @param {{ octokit: Octokit, context: Context, registryPath: string, registryBaseRef: string }} args
* @returns {Promise<string[]>} the reasons core review is needed; empty means the change is approved
*/
async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef = 'main' }) {
async function isAllowedRegistryJsonUpdate({
octokit,
context,
registryPath,
registryBaseRef,
}) {
assertHasPullRequest(context);
const pr = context.payload.pull_request;

Expand All @@ -247,6 +260,7 @@ async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef =
owner: context.repo.owner,
repo: context.repo.repo,
ref: registryBaseRef,
registryPath,
});

const head = pr['head'];
Expand All @@ -260,22 +274,12 @@ async function isAllowedRegistryJsonUpdate({ octokit, context, registryBaseRef =
owner: head?.repo?.owner?.login ?? context.repo.owner,
repo: head?.repo?.name ?? context.repo.repo,
ref,
registryPath,
});

return diffRegistry(mainRegistry, prRegistry);
}

/**
* Checks whether the PR changed only registry.json.
*
* @param {{ octokit: Octokit, context: Context }} args
* @returns {Promise<string[]>}
*/
async function getChangedFileReviewReasons({ octokit, context }) {
const changedFiles = await getChangedFiles({ octokit, context });
return diffChangedFiles(changedFiles);
}

/**
* Fetches the list of files changed by the PR.
*
Expand All @@ -292,42 +296,62 @@ async function getChangedFiles({ octokit, context }) {
}

/**
* Flags file-level changes that disqualify a PR from the registry-only fast path.
* Only in-place edits to the known registry files may skip core review; touching any
* other file, or renaming a registry file, requires a core reviewer.
*
* @param {PullRequestFile[]} changedFiles
* @returns {string[]}
* @returns {string[]} the reasons core review is needed; empty means every change is registry-only
*/
function diffChangedFiles(changedFiles) {
const unexpectedFiles = changedFiles
.filter((file) => file.filename !== REGISTRY_JSON_PATH || file.previous_filename != null)
const registryPaths = [...REGISTRY_JSON_PATHS].join(', ');

const nonRegistryFiles = changedFiles
.filter((file) => !REGISTRY_JSON_PATHS.has(file.filename))
.map((file) => file.filename);

if (unexpectedFiles.length === 0) {
return [];
const renamedRegistryFiles = changedFiles
.filter((file) => REGISTRY_JSON_PATHS.has(file.filename) && file.previous_filename != null)
.map((file) => `${file.previous_filename} -> ${file.filename}`);

const reasons = [];

if (nonRegistryFiles.length > 0) {
reasons.push(
`PR changes files outside the extension registries (${registryPaths}), ` +
`which requires core review: ${nonRegistryFiles.join(', ')}`,
);
}

return [
`PR changes files outside ${REGISTRY_JSON_PATH}; core review required for non-registry-only PRs: ${unexpectedFiles.join(', ')}`,
];
if (renamedRegistryFiles.length > 0) {
reasons.push(
`PR renames extension registry files, which requires core review: ` +
`${renamedRegistryFiles.join(', ')}`,
);
}

return reasons;
}

/**
* Fetches and parses cli/azd/extensions/registry.json at a given ref.
* Fetches and parses an extension registry at a given ref.
*
* @param {{ octokit: Octokit, owner: string, repo: string, ref: string }} args
* @param {{ octokit: Octokit, owner: string, repo: string, ref: string, registryPath: string }} args
* @returns {Promise<RegistryJson>}
*/
async function getRegistryJson({ octokit, owner, repo, ref }) {
async function getRegistryJson({ octokit, owner, repo, ref, registryPath }) {
const { data } = await octokit.rest.repos.getContent({
owner,
repo,
path: REGISTRY_JSON_PATH,
path: registryPath,
ref,
mediaType: {
format: 'raw',
},
});

if (typeof data !== 'string') {
throw new Error(`Unable to load ${REGISTRY_JSON_PATH} from ${owner}/${repo}@${ref}`);
throw new Error(`Unable to load ${registryPath} from ${owner}/${repo}@${ref}`);
}

return JSON.parse(data);
Expand Down Expand Up @@ -803,5 +827,3 @@ function assertHasPullRequest(context) {
throw new Error('No pull_request found in event payload. Workflow targeting should only target pull requests.');
}
}


Loading