Skip to content
Merged
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
21 changes: 11 additions & 10 deletions actions/setup/js/workflow_job_durations.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// @ts-check
/// <reference types="@actions/github-script" />

const JOB_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;

/** @param {unknown} value */
const isPlainObject = value => !!value && typeof value === "object" && !Array.isArray(value);

/**
* Only uniquely matched completed jobs can supply a dependency's duration.
* Matrix and reusable-workflow groups do not have a single job duration.
Expand All @@ -13,13 +18,7 @@ async function collectJobDurations({ githubClient = global.github, runContext =
if (env.GH_AW_JOB_DURATIONS) {
try {
const cached = JSON.parse(env.GH_AW_JOB_DURATIONS);
if (
cached &&
typeof cached === "object" &&
!Array.isArray(cached) &&
Object.entries(cached).every(([id, duration]) => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(id) && typeof duration === "number" && Number.isSafeInteger(duration) && duration >= 0)
)
return cached;
if (isPlainObject(cached) && Object.entries(cached).every(([id, duration]) => JOB_ID_PATTERN.test(id) && typeof duration === "number" && Number.isSafeInteger(duration) && duration >= 0)) return cached;
warn("Unified session: invalid cached job durations; reading Actions job timing");
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
Expand All @@ -38,7 +37,7 @@ async function collectJobDurations({ githubClient = global.github, runContext =
warn("Unified session: job durations unavailable (invalid job metadata)");
return {};
}
if (!needs || typeof needs !== "object" || Array.isArray(needs) || !names || typeof names !== "object" || Array.isArray(names) || !eligible || typeof eligible !== "object" || Array.isArray(eligible)) {
if (!isPlainObject(needs) || !isPlainObject(names) || !isPlainObject(eligible)) {
warn("Unified session: job durations unavailable (invalid job metadata)");
return {};
}
Expand All @@ -62,18 +61,20 @@ async function collectJobDurations({ githubClient = global.github, runContext =
jobs.push(...response.data.jobs);
if (response.data.jobs.length < 100) break;
}
} catch (error) {
} catch {
// API errors can include request details; never log the response or metadata.
warn("Unified session: job durations unavailable (Actions API request failed)");
return {};
}
/** @type {Record<string, number>} */
const durations = Object.create(null);
const knownIds = [...new Set([...Object.keys(needs), ...Object.keys(names)])];
/** @param {string} id */
const displayName = id => (typeof names[id] === "string" && names[id] ? names[id] : id);
/** @param {string} name @param {string} display */
const matchesName = (name, display) => name === display || name.startsWith(`${display} (`) || name.startsWith(`${display} / `);
for (const [jobId, dependency] of Object.entries(needs)) {
if (eligible[jobId] !== true || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(jobId) || !["success", "failure", "cancelled"].includes(dependency?.result)) continue;
if (eligible[jobId] !== true || !JOB_ID_PATTERN.test(jobId) || !["success", "failure", "cancelled"].includes(dependency?.result)) continue;
const matches = jobs.filter(job => job.name === displayName(jobId) && (job.run_attempt === undefined || job.run_attempt === attempt));
const ambiguous = knownIds.some(id => id !== jobId && matchesName(displayName(jobId), displayName(id)));
if (matches.length !== 1 || ambiguous) {
Expand Down
37 changes: 37 additions & 0 deletions actions/setup/js/workflow_job_durations.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,41 @@ describe("workflow job durations", () => {
expect(options.githubClient.rest.actions.listJobsForWorkflowRunAttempt).not.toHaveBeenCalled();
});
});

it("warns and falls back to the API when cached durations are invalid", async () => {
for (const cached of ['{"agent":-1}', '{"bad id":1}', "[1]", "null", "not json"]) {
const options = fixture();
options.env.GH_AW_JOB_DURATIONS = cached;
expect(await collectJobDurations(options)).toEqual({ agent: 2000 });
expect(options.warn).toHaveBeenCalledWith("Unified session: invalid cached job durations; reading Actions job timing");
}
});

it("returns valid cached durations without an API request", async () => {
const options = fixture();
options.env.GH_AW_JOB_DURATIONS = '{"agent":5}';
expect(await collectJobDurations(options)).toEqual({ agent: 5 });
expect(options.githubClient.rest.actions.listJobsForWorkflowRunAttempt).not.toHaveBeenCalled();
});

it("rejects non-object job metadata", async () => {
for (const key of ["GH_AW_JOB_RESULTS", "GH_AW_JOB_DISPLAY_NAMES", "GH_AW_JOB_TIMING_ELIGIBLE"]) {
const options = fixture();
options.env[key] = "[]";
expect(await collectJobDurations(options)).toEqual({});
expect(options.warn).toHaveBeenCalledWith("Unified session: job durations unavailable (invalid job metadata)");
}
});

it("warns when the run context or attempt is missing", async () => {
const options = fixture();
options.env.GITHUB_RUN_ATTEMPT = "0";
expect(await collectJobDurations(options)).toEqual({});
expect(options.warn).toHaveBeenCalledWith("Unified session: job durations unavailable (missing Actions run context)");
});

it("skips jobs with invalid IDs or non-terminal results", async () => {
const options = fixture({ "bad id": { result: "success" }, agent: { result: "skipped" } }, { agent: "Agent" });
expect(await collectJobDurations(options)).toEqual({});
Comment on lines +195 to +196
});
});
Loading