diff --git a/.github/ISSUE_TEMPLATE/external-plugin.yml b/.github/ISSUE_TEMPLATE/external-plugin.yml index ccbb3a77..3daf8193 100644 --- a/.github/ISSUE_TEMPLATE/external-plugin.yml +++ b/.github/ISSUE_TEMPLATE/external-plugin.yml @@ -14,7 +14,7 @@ body: Before you continue: - Public submissions are **GitHub-only** in v1. - The plugin must live in a **public GitHub repository**. - - Use an **immutable ref** for review: a release tag or full 40-character commit SHA. + - Provide an immutable **ref**, **sha**, or both for review. - Do **not** open a PR that edits `plugins/external.json` directly. - type: input id: plugin-name @@ -51,11 +51,19 @@ body: - type: input id: immutable-ref attributes: - label: Immutable ref to review - description: Release tag or full 40-character commit SHA. - placeholder: refs/tags/v1.2.3 or 0123456789abcdef0123456789abcdef01234567 + label: Ref to review + description: Optional release tag or tag ref. Submit this, a commit SHA, or both. + placeholder: v1.2.3 validations: - required: true + required: false + - type: input + id: immutable-sha + attributes: + label: Commit SHA to review + description: Optional full 40-character commit SHA. Submit this, a ref, or both. + placeholder: 0123456789abcdef0123456789abcdef01234567 + validations: + required: false - type: input id: version attributes: @@ -119,7 +127,7 @@ body: options: - label: The plugin lives in a public GitHub repository. required: true - - label: The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch. + - label: The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch. required: true - label: This submission follows this repository's contribution, security, and responsible AI policies. required: true diff --git a/AGENTS.md b/AGENTS.md index 28f3a33c..020d5746 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,7 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: 1. Do not open a direct PR that edits `plugins/external.json` for a public third-party plugin submission 2. Public external plugin submissions use the external plugin issue workflow documented in [CONTRIBUTING.md](CONTRIBUTING.md#adding-external-plugins) -3. In v1, only GitHub-hosted plugins are accepted for public submission, using a public repo plus an immutable `ref` +3. In v1, only GitHub-hosted plugins are accepted for public submission, using a public repo plus an immutable `ref`, `sha`, or both 4. The shared validator in `eng/external-plugin-validation.mjs` is the canonical source of truth for external plugin data rules; reuse it instead of duplicating checks in scripts or workflows 5. Submission issues move through `external-plugin` + `awaiting-review` -> `ready-for-review` -> `approved` or `rejected` 6. After issue edits, the issue author or a maintainer can comment `/rerun-intake` to re-run automated intake without opening a new submission issue diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4c5b37f..14c57552 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -202,7 +202,8 @@ The external plugin issue form will collect these fields: - Short description - GitHub repository in `owner/repo` format - Plugin path inside the repository (optional when the plugin is at the repository root) -- Immutable ref to review (`ref`), using a release tag or full commit SHA rather than a branch +- Ref to review (`ref`), using a release tag or tag ref rather than a branch +- Commit SHA to review (`sha`), using a full 40-character commit SHA - Plugin version - License identifier - Author name @@ -210,7 +211,7 @@ The external plugin issue form will collect these fields: - Homepage URL (optional) - Keywords/tags - Additional notes for reviewers (optional) -- Confirmation checkboxes that the repository is public, the ref is immutable, the submission follows this repository's policies, and the plugin is not a duplicate listing +- Confirmation checkboxes that the repository is public, the submitted ref and/or sha is immutable, the submission follows this repository's policies, and the plugin is not a duplicate listing The repository's canonical validation rules live in `eng/external-plugin-validation.mjs`. Build scripts reuse the `marketplace` policy from that module, and the issue intake automation uses the stricter `publicSubmission` policy so the JSON contract and workflow checks stay aligned. @@ -223,7 +224,7 @@ For entries committed to `plugins/external.json`, the current marketplace valida - `source.source: "github"` plus `source.repo` in `owner/repo` format - optional `source.path` values of `/` for repository root, or a repository-relative folder where the plugin structure starts (do not point to `plugin.json` directly) -The public-submission policy builds on those rules and also requires `license` plus an immutable `source.ref`. +The public-submission policy builds on those rules and also requires `license` plus at least one immutable source locator: `source.ref`, `source.sha`, or both. ##### Review workflow @@ -240,7 +241,7 @@ The public-submission policy builds on those rules and also requires `license` p Maintainers are responsible for confirming that the submission: - Clearly fits the Awesome Copilot collection and adds value beyond existing listings -- Uses a public GitHub repository and an immutable ref that can be reviewed reliably +- Uses a public GitHub repository and an immutable ref and/or SHA that can be reviewed reliably - Includes the required metadata for `plugins/external.json` (`name`, `description`, `version`, `author.name`, `repository`, `keywords`, and `source`), plus any supplied homepage/license fields - Does not obviously duplicate an existing marketplace entry - Continues to meet this repository's content, security, and responsible AI policies @@ -284,7 +285,8 @@ Approved submissions are converted into `plugins/external.json` entries followin "source": "github", "repo": "owner/plugin-repo", "path": ".github/plugins/my-external-plugin", - "ref": "v1.0.0" + "ref": "v1.0.0", + "sha": "0123456789abcdef0123456789abcdef01234567" } } ] diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 7bf187a6..72c981a8 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -15,11 +15,17 @@ const RERUN_INTAKE_COMMAND_PATTERN = new RegExp( ); const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); +// Each entry is a Set of equivalent checklist item texts (new + legacy aliases). +// A submission passes if the checked items contain at least one text from each Set. const REQUIRED_CHECKLIST_ITEMS = [ - "The plugin lives in a public GitHub repository.", - "The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.", - "This submission follows this repository's contribution, security, and responsible AI policies.", - "This plugin is not already listed in the Awesome Copilot marketplace.", + new Set(["The plugin lives in a public GitHub repository."]), + new Set([ + "The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.", + // Legacy text used in the original issue template + "The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.", + ]), + new Set(["This submission follows this repository's contribution, security, and responsible AI policies."]), + new Set(["This plugin is not already listed in the Awesome Copilot marketplace."]), ]; const FIELD_TITLES = Object.freeze({ @@ -27,7 +33,8 @@ const FIELD_TITLES = Object.freeze({ shortDescription: "Short description", githubRepository: "GitHub repository", pluginPath: "Plugin path inside the repository", - immutableRef: "Immutable ref to review", + immutableRef: "Ref to review", + immutableSha: "Commit SHA to review", version: "Version", license: "License identifier", authorName: "Author name", @@ -38,6 +45,11 @@ const FIELD_TITLES = Object.freeze({ submissionChecklist: "Submission checklist", }); +// Legacy field title used in the original issue template (before the ref/sha split) +const LEGACY_FIELD_TITLES = Object.freeze({ + immutableRef: "Immutable ref to review", +}); + function normalizeMultilineText(value) { return String(value ?? "").replace(/\r\n/g, "\n"); } @@ -156,7 +168,7 @@ function encodeRepoPath(repo) { return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`; } -async function validateRemoteRepository(repo, ref, errors, warnings, token) { +async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, token) { const encodedRepo = encodeRepoPath(repo); const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token); @@ -177,6 +189,15 @@ async function validateRemoteRepository(repo, ref, errors, warnings, token) { warnings.push(`submission: GitHub repository "${repo}" is archived`); } + if (sha) { + if (/^[0-9a-f]{40}$/i.test(sha)) { + const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(sha)}`, token); + if (!commitResponse.ok) { + errors.push(`submission: commit "${sha}" was not found in GitHub repository "${repo}"`); + } + } + } + if (!ref) { return; } @@ -189,6 +210,14 @@ async function validateRemoteRepository(repo, ref, errors, warnings, token) { return; } + if (ref.startsWith("refs/heads/") || ["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) { + return; + } + + if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) { + return; + } + const tagName = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref; const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token); @@ -197,7 +226,7 @@ async function validateRemoteRepository(repo, ref, errors, warnings, token) { } if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) { - errors.push('submission: commit SHAs in "Immutable ref to review" must use the full 40-character SHA'); + errors.push('submission: commit SHAs in "Ref to review" must use the full 40-character SHA or be submitted in "Commit SHA to review"'); return; } @@ -221,7 +250,11 @@ export function parseExternalPluginIssueBody(body) { const pluginName = requiredField(FIELD_TITLES.pluginName); const shortDescription = requiredField(FIELD_TITLES.shortDescription); const repoInput = normalizeGitHubRepo(requiredField(FIELD_TITLES.githubRepository)); - const immutableRef = requiredField(FIELD_TITLES.immutableRef); + // Support both the current field title and the legacy title used before the ref/sha split + const immutableRef = stripNoResponse( + sections.get(FIELD_TITLES.immutableRef) ?? sections.get(LEGACY_FIELD_TITLES.immutableRef), + ); + const immutableSha = stripNoResponse(sections.get(FIELD_TITLES.immutableSha)); const version = requiredField(FIELD_TITLES.version); const license = requiredField(FIELD_TITLES.license); const authorName = requiredField(FIELD_TITLES.authorName); @@ -233,9 +266,22 @@ export function parseExternalPluginIssueBody(body) { const additionalNotes = stripNoResponse(sections.get(FIELD_TITLES.additionalNotes)); const checkedItems = parseChecklist(sections.get(FIELD_TITLES.submissionChecklist)); - for (const item of REQUIRED_CHECKLIST_ITEMS) { - if (!checkedItems.has(item)) { - errors.push(`submission: checklist item must be checked: "${item}"`); + if (!immutableRef && !immutableSha) { + errors.push(`submission: one of "${FIELD_TITLES.immutableRef}" or "${FIELD_TITLES.immutableSha}" is required`); + } + + for (const equivalents of REQUIRED_CHECKLIST_ITEMS) { + let isChecked = false; + for (const text of equivalents) { + if (checkedItems.has(text)) { + isChecked = true; + break; + } + } + if (!isChecked) { + // Report using the canonical (first) text in each equivalents Set + const [canonical] = equivalents; + errors.push(`submission: checklist item must be checked: "${canonical}"`); } } @@ -256,6 +302,7 @@ export function parseExternalPluginIssueBody(body) { repo: repoInput, ...(pluginPath ? { path: pluginPath } : {}), ...(immutableRef ? { ref: immutableRef } : {}), + ...(immutableSha ? { sha: immutableSha } : {}), }, }; @@ -297,8 +344,8 @@ export async function evaluateExternalPluginIssue({ issue, token } = {}) { } } - if (parsed.plugin?.source?.repo && parsed.plugin?.source?.ref) { - await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source.ref, errors, warnings, token); + if (parsed.plugin?.source?.repo && (parsed.plugin?.source?.ref || parsed.plugin?.source?.sha)) { + await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source, errors, warnings, token); } const dedupedErrors = [...new Set(errors)]; @@ -324,7 +371,8 @@ export async function evaluateExternalPluginIssue({ issue, token } = {}) { "", `- **Plugin:** ${parsed.plugin.name}`, `- **Repository:** ${parsed.plugin.repository}`, - `- **Ref:** ${parsed.plugin.source.ref}`, + parsed.plugin.source.ref ? `- **Ref:** ${parsed.plugin.source.ref}` : undefined, + parsed.plugin.source.sha ? `- **SHA:** ${parsed.plugin.source.sha}` : undefined, `- **Keywords:** ${normalizedKeywords}`, "", "### Canonical external.json payload", diff --git a/eng/external-plugin-validation.mjs b/eng/external-plugin-validation.mjs index 100ce1f4..1a49bff4 100644 --- a/eng/external-plugin-validation.mjs +++ b/eng/external-plugin-validation.mjs @@ -11,7 +11,7 @@ export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({ requireRepository: true, requireKeywords: true, requireLicense: false, - requireImmutableRef: false, + requireImmutableLocator: false, }), publicSubmission: Object.freeze({ allowedSourceTypes: ["github"], @@ -19,7 +19,7 @@ export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({ requireRepository: true, requireKeywords: true, requireLicense: true, - requireImmutableRef: true, + requireImmutableLocator: true, }), }); @@ -263,9 +263,24 @@ function validateImmutableRef(ref, prefix, errors) { if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) { errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`); } + + if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) { + errors.push(`${prefix}: "source.ref" must be a full 40-character commit SHA when referencing a commit`); + } } -function validateGitHubSource(source, prefix, errors, requireImmutableRef) { +function validateCommitSha(sha, prefix, errors) { + if (!isNonEmptyString(sha)) { + errors.push(`${prefix}: "source.sha" must be a non-empty string when provided`); + return; + } + + if (!/^[0-9a-f]{40}$/i.test(sha)) { + errors.push(`${prefix}: "source.sha" must be a full 40-character commit SHA`); + } +} + +function validateGitHubSource(source, prefix, errors, requireImmutableLocator) { if (!source || typeof source !== "object" || Array.isArray(source)) { errors.push(`${prefix}: "source" must be an object`); return; @@ -287,8 +302,14 @@ function validateGitHubSource(source, prefix, errors, requireImmutableRef) { if (source.ref !== undefined) { validateImmutableRef(source.ref, prefix, errors); - } else if (requireImmutableRef) { - errors.push(`${prefix}: "source.ref" is required for public external plugin submissions`); + } + + if (source.sha !== undefined) { + validateCommitSha(source.sha, prefix, errors); + } + + if (requireImmutableLocator && source.ref === undefined && source.sha === undefined) { + errors.push(`${prefix}: one of "source.ref" or "source.sha" is required for public external plugin submissions`); } } @@ -325,7 +346,7 @@ export function validateExternalPlugin(plugin, index, options = {}) { } else if (!policy.allowedSourceTypes.includes(plugin.source.source)) { errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`); } else if (plugin.source.source === "github") { - validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableRef); + validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableLocator); } return { errors, warnings };