Skip to content
175 changes: 174 additions & 1 deletion actions/setup/js/check_version_updates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const { getErrorMessage } = require("./error_helpers.cjs");

const CONFIG_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json";
const FETCH_TIMEOUT_MS = 120_000;
const BLOCKED_VERSION_ISSUE_TITLE_PREFIX = "[aw] Workflows blocked by compile-agentic";
const GITHUB_API_VERSION = "2022-11-28";

/**
* Parse an official version string (must be in vMAJOR.MINOR.PATCH format).
Expand Down Expand Up @@ -58,6 +60,168 @@ function compareVersions(a, b) {
return 0;
}

/**
* Build the stable issue title used to deduplicate blocked compiler notifications.
*
* @param {string} compiledVersion
* @returns {string}
*/
function buildBlockedVersionIssueTitle(compiledVersion) {
return `${BLOCKED_VERSION_ISSUE_TITLE_PREFIX} ${compiledVersion}`;
}

/**
* Return a Markdown-safe inline-code representation.
*
* @param {string} value
* @returns {string}
*/
function markdownCode(value) {
return `\`${String(value).replace(/`/g, "\\`")}\``;
}

/**
* Build a link to the current workflow run when the GitHub Actions context is available.
*
* @returns {string}
*/
function getRunUrl() {
const repoFullName = process.env.GITHUB_REPOSITORY || (typeof context !== "undefined" && context.repo ? `${context.repo.owner}/${context.repo.repo}` : "");
const runId = process.env.GITHUB_RUN_ID || String(typeof context !== "undefined" && context.runId ? context.runId : "");
if (!repoFullName || !runId) {
return "";
}
const serverUrl = process.env.GITHUB_SERVER_URL || (typeof context !== "undefined" ? context.serverUrl : "") || "https://github.com";
return `${serverUrl}/${repoFullName}/actions/runs/${runId}`;
}

/**
* Build the body for the blocked compiler notification issue.
*
* @param {string} compiledVersion
* @returns {string}
*/
function buildBlockedVersionIssueBody(compiledVersion) {
const workflowName = process.env.GH_AW_WORKFLOW_NAME || (typeof context !== "undefined" ? context.workflow : "") || "unknown";
const runUrl = getRunUrl();
const lines = [
`<!-- gh-aw-blocked-compiler-version: ${compiledVersion} -->`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot refactor text to template in actions/setup/md and use render file template to render.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 86f6d51: extracted the issue body into actions/setup/md/blocked_compiler_version_issue.md and refactored buildBlockedVersionIssueBody to call renderTemplateFromFile(getPromptPath(...), {...}), matching the pattern used in handle_agent_failure.cjs.

"",
"## Agentic workflows are blocked",
"",
`This repository has one or more workflows compiled with ${markdownCode(compiledVersion)}, which is in the blocked versions list.`,
"",
"Activation fails before the agent, safe outputs, and conclusion jobs can run.",
"",
"### Latest blocked run",
"",
`- Workflow: ${markdownCode(workflowName)}`,
];
if (runUrl) {
lines.push(`- Run: ${runUrl}`);
}
lines.push(
"",
"### Action required",
"",
"Update `gh-aw` to the latest version and recompile the affected workflows with `gh aw compile`.",
"",
"This issue is updated by the activation-stage version check when another blocked run is detected."
);
return lines.join("\n");
}

/**
* Find an existing open blocked-version issue for this compiler version.
*
* NOTE: This relies on the GitHub search index, which is eventually consistent.
* During a repo-wide blocked-version outage, many activation runs can fire close
* together; two runs can both observe `items: []` before the index catches up and
* both proceed to create an issue, producing duplicates. This is accepted as a
* best-effort tradeoff (consistent with other dedup lookups in this codebase, e.g.
* handle_agent_failure.cjs) rather than a correctness guarantee. The call is wrapped
* in withRetry so transient failures and secondary rate limits (more likely during a
* fan-out of many concurrent activation failures) don't silently drop the lookup.
*
* @param {string} owner
* @param {string} repo
* @param {string} compiledVersion
* @returns {Promise<{number: number, html_url: string} | null>}
*/
async function findExistingBlockedVersionIssue(owner, repo, compiledVersion) {
Comment thread
github-actions[bot] marked this conversation as resolved.
const title = buildBlockedVersionIssueTitle(compiledVersion);
const result = await withRetry(
() =>
github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:issue is:open in:title "${title}"`,
per_page: 10,
}),
{},
"search for existing blocked compiler version issue"
);
const existing = result.data.items.find(item => item.title === title && !item.pull_request);
return existing ? { number: existing.number, html_url: existing.html_url } : null;
}

/**
* Best-effort issue notification for blocked compiler versions. Failures here must not
* mask the primary blocked-version error.
*
* @param {string} compiledVersion
* @returns {Promise<void>}
*/
async function reportBlockedVersionIssue(compiledVersion) {
if (process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE === "false") {
core.info("Blocked compiler version issue reporting is disabled");
return;
}
if (typeof github === "undefined" || typeof context === "undefined" || !github.rest?.issues || !github.rest?.search || !context.repo) {
Comment thread
github-actions[bot] marked this conversation as resolved.
core.info("GitHub issue APIs are unavailable; skipping blocked compiler version issue notification");
return;
}

const { owner, repo } = context.repo;
const title = buildBlockedVersionIssueTitle(compiledVersion);
const body = buildBlockedVersionIssueBody(compiledVersion);

try {
const existing = await findExistingBlockedVersionIssue(owner, repo, compiledVersion);
if (existing) {
const updatedIssue = await withRetry(
() =>
github.rest.issues.update({
owner,
repo,
issue_number: existing.number,
title,
body,
headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION },
}),
{},
"update blocked compiler version issue"
);
core.info(`Updated blocked compiler version issue #${updatedIssue.data.number}: ${updatedIssue.data.html_url}`);
return;
}

const newIssue = await withRetry(
() =>
github.rest.issues.create({
owner,
repo,
title,
body,
headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION },
}),
{},
"create blocked compiler version issue"
);
core.info(`Created blocked compiler version issue #${newIssue.data.number}: ${newIssue.data.html_url}`);
} catch (err) {
core.warning(`Could not create or update blocked compiler version issue: ${getErrorMessage(err)}`);
}
}

/**
* @typedef {object} UpdateConfig
* @property {string[]} [blockedVersions]
Expand Down Expand Up @@ -128,6 +292,7 @@ async function main() {
.addRaw("This version has been revoked, typically due to a security issue.\n\n")
.addRaw("**Action required:** Update `gh-aw` to the latest version and recompile your workflow with `gh aw compile`.\n");
await core.summary.write();
await reportBlockedVersionIssue(compiledVersion);
core.setFailed(`Blocked compile-agentic version: ${compiledVersion} is in the blocked versions list. Update gh-aw to the latest version and recompile your workflow.`);
return;
}
Expand Down Expand Up @@ -157,4 +322,12 @@ async function main() {
core.info(`✅ Version check passed: ${compiledVersion}`);
}

module.exports = { main };
module.exports = {
buildBlockedVersionIssueBody,
buildBlockedVersionIssueTitle,
compareVersions,
findExistingBlockedVersionIssue,
main,
parseVersion,
reportBlockedVersionIssue,
};
142 changes: 142 additions & 0 deletions actions/setup/js/check_version_updates.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ describe("check_version_updates", () => {
};

global.core = mockCore;
global.github = {};
global.context = { repo: { owner: "owner", repo: "repo" }, workflow: "Test workflow", runId: 123 };

mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

delete process.env.GH_AW_COMPILED_VERSION;
delete process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE;
delete process.env.GH_AW_WORKFLOW_NAME;
delete process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_RUN_ID;
delete process.env.GITHUB_SERVER_URL;

vi.resetModules();

Expand All @@ -38,6 +45,9 @@ describe("check_version_updates", () => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
delete global.core;
delete global.github;
delete global.context;
});

/**
Expand Down Expand Up @@ -336,6 +346,138 @@ describe("check_version_updates", () => {
expect(mockCore.summary.write).toHaveBeenCalled();
});

it("should create a deduplicated issue when version is blocked", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
process.env.GH_AW_WORKFLOW_NAME = "Blocked workflow";
process.env.GITHUB_REPOSITORY = "owner/repo";
process.env.GITHUB_RUN_ID = "456";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
const searchMock = vi.fn().mockResolvedValue({ data: { items: [] } });
const createMock = vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/owner/repo/issues/42" } });
global.github = {
rest: {
search: { issuesAndPullRequests: searchMock },
issues: { create: createMock, update: vi.fn() },
},
};

await runMain();

expect(searchMock).toHaveBeenCalledWith({
q: 'repo:owner/repo is:issue is:open in:title "[aw] Workflows blocked by compile-agentic v1.0.0"',
per_page: 10,
});
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
title: "[aw] Workflows blocked by compile-agentic v1.0.0",
body: expect.stringContaining("Workflow: `Blocked workflow`"),
})
);
expect(createMock.mock.calls[0][0].body).toContain("https://github.com/owner/repo/actions/runs/456");
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

it("should update an existing blocked version issue when version is blocked", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
const searchMock = vi.fn().mockResolvedValue({
data: {
items: [{ number: 7, html_url: "https://github.com/owner/repo/issues/7", title: "[aw] Workflows blocked by compile-agentic v1.0.0" }],
},
});
const updateMock = vi.fn().mockResolvedValue({ data: { number: 7, html_url: "https://github.com/owner/repo/issues/7" } });
const createMock = vi.fn();
global.github = {
rest: {
search: { issuesAndPullRequests: searchMock },
issues: { create: createMock, update: updateMock },
},
};

await runMain();

expect(updateMock).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
issue_number: 7,
title: "[aw] Workflows blocked by compile-agentic v1.0.0",
})
);
expect(createMock).not.toHaveBeenCalled();
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

it("should continue failing the version check when blocked issue creation fails", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
global.github = {
rest: {
search: { issuesAndPullRequests: vi.fn().mockResolvedValue({ data: { items: [] } }) },
issues: { create: vi.fn().mockRejectedValue(new Error("Resource not accessible by integration")), update: vi.fn() },
},
};

await runMain();

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not create or update blocked compiler version issue"));
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

it("should skip issue notification when github APIs are unavailable", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
global.github = {}; // no rest.search / rest.issues

await runMain();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GitHub issue APIs are unavailable"));
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

it("should retry the issue search on a transient error before creating an issue", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
const searchMock = vi
.fn()
.mockRejectedValueOnce(new Error("secondary rate limit exceeded"))
.mockResolvedValueOnce({ data: { items: [] } });
const createMock = vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/owner/repo/issues/42" } });
global.github = {
rest: {
search: { issuesAndPullRequests: searchMock },
issues: { create: createMock, update: vi.fn() },
},
};

await runMain();

expect(searchMock).toHaveBeenCalledTimes(2);
expect(createMock).toHaveBeenCalledTimes(1);
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

it("should skip blocked version issue creation when disabled", async () => {
process.env.GH_AW_COMPILED_VERSION = "v1.0.0";
process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE = "false";
mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" }));
const createMock = vi.fn();
global.github = {
rest: {
search: { issuesAndPullRequests: vi.fn() },
issues: { create: createMock, update: vi.fn() },
},
};

await runMain();

expect(createMock).not.toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith("Blocked compiler version issue reporting is disabled");
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version"));
});

// ---------------------------------------------------------------------------
// Minimum version cases
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading