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
167 changes: 126 additions & 41 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/tests/unit/verify-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
58 changes: 58 additions & 0 deletions apps/cli/tests/unit/verify-issue-target.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions scripts/verify-issue-target.d.ts
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 40 additions & 0 deletions scripts/verify-issue-target.js
Original file line number Diff line number Diff line change
@@ -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] };
}