diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f0ed6ae5..4760f911 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -109,9 +109,18 @@ jobs: env: COVERAGE_OUTCOME: ${{ steps.coverage.outcome }} GATES_OUTCOME: ${{ steps.gates.outcome }} + VSPEC_VERIFY_ISSUE_REPOSITORY: ${{ vars.VSPEC_VERIFY_ISSUE_REPOSITORY }} + VSPEC_VERIFY_ISSUE_TOKEN_SET: ${{ secrets.VSPEC_VERIFY_ISSUE_TOKEN != '' }} with: + github-token: ${{ secrets.VSPEC_VERIFY_ISSUE_TOKEN || github.token }} script: | const fs = require("node:fs"); + const { + issueRepositoryRequiresExternalToken, + resolveVerifyIssueRepository + } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/verify-issue-target.js` + ); const title = "Verify (coverage + gate sweep) is failing"; const label = "verify-failure"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; @@ -131,78 +140,154 @@ jobs: log, "```" ].join("\n"); + const currentRepository = `${context.repo.owner}/${context.repo.repo}`; + const configuredRepository = process.env.VSPEC_VERIFY_ISSUE_REPOSITORY; + let issueRepository = { owner: context.repo.owner, repo: context.repo.repo }; async function ensureLabel() { try { await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, + owner: issueRepository.owner, + repo: issueRepository.repo, name: label }); } catch (error) { if (error.status !== 404) throw error; await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, + owner: issueRepository.owner, + repo: issueRepository.repo, name: label, color: "B60205", description: "Automated Verify (coverage + gate sweep) failures" }); } } - await ensureLabel(); - const { data: issues } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:issue label:${label}` - }); - const existing = issues.items.find((issue) => issue.title === title); - if (existing === undefined) { - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title, - labels: [label], - body + try { + const repositoryMetadata = configuredRepository?.trim() + ? undefined + : (await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo + })).data; + issueRepository = resolveVerifyIssueRepository({ + configuredRepository, + currentRepository, + repositoryMetadata }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: existing.number, - body + if ( + issueRepositoryRequiresExternalToken({ currentRepository, issueRepository }) && + process.env.VSPEC_VERIFY_ISSUE_TOKEN_SET !== "true" + ) { + core.warning( + `Skipping Verify issue update for ${issueRepository.owner}/${issueRepository.repo}; fork tokens cannot write upstream issues. The upstream repository's Verify run owns issue reporting, or set VSPEC_VERIFY_ISSUE_TOKEN for this fork.` + ); + return; + } + core.info(`Reporting Verify failure in ${issueRepository.owner}/${issueRepository.repo}`); + await ensureLabel(); + const { data: issues } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${issueRepository.owner}/${issueRepository.repo} is:issue label:${label}` }); - if (existing.state !== "open") { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, + const existing = issues.items.find((issue) => issue.title === title); + if (existing === undefined) { + await github.rest.issues.create({ + owner: issueRepository.owner, + repo: issueRepository.repo, + title, + labels: [label], + body + }); + } else { + await github.rest.issues.createComment({ + owner: issueRepository.owner, + repo: issueRepository.repo, issue_number: existing.number, - state: "open" + body }); + if (existing.state !== "open") { + await github.rest.issues.update({ + owner: issueRepository.owner, + repo: issueRepository.repo, + issue_number: existing.number, + state: "open" + }); + } } + } catch (error) { + if (![403, 404, 410].includes(error.status)) { + throw error; + } + core.warning( + `Could not update Verify issue in ${issueRepository.owner}/${issueRepository.repo}: ${error.message}. Set VSPEC_VERIFY_ISSUE_TOKEN with issues:write access to that repository when running from a fork.` + ); } - name: Close verify issue on green if: >- github.event_name != 'pull_request' && steps.coverage.outcome == 'success' && steps.gates.outcome == 'success' uses: actions/github-script@v7 + env: + VSPEC_VERIFY_ISSUE_REPOSITORY: ${{ vars.VSPEC_VERIFY_ISSUE_REPOSITORY }} + VSPEC_VERIFY_ISSUE_TOKEN_SET: ${{ secrets.VSPEC_VERIFY_ISSUE_TOKEN != '' }} with: + github-token: ${{ secrets.VSPEC_VERIFY_ISSUE_TOKEN || github.token }} script: | + const { + issueRepositoryRequiresExternalToken, + resolveVerifyIssueRepository + } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/verify-issue-target.js` + ); const label = "verify-failure"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const { data: issues } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:${label}` - }); - for (const issue of issues.items) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `Resolved by ${context.sha} — Verify is green. ${runUrl}` + const currentRepository = `${context.repo.owner}/${context.repo.repo}`; + const configuredRepository = process.env.VSPEC_VERIFY_ISSUE_REPOSITORY; + let issueRepository = { owner: context.repo.owner, repo: context.repo.repo }; + try { + const repositoryMetadata = configuredRepository?.trim() + ? undefined + : (await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo + })).data; + issueRepository = resolveVerifyIssueRepository({ + configuredRepository, + currentRepository, + repositoryMetadata }); - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: "closed" + if ( + issueRepositoryRequiresExternalToken({ currentRepository, issueRepository }) && + process.env.VSPEC_VERIFY_ISSUE_TOKEN_SET !== "true" + ) { + core.warning( + `Skipping Verify issue close for ${issueRepository.owner}/${issueRepository.repo}; fork tokens cannot write upstream issues. The upstream repository's Verify run owns issue reporting, or set VSPEC_VERIFY_ISSUE_TOKEN for this fork.` + ); + return; + } + core.info(`Closing Verify issues in ${issueRepository.owner}/${issueRepository.repo}`); + const { data: issues } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${issueRepository.owner}/${issueRepository.repo} is:issue is:open label:${label}` }); + for (const issue of issues.items) { + await github.rest.issues.createComment({ + owner: issueRepository.owner, + repo: issueRepository.repo, + issue_number: issue.number, + body: `Resolved by ${context.sha} — Verify is green. ${runUrl}` + }); + await github.rest.issues.update({ + owner: issueRepository.owner, + repo: issueRepository.repo, + issue_number: issue.number, + state: "closed" + }); + } + } catch (error) { + if (![403, 404, 410].includes(error.status)) { + throw error; + } + core.warning( + `Could not close Verify issue in ${issueRepository.owner}/${issueRepository.repo}: ${error.message}. Set VSPEC_VERIFY_ISSUE_TOKEN with issues:write access to that repository when running from a fork.` + ); } - name: Fail workflow if any check failed if: steps.coverage.outcome == 'failure' || steps.gates.outcome == 'failure' diff --git a/apps/cli/tests/unit/verify-action.test.ts b/apps/cli/tests/unit/verify-action.test.ts index f6c842bf..04e06030 100644 --- a/apps/cli/tests/unit/verify-action.test.ts +++ b/apps/cli/tests/unit/verify-action.test.ts @@ -28,4 +28,18 @@ describe("verify GitHub Action adapter", () => { expect(workflow).toContain("steps.verify.outputs.exit_code"); expect(workflow).toContain("steps.verify.outputs.log_path"); }); + + it("posts verify failure issues to the upstream repository from forks", () => { + const workflow = readFileSync(".github/workflows/verify.yml", "utf8"); + + expect(workflow).toContain( + "github-token: ${{ secrets.VSPEC_VERIFY_ISSUE_TOKEN || github.token }}" + ); + expect(workflow).toContain("VSPEC_VERIFY_ISSUE_REPOSITORY"); + expect(workflow).toContain("VSPEC_VERIFY_ISSUE_TOKEN_SET"); + expect(workflow).toContain("resolveVerifyIssueRepository"); + expect(workflow).toContain("issueRepositoryRequiresExternalToken"); + expect(workflow).toContain("github.rest.repos.get"); + expect(workflow).toContain("Skipping Verify issue update for"); + }); }); diff --git a/apps/cli/tests/unit/verify-issue-target.test.ts b/apps/cli/tests/unit/verify-issue-target.test.ts new file mode 100644 index 00000000..273b663f --- /dev/null +++ b/apps/cli/tests/unit/verify-issue-target.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + issueRepositoryRequiresExternalToken, + resolveVerifyIssueRepository +} from "../../../../scripts/verify-issue-target.js"; + +describe("verify issue target", () => { + it("routes fork verify issues to the upstream repository", () => { + expect( + resolveVerifyIssueRepository({ + currentRepository: "fork-owner/product", + repositoryMetadata: { + fork: true, + parent: { full_name: "upstream-owner/product" } + } + }) + ).toEqual({ owner: "upstream-owner", repo: "product" }); + }); + + it("keeps non-fork verify issues in the current repository", () => { + expect( + resolveVerifyIssueRepository({ + currentRepository: "vibemafiaclub/vooster", + repositoryMetadata: { fork: false } + }) + ).toEqual({ owner: "vibemafiaclub", repo: "vooster" }); + }); + + it("allows an explicit issue repository override", () => { + expect( + resolveVerifyIssueRepository({ + configuredRepository: "ops/alerts", + currentRepository: "Seongho-Bae/vooster", + repositoryMetadata: { + fork: true, + parent: { full_name: "vibemafiaclub/vooster" } + } + }) + ).toEqual({ owner: "ops", repo: "alerts" }); + }); + + it("requires an external token only when reporting outside the current repository", () => { + expect( + issueRepositoryRequiresExternalToken({ + currentRepository: "fork-owner/product", + issueRepository: { owner: "upstream-owner", repo: "product" } + }) + ).toBe(true); + + expect( + issueRepositoryRequiresExternalToken({ + currentRepository: "upstream-owner/product", + issueRepository: { owner: "upstream-owner", repo: "product" } + }) + ).toBe(false); + }); +}); diff --git a/scripts/verify-issue-target.d.ts b/scripts/verify-issue-target.d.ts new file mode 100644 index 00000000..7855f344 --- /dev/null +++ b/scripts/verify-issue-target.d.ts @@ -0,0 +1,22 @@ +type VerifyIssueRepository = { + owner: string; + repo: string; +}; + +type VerifyIssueRepositoryMetadata = { + fork?: boolean; + parent?: { + full_name?: string; + }; +}; + +export function resolveVerifyIssueRepository(input: { + configuredRepository?: string; + currentRepository: string; + repositoryMetadata?: VerifyIssueRepositoryMetadata; +}): VerifyIssueRepository; + +export function issueRepositoryRequiresExternalToken(input: { + currentRepository: string; + issueRepository: VerifyIssueRepository; +}): boolean; diff --git a/scripts/verify-issue-target.js b/scripts/verify-issue-target.js new file mode 100644 index 00000000..66866bbd --- /dev/null +++ b/scripts/verify-issue-target.js @@ -0,0 +1,40 @@ +export function resolveVerifyIssueRepository({ + configuredRepository, + currentRepository, + repositoryMetadata +}) { + const configured = configuredRepository?.trim(); + if (configured !== undefined && configured !== "") { + return parseRepositoryFullName(configured); + } + + if ( + repositoryMetadata?.fork === true && + repositoryMetadata.parent?.full_name !== undefined + ) { + return parseRepositoryFullName(repositoryMetadata.parent.full_name); + } + + return parseRepositoryFullName(currentRepository); +} + +export function issueRepositoryRequiresExternalToken({ + currentRepository, + issueRepository +}) { + const current = parseRepositoryFullName(currentRepository); + return ( + current.owner.toLowerCase() !== issueRepository.owner.toLowerCase() || + current.repo.toLowerCase() !== issueRepository.repo.toLowerCase() + ); +} + +function parseRepositoryFullName(fullName) { + const parts = fullName?.trim().split("/") ?? []; + if (parts.length !== 2 || parts.some((part) => part.trim() === "")) { + throw new Error( + `Expected GitHub repository as owner/repo, got ${JSON.stringify(fullName)}` + ); + } + return { owner: parts[0], repo: parts[1] }; +}