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
89 changes: 83 additions & 6 deletions actions/setup/js/check_workflow_timestamp_api.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
Comment on lines +90 to +124
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

Local filesystem fallback builds paths by joining $GITHUB_WORKSPACE with lockFilePath/workflowMdPath, which are derived from GH_AW_WORKFLOW_FILE without any validation. If GH_AW_WORKFLOW_FILE contains path traversal segments (e.g. ../), the fallback could read files outside .github/workflows (and potentially outside the workspace). Consider normalizing with path.resolve() and enforcing that the resolved paths stay under ${GITHUB_WORKSPACE}/.github/workflows, or rejecting workflowFile values containing path separators / ...

Copilot uses AI. Check for mistakes.

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);
Expand All @@ -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();
}
Comment on lines 181 to 188
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The new fallback-on-exception path in compareFrontmatterHashes() (catch block that logs Could not compute frontmatter hash via API: and then tries the local filesystem) isn’t covered by tests. Consider adding a test where the lock file fetch succeeds via API but the source .md fetch (via the GitHub fileReader) throws, and assert that the local fallback is attempted and the outcome matches the local hashes.

Copilot uses AI. Check for mistakes.
}

Expand Down
167 changes: 166 additions & 1 deletion actions/setup/js/check_workflow_timestamp_api.test.cjs
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"));
});
});
});
Loading