Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Harden run created-at normalization and grader failure reporting
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Sep 13, 2026
commit 2768b1bebb3f1b87c39cd266ef577fc35fbece04
15 changes: 15 additions & 0 deletions actions/setup/js/generate_aw_info.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ const { ERR_CONFIG, ERR_SYSTEM } = require("./error_codes.cjs");
const RUN_CREATED_AT_ATTEMPTS = 3;

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 move run create at logic into own file, add tests
Move constants to constants

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.

All created-at logic now lives in run_created_at.cjs (normalizeRunCreatedAt, fetchRunCreatedAt, resolveRunCreatedAtForInfo, resolveRunCreatedAtForGrading); generate_aw_info.cjs and trace_graders.cjs just call it. Retry constants moved to constants.cjs, and the tests moved into run_created_at.test.cjs. c25ee41

const RUN_CREATED_AT_RETRY_DELAY_MS = 500;

/**
* Report whether a workflow-run lookup failure is permanent (for example a missing
* `actions: read` permission), so retries are not spent on a request that cannot succeed.
*
* @param {any} err
* @returns {boolean}
*/
function isNonRetryableRunCreatedAtError(err) {
const status = Number(err?.status);
return Number.isInteger(status) && status >= 400 && status < 500 && status !== 408 && status !== 429;
}

/**
* Resolve the authoritative workflow-run creation time as a UTC ISO timestamp.
* Consumers (the operational-value grader) require a valid timestamp, so transient
Expand Down Expand Up @@ -44,6 +56,9 @@ async function resolveRunCreatedAt(core, ctx, githubClient) {
lastError = `workflow run creation time is not a valid timestamp: ${JSON.stringify(response?.data?.created_at)}`;
} catch (err) {
lastError = getErrorMessage(err);
if (isNonRetryableRunCreatedAtError(err)) {
break;
}
}
if (attempt < RUN_CREATED_AT_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, RUN_CREATED_AT_RETRY_DELAY_MS));
Expand Down
11 changes: 11 additions & 0 deletions actions/setup/js/generate_aw_info.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ describe("generate_aw_info.cjs", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "2026-08-24T12:00:00Z");
});

it("should not retry a permanent run creation time lookup failure", async () => {
process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT = "true";
const forbidden = Object.assign(new Error("forbidden"), { status: 403 });
mockGithub.rest.actions.getWorkflowRun.mockRejectedValue(forbidden);

await main(mockCore, mockContext, mockGithub);

expect(mockGithub.rest.actions.getWorkflowRun).toHaveBeenCalledTimes(1);
expect(mockCore.setOutput).toHaveBeenCalledWith("run_created_at", "");
});

it("should warn and clear the run creation time when the lookup returns no usable timestamp", async () => {
process.env.GH_AW_INFO_FETCH_RUN_CREATED_AT = "true";
mockGithub.rest.actions.getWorkflowRun.mockResolvedValue({ data: { created_at: null } });
Expand Down
15 changes: 11 additions & 4 deletions actions/setup/js/run_created_at.cjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
// @ts-check

// ISO-8601 date-time with a UTC designator or a numeric offset. Date.parse accepts
// looser formats (for example "2026" or "2026-09-12 20:58:00"), which must not be
// promoted to a valid run creation time.
const ISO_8601_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* Normalize a workflow-run creation time into the strict UTC ISO-8601 form
* (`YYYY-MM-DDTHH:MM:SSZ`) expected by the operational-value grader contract.
*
* GitHub returns `created_at` as an ISO timestamp, but callers may also receive
* an empty string (unresolved workflow output) or an offset-bearing timestamp.
* Values that cannot be parsed are reported as an empty string so callers can
* treat the timestamp as unavailable rather than propagating an invalid value.
* Values that are not ISO-8601 date-times are reported as an empty string so
* callers can treat the timestamp as unavailable rather than propagating an
* invalid value. Sub-second precision is truncated (not rounded) because the
* grader contract records run creation times at second granularity.
*
* @param {unknown} value
* @returns {string} normalized timestamp, or "" when the value is unusable
*/
function normalizeRunCreatedAt(value) {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (trimmed === "") return "";
if (!ISO_8601_DATE_TIME.test(trimmed)) return "";
const parsed = Date.parse(trimmed);
if (!Number.isFinite(parsed)) return "";
return new Date(parsed).toISOString().replace(/\.\d{3}Z$/, "Z");
return new Date(Math.floor(parsed / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
}

module.exports = { normalizeRunCreatedAt };
3 changes: 3 additions & 0 deletions actions/setup/js/run_created_at.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ describe("normalizeRunCreatedAt", () => {
it("normalizes offsets and millisecond precision", () => {
expect(normalizeRunCreatedAt("2026-09-12T22:58:00+02:00")).toBe("2026-09-12T20:58:00Z");
expect(normalizeRunCreatedAt("2026-09-12T20:58:00.553Z")).toBe("2026-09-12T20:58:00Z");
expect(normalizeRunCreatedAt("2026-09-12T20:58:00.999Z")).toBe("2026-09-12T20:58:00Z");
});

it("reports unusable values as empty", () => {
expect(normalizeRunCreatedAt("")).toBe("");
expect(normalizeRunCreatedAt(" ")).toBe("");
expect(normalizeRunCreatedAt("not-a-timestamp")).toBe("");
expect(normalizeRunCreatedAt("2026")).toBe("");
expect(normalizeRunCreatedAt("2026-09-12 20:58:00")).toBe("");
expect(normalizeRunCreatedAt(null)).toBe("");
expect(normalizeRunCreatedAt(undefined)).toBe("");
expect(normalizeRunCreatedAt(1757710680000)).toBe("");
Expand Down
8 changes: 6 additions & 2 deletions actions/setup/js/trace_graders.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -691,8 +691,12 @@ async function resolveRunCreatedAt(env = process.env, githubClient = undefined)
const repository = String(env.GITHUB_REPOSITORY || "");
const [owner, repo] = repository.split("/");
const runId = String(env.GITHUB_RUN_ID || "");
if (!github?.rest?.actions?.getWorkflowRun || !owner || !repo || !/^\d+$/.test(runId)) {
return { createdAt: "", error: "workflow run creation time is unavailable and cannot be read from Actions run metadata" };
const missing = [];
if (!github?.rest?.actions?.getWorkflowRun) missing.push("an authenticated GitHub client");
if (!owner || !repo) missing.push("GITHUB_REPOSITORY");
if (!/^\d+$/.test(runId)) missing.push("GITHUB_RUN_ID");
if (missing.length > 0) {
return { createdAt: "", error: `workflow run creation time is unavailable and cannot be read from Actions run metadata (missing ${missing.join(", ")})` };
}
try {
const response = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: Number(runId) });
Expand Down
42 changes: 42 additions & 0 deletions actions/setup/js/trace_graders.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,48 @@ describe("trace_graders", () => {
});
});

describe("operational-value run created-at acquisition", () => {
it("reports an explicit grader error instead of grading an invalid request", async () => {
const originalCore = global.core;
const originalGithub = global.github;
const originalRunId = process.env.GITHUB_RUN_ID;
const originalRepository = process.env.GITHUB_REPOSITORY;
const originalCreatedAt = process.env.GH_AW_RUN_CREATED_AT;
// @ts-ignore - minimal core stub for the graders entry point
global.core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn(), summary: { addDetails: vi.fn(), write: vi.fn() } };
// @ts-ignore - no authenticated client is available for the metadata fallback
global.github = undefined;
process.env.GITHUB_RUN_ID = "34718229379";
process.env.GITHUB_REPOSITORY = "githubnext/gh-aw-cao";
delete process.env.GH_AW_RUN_CREATED_AT;

const evaluator = "#!/usr/bin/env bash\nexit 0\n";
const digest = crypto.createHash("sha256").update(evaluator, "utf8").digest("hex");
const manifest = { version: 1, graders: [{ id: "operational-value", name: "Operational Value", source: "operational-value", enabled: true, unit: "ratio", digest }] };
const execSpec = [{ id: "operational-value", run: evaluator }];

try {
await main(Buffer.from(JSON.stringify(manifest)).toString("base64"), Buffer.from(JSON.stringify(execSpec)).toString("base64"));
const results = JSON.parse(fs.readFileSync(RESULTS_PATH, "utf8"));
const result = results.results.find(entry => entry.id === "operational-value");

expect(result.status).toBe("error");
expect(result.unit).toBe("ratio");
expect(result.error).toContain("workflow run creation time is unavailable");
expect(result.diagnostics).toEqual({ missingReason: "run created-at acquisition failed" });
} finally {
global.core = originalCore;
global.github = originalGithub;
if (originalRunId === undefined) delete process.env.GITHUB_RUN_ID;
else process.env.GITHUB_RUN_ID = originalRunId;
if (originalRepository === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = originalRepository;
if (originalCreatedAt === undefined) delete process.env.GH_AW_RUN_CREATED_AT;
else process.env.GH_AW_RUN_CREATED_AT = originalCreatedAt;
}
});
});

describe("archiveOperationalValueEvaluator", () => {
it("writes only evaluator bytes matching the frozen digest", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "operational-value-evaluator-archive-"));
Expand Down