-
Notifications
You must be signed in to change notification settings - Fork 355
Expand file tree
/
Copy pathcheck_workflow_timestamp_api.cjs
More file actions
319 lines (276 loc) · 15 KB
/
check_workflow_timestamp_api.cjs
File metadata and controls
319 lines (276 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
// @ts-check
/// <reference types="@actions/github-script" />
/**
* Check for a stale workflow lock file using frontmatter hash comparison.
* This script verifies that the stored frontmatter hash in the lock file
* matches the recomputed hash from the source .md file, detecting cases where
* the workflow was edited without recompiling the lock file. It does not
* provide tamper protection — use code review to guard against intentional
* modifications.
*
* Supports both same-repo and cross-repo reusable workflow scenarios:
* - Primary: GitHub API (uses GITHUB_WORKFLOW_REF to identify source repo)
* - Fallback: local filesystem ($GITHUB_WORKSPACE) when API access is unavailable
* (e.g., cross-org reusable workflows where the caller token can't read the source repo)
*/
const fs = require("fs");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");
const { extractHashFromLockFile, computeFrontmatterHash, createGitHubFileReader } = require("./frontmatter_hash_pure.cjs");
const { getFileContent } = require("./github_api_helpers.cjs");
const { ERR_CONFIG } = require("./error_codes.cjs");
// Matches GitHub workflow ref paths of the form "owner/repo/...[@ref]"
// and captures: [1] owner, [2] repo, [3] optional ref
const GITHUB_REPO_PATH_RE = /^([^/]+)\/([^/]+)\/.+?(?:@(.+))?$/;
async function main() {
const workflowFile = process.env.GH_AW_WORKFLOW_FILE;
if (!workflowFile) {
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_FILE not available.`);
return;
}
// Construct file paths
const workflowBasename = workflowFile.replace(".lock.yml", "");
const workflowMdPath = `.github/workflows/${workflowBasename}.md`;
const lockFilePath = `.github/workflows/${workflowFile}`;
core.info(`Checking for stale lock file using frontmatter hash:`);
core.info(` Source: ${workflowMdPath}`);
core.info(` Lock file: ${lockFilePath}`);
// Determine workflow source repository from the workflow ref for cross-repo support.
//
// For cross-repo reusable workflow invocations, both GITHUB_WORKFLOW_REF (env var) and
// ${{ github.workflow_ref }} (injected as GH_AW_CONTEXT_WORKFLOW_REF) resolve to the
// TOP-LEVEL CALLER's workflow, not the reusable workflow being executed. This causes the
// script to look for lock files in the wrong repository when used alone.
//
// The reliable fix is the referenced_workflows API lookup below, which identifies the
// callee's repo/ref from the caller's run object. GH_AW_CONTEXT_WORKFLOW_REF is only
// used as a fallback when the API lookup is unavailable or finds no matching entry.
//
// Refs: https://github.com/github/gh-aw/issues/23935
// https://github.com/github/gh-aw/issues/24422
const workflowEnvRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF || "";
const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
// Parse owner, repo, and optional ref from GITHUB_WORKFLOW_REF as a single unit so that
// repo and ref are always consistent with each other. The @ref segment may be absent (e.g.
// when the env var was set without a ref suffix), so treat it as optional.
const workflowRefMatch = workflowEnvRef.match(GITHUB_REPO_PATH_RE);
// Use the workflow source repo if parseable, otherwise fall back to context.repo
let owner = workflowRefMatch ? workflowRefMatch[1] : context.repo.owner;
let repo = workflowRefMatch ? workflowRefMatch[2] : context.repo.repo;
let workflowRepo = `${owner}/${repo}`;
// Determine ref in a way that keeps repo+ref consistent:
// - If a ref is present in GITHUB_WORKFLOW_REF, use it.
// - For same-repo runs without a parsed ref, fall back to context.sha (existing behavior).
// - For cross-repo runs without a parsed ref, omit ref so the API uses the default branch
// (avoids mixing source repo owner/name with a SHA that only exists in the triggering repo).
let ref;
if (workflowRefMatch && workflowRefMatch[3]) {
ref = workflowRefMatch[3];
} else if (workflowRepo === currentRepo) {
ref = context.sha;
} else {
ref = undefined;
}
// Always attempt referenced_workflows API lookup to resolve the callee repo/ref.
// This handles cross-repo reusable workflow scenarios reliably.
//
// IMPORTANT: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger
// event (e.g., "push", "issues"), NOT "workflow_call". We therefore cannot rely on event
// name to detect cross-repo scenarios and must always attempt the referenced_workflows
// API lookup.
//
// Similarly, GH_AW_CONTEXT_WORKFLOW_REF (${{ github.workflow_ref }}) resolves to the
// CALLER's workflow ref, not the callee's. It is used as a fallback only when the API
// lookup does not find a matching entry.
//
// Resolution priority:
// 1. referenced_workflows[].sha — immutable commit SHA from the callee repo (most precise).
// 2. referenced_workflows[].ref — branch/tag ref from the callee (fallback when sha absent).
// 3. GH_AW_CONTEXT_WORKFLOW_REF — injected by the compiler; used when the API is unavailable
// or when no matching entry is found in referenced_workflows.
//
// When a reusable workflow is called from another repo, GITHUB_RUN_ID and GITHUB_REPOSITORY
// are set to the caller's run ID and repo. The caller's run object includes a
// referenced_workflows array listing the callee's exact path, sha, and ref.
//
// GITHUB_RUN_ID is always set in GitHub Actions environments.
// context.runId is a fallback for environments where env vars are absent.
//
// Refs: https://github.com/github/gh-aw/issues/24422
const runId = parseInt(process.env.GITHUB_RUN_ID || String(context.runId), 10);
if (Number.isFinite(runId)) {
const [runOwner, runRepo] = currentRepo.split("/");
try {
core.info(`Checking for cross-repo callee via referenced_workflows API (run ${runId})`);
const runResponse = await github.rest.actions.getWorkflowRun({
owner: runOwner,
repo: runRepo,
run_id: runId,
});
const referencedWorkflows = runResponse.data.referenced_workflows || [];
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in run`);
// Find the entry whose path matches the current workflow file.
// Path format: "org/repo/.github/workflows/file.lock.yml@ref"
// Using replace to robustly strip the optional @ref suffix before matching.
const matchingEntry = referencedWorkflows.find(wf => {
const pathWithoutRef = wf.path.replace(/@.*$/, "");
return pathWithoutRef.endsWith(`/.github/workflows/${workflowFile}`);
});
if (matchingEntry) {
const pathMatch = matchingEntry.path.match(GITHUB_REPO_PATH_RE);
if (pathMatch) {
owner = pathMatch[1];
repo = pathMatch[2];
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
ref = matchingEntry.sha || matchingEntry.ref || pathMatch[3];
workflowRepo = `${owner}/${repo}`;
core.info(`Resolved callee repo from referenced_workflows: ${owner}/${repo} @ ${ref || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
}
} else {
core.info(`No matching entry in referenced_workflows for "${workflowFile}", falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
}
} catch (error) {
core.info(`Could not fetch referenced_workflows from API: ${getErrorMessage(error)}, falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
}
} else {
core.info("Run ID is unavailable or invalid, falling back to GH_AW_CONTEXT_WORKFLOW_REF");
}
const contextWorkflowRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF;
core.info(`GITHUB_WORKFLOW_REF: ${process.env.GITHUB_WORKFLOW_REF || "(not set)"}`);
if (contextWorkflowRef) {
core.info(`GH_AW_CONTEXT_WORKFLOW_REF: ${contextWorkflowRef} (used for source repo resolution)`);
}
core.info(`GITHUB_REPOSITORY: ${currentRepo}`);
core.info(`Resolved source repo: ${owner}/${repo} @ ${ref || "(default branch)"}`);
if (workflowRepo !== currentRepo) {
core.info(`Cross-repo invocation detected: workflow source is "${workflowRepo}", current repo is "${currentRepo}"`);
} else {
core.info(`Same-repo invocation: checking out ${workflowRepo} @ ${ref}`);
}
// Fallback: compare frontmatter hashes using local filesystem files.
// Used when the GitHub API is inaccessible (e.g., cross-org reusable workflow where
// the caller's GITHUB_TOKEN cannot read the source repo).
// The activation job's "Checkout .github and .agents folders" step always runs before
// this check and places the workflow source files in $GITHUB_WORKSPACE, so the local
// files are always available at this point.
async function compareFrontmatterHashesFromLocalFiles() {
const workspace = process.env.GITHUB_WORKSPACE;
if (!workspace) {
core.info("GITHUB_WORKSPACE not available for local filesystem fallback");
return null;
}
// Resolve and validate both paths to prevent path traversal attacks.
// GH_AW_WORKFLOW_FILE could theoretically contain "../" segments; reject any
// resolved path that escapes the workspace/.github/workflows directory.
const allowedDir = path.resolve(workspace, ".github", "workflows");
const localLockFilePath = path.resolve(workspace, lockFilePath);
const localMdFilePath = path.resolve(workspace, workflowMdPath);
if (!localLockFilePath.startsWith(allowedDir + path.sep) && localLockFilePath !== allowedDir) {
core.info(`Resolved lock file path escapes workspace: ${localLockFilePath}`);
return null;
}
if (!localMdFilePath.startsWith(allowedDir + path.sep) && localMdFilePath !== allowedDir) {
core.info(`Resolved source file path escapes workspace: ${localMdFilePath}`);
return null;
}
core.info(`Attempting local filesystem fallback for hash comparison:`);
core.info(` Lock file: ${localLockFilePath}`);
core.info(` Source: ${localMdFilePath}`);
if (!fs.existsSync(localLockFilePath)) {
core.info(`Local lock file not found: ${localLockFilePath}`);
return null;
}
if (!fs.existsSync(localMdFilePath)) {
core.info(`Local source file not found: ${localMdFilePath}`);
return null;
}
try {
const localLockContent = fs.readFileSync(localLockFilePath, "utf8");
const storedHash = extractHashFromLockFile(localLockContent);
if (!storedHash) {
core.info("No frontmatter hash found in local lock file");
return null;
}
// computeFrontmatterHash uses the local filesystem reader by default
const recomputedHash = await computeFrontmatterHash(localMdFilePath);
const match = storedHash === recomputedHash;
core.info(`Frontmatter hash comparison (local filesystem fallback):`);
core.info(` Lock file hash: ${storedHash}`);
core.info(` Recomputed hash: ${recomputedHash}`);
core.info(` Status: ${match ? "✅ Hashes match" : "⚠️ Hashes differ"}`);
return { match, storedHash, recomputedHash };
} catch (error) {
core.info(`Could not compute frontmatter hash from local files: ${getErrorMessage(error)}`);
return null;
}
}
// Primary: compare frontmatter hashes using the GitHub API.
// Falls back to local filesystem if the API is inaccessible.
async function compareFrontmatterHashes() {
try {
// Fetch lock file content to extract stored hash
const lockFileContent = await getFileContent(github, owner, repo, lockFilePath, ref);
if (!lockFileContent) {
core.info("Unable to fetch lock file content for hash comparison via API, trying local filesystem fallback");
return await compareFrontmatterHashesFromLocalFiles();
}
const storedHash = extractHashFromLockFile(lockFileContent);
if (!storedHash) {
core.info("No frontmatter hash found in lock file");
return null;
}
// Compute hash using pure JavaScript implementation
// Create a GitHub file reader for fetching workflow files via API
const fileReader = createGitHubFileReader(github, owner, repo, ref);
const recomputedHash = await computeFrontmatterHash(workflowMdPath, { fileReader });
const match = storedHash === recomputedHash;
// Log hash comparison
core.info(`Frontmatter hash comparison:`);
core.info(` Lock file hash: ${storedHash}`);
core.info(` Recomputed hash: ${recomputedHash}`);
core.info(` Status: ${match ? "✅ Hashes match" : "⚠️ Hashes differ"}`);
return { match, storedHash, recomputedHash };
} catch (error) {
const errorMessage = getErrorMessage(error);
core.info(`Could not compute frontmatter hash via API: ${errorMessage}`);
// Fall back to local filesystem when API is unavailable
// (e.g., cross-org reusable workflow where caller token lacks source repo access)
return await compareFrontmatterHashesFromLocalFiles();
}
}
const hashComparison = await compareFrontmatterHashes();
if (!hashComparison) {
// Could not compute hash - be conservative and fail
core.warning("Could not compare frontmatter hashes - assuming lock file is outdated");
const warningMessage = `Lock file '${lockFilePath}' is outdated or unverifiable! Could not verify frontmatter hash for '${workflowMdPath}'. Run 'gh aw compile' to regenerate the lock file.`;
let summary = core.summary
.addRaw("### ⚠️ Workflow Lock File Warning\n\n")
.addRaw("**WARNING**: Could not verify whether lock file is up to date. Frontmatter hash check failed.\n\n")
.addRaw("**Files:**\n")
.addRaw(`- Source: \`${workflowMdPath}\`\n`)
.addRaw(`- Lock: \`${lockFilePath}\`\n\n`)
.addRaw("**Action Required:** Run `gh aw compile` to regenerate the lock file.\n\n");
await summary.write();
core.setFailed(`${ERR_CONFIG}: ${warningMessage}`);
} else if (hashComparison.match) {
// Hashes match - lock file is up to date
core.info("✅ Lock file is up to date (hashes match)");
} else {
// Hashes differ - lock file needs recompilation
const warningMessage = `Lock file '${lockFilePath}' is outdated! The workflow file '${workflowMdPath}' frontmatter has changed. Run 'gh aw compile' to regenerate the lock file.`;
let summary = core.summary
.addRaw("### ⚠️ Workflow Lock File Warning\n\n")
.addRaw("**WARNING**: Lock file is outdated (frontmatter hash mismatch).\n\n")
.addRaw("**Files:**\n")
.addRaw(`- Source: \`${workflowMdPath}\`\n`)
.addRaw(` - Frontmatter hash: \`${hashComparison.recomputedHash.substring(0, 12)}...\`\n`)
.addRaw(`- Lock: \`${lockFilePath}\`\n`)
.addRaw(` - Stored hash: \`${hashComparison.storedHash.substring(0, 12)}...\`\n\n`)
.addRaw("**Action Required:** Run `gh aw compile` to regenerate the lock file.\n\n");
await summary.write();
// Fail the step to prevent workflow from running with outdated configuration
core.setFailed(`${ERR_CONFIG}: ${warningMessage}`);
}
}
module.exports = { main };