diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index 825e9e4bbfd..cdc538a6faa 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -6,8 +6,15 @@ * This script verifies that the stored frontmatter hash in the lock file * matches the recomputed hash from the source .md file, regardless of * commit timestamps. + * + * 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"); @@ -73,15 +80,83 @@ async function main() { core.info(`Same-repo invocation: checking out ${workflowRepo} @ ${ref}`); } - // Helper function to compute and compare frontmatter hashes - // Returns: { match: boolean, storedHash: string, recomputedHash: string } or null on error + // 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"); - return null; + core.info("Unable to fetch lock file content for hash comparison via API, trying local filesystem fallback"); + return await compareFrontmatterHashesFromLocalFiles(); } const storedHash = extractHashFromLockFile(lockFileContent); @@ -106,8 +181,10 @@ async function main() { return { match, storedHash, recomputedHash }; } catch (error) { const errorMessage = getErrorMessage(error); - core.info(`Could not compute frontmatter hash: ${errorMessage}`); - return null; + 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(); } } diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 1906e78a8ed..7a4d227e0a4 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; const mockCore = { debug: vi.fn(), @@ -48,6 +51,7 @@ describe("check_workflow_timestamp_api.cjs", () => { delete process.env.GH_AW_WORKFLOW_FILE; delete process.env.GITHUB_WORKFLOW_REF; delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_WORKSPACE; // Dynamically import the module to get fresh instance const module = await import("./check_workflow_timestamp_api.cjs"); @@ -622,4 +626,165 @@ engine: copilot expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("(default branch)")); }); }); + + describe("local filesystem fallback for cross-org reusable workflows", () => { + let tmpDir; + let workflowsDir; + // Pre-computed hash for frontmatter "engine: copilot" (used across multiple tests) + const copilotFrontmatterHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + + beforeEach(async () => { + process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; + // Simulate cross-org: workflow defined in source-org/source-repo, running in target-org/target-repo + process.env.GITHUB_WORKFLOW_REF = "source-org/source-repo/.github/workflows/test.lock.yml@v1"; + process.env.GITHUB_REPOSITORY = "target-org/target-repo"; + + // Create temp directory structure mimicking $GITHUB_WORKSPACE after checkout + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); + workflowsDir = path.join(tmpDir, ".github", "workflows"); + fs.mkdirSync(workflowsDir, { recursive: true }); + + const module = await import("./check_workflow_timestamp_api.cjs"); + main = module.main; + }); + + afterEach(() => { + delete process.env.GITHUB_WORKSPACE; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("should pass when API fails but local files have matching hashes", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + + // Write local files — hash matches "engine: copilot" frontmatter + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: copilot\n---\n# Test"); + + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fail when API fails and local files have mismatched hashes", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + + // Lock file stores copilot hash but .md file now has claude frontmatter + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Test"); + + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("outdated")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("frontmatter has changed")); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should fail when both API and local filesystem are unavailable", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + // Do not set GITHUB_WORKSPACE — local filesystem fallback also unavailable + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Unable to fetch lock file content for hash comparison via API")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKSPACE not available")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); + + it("should fail when API fails and local lock file is missing", async () => { + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + // Workspace exists but lock file not present + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Local lock file not found")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); + + it("should use API if available even in cross-repo scenario (API preferred over local files)", async () => { + const lockFileContent = `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`; + const mdFileContent = "---\nengine: copilot\n---\n# Test"; + + // API succeeds + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); + + // Local files also available (but should not be used since API succeeds) + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), "# frontmatter-hash: different-hash\nname: Test\n"); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Different"); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + // API result takes precedence (hashes match via API) + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fall back to local files when API lock file fetch succeeds but md file fetch throws", async () => { + // First API call (lock file) succeeds, second (md file) throws — triggers the catch-block fallback + const lockFileContent = `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`; + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockRejectedValueOnce(new Error("Resource not accessible by integration")); + + // Local files have matching hashes + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: copilot\n---\n# Test"); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not compute frontmatter hash via API")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should reject path traversal in GH_AW_WORKFLOW_FILE via local filesystem fallback", async () => { + // Craft a malicious workflow file name that tries to escape the workspace + process.env.GH_AW_WORKFLOW_FILE = "../../etc/passwd.lock.yml"; + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + // The path traversal is rejected before any file read + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("escapes workspace")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); + }); });