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
40 changes: 40 additions & 0 deletions actions/setup/js/check_workflow_timestamp_api.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*/

const { getErrorMessage } = require("./error_helpers.cjs");
const { computeFrontmatterHash, extractHashFromLockFile, createGitHubFileReader } = require("./frontmatter_hash_pure.cjs");
const { getFileContent } = require("./github_api_helpers.cjs");

async function main() {
const workflowFile = process.env.GH_AW_WORKFLOW_FILE;
Expand Down Expand Up @@ -60,6 +62,42 @@ async function main() {
}
}

// Helper function to compute and compare frontmatter hashes
async function logFrontmatterHashComparison() {
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;
}

const storedHash = extractHashFromLockFile(lockFileContent);
if (!storedHash) {
core.info("No frontmatter hash found in lock file");
return;
}

// Compute hash from source .md file using GitHub API
const fileReader = createGitHubFileReader(github, owner, repo, ref);
const recomputedHash = await computeFrontmatterHash(workflowMdPath, { fileReader });

// Log hash comparison
core.info(`Frontmatter hash comparison:`);
core.info(` Lock file hash: ${storedHash}`);
core.info(` Recomputed hash: ${recomputedHash}`);

if (storedHash === recomputedHash) {
core.info(` Status: ✅ Hashes match`);
} else {
core.info(` Status: ⚠️ Hashes differ`);
}
} catch (error) {
const errorMessage = getErrorMessage(error);
core.info(`Could not compute frontmatter hash: ${errorMessage}`);
}
}

// Fetch last commits for both files
const workflowCommit = await getLastCommitForFile(workflowMdPath);
const lockCommit = await getLastCommitForFile(lockFilePath);
Expand All @@ -85,6 +123,8 @@ async function main() {
core.info(` Source last commit: ${workflowDate.toISOString()} (${workflowCommit.sha.substring(0, 7)})`);
core.info(` Lock last commit: ${lockDate.toISOString()} (${lockCommit.sha.substring(0, 7)})`);

// Log frontmatter hash comparison
await logFrontmatterHashComparison();
// Check if workflow file is newer than lock file
if (workflowDate > lockDate) {
const warningMessage = `Lock file '${lockFilePath}' is outdated! The workflow file '${workflowMdPath}' has been modified more recently. Run 'gh aw compile' to regenerate the lock file.`;
Expand Down
146 changes: 146 additions & 0 deletions actions/setup/js/check_workflow_timestamp_api.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const mockGithub = {
rest: {
repos: {
listCommits: vi.fn(),
getContent: vi.fn(),
},
},
};
Expand Down Expand Up @@ -359,4 +360,149 @@ describe("check_workflow_timestamp_api.cjs", () => {
expect(mockCore.setFailed).not.toHaveBeenCalled();
});
});

describe("frontmatter hash comparison", () => {
beforeEach(() => {
process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml";
});

it("should log frontmatter hash comparison when both files exist", async () => {
const lockFileContent = `# frontmatter-hash: abc123def456
name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "test"`;

const mdFileContent = `---
engine: copilot
---
# Test Workflow`;

mockGithub.rest.repos.listCommits
.mockResolvedValueOnce({
data: [
{
sha: "src123",
commit: {
committer: { date: "2024-01-01T12:00:00Z" },
message: "Source commit",
},
},
],
})
.mockResolvedValueOnce({
data: [
{
sha: "lock123",
commit: {
committer: { date: "2024-01-01T13:00:00Z" },
message: "Lock commit",
},
},
],
});

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"),
},
});

await main();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Frontmatter hash comparison"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Lock file hash:"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Recomputed hash:"));
});

it("should handle missing frontmatter hash in lock file", async () => {
const lockFileContent = `name: Test Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest`;

mockGithub.rest.repos.listCommits
.mockResolvedValueOnce({
data: [
{
sha: "src123",
commit: {
committer: { date: "2024-01-01T12:00:00Z" },
message: "Source commit",
},
},
],
})
.mockResolvedValueOnce({
data: [
{
sha: "lock123",
commit: {
committer: { date: "2024-01-01T13:00:00Z" },
message: "Lock commit",
},
},
],
});

mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "file",
encoding: "base64",
content: Buffer.from(lockFileContent).toString("base64"),
},
});

await main();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No frontmatter hash found"));
});

it("should handle errors during hash computation gracefully", async () => {
mockGithub.rest.repos.listCommits
.mockResolvedValueOnce({
data: [
{
sha: "src123",
commit: {
committer: { date: "2024-01-01T12:00:00Z" },
message: "Source commit",
},
},
],
})
.mockResolvedValueOnce({
data: [
{
sha: "lock123",
commit: {
committer: { date: "2024-01-01T13:00:00Z" },
message: "Lock commit",
},
},
],
});

mockGithub.rest.repos.getContent.mockRejectedValue(new Error("API error"));

await main();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not fetch content"));
expect(mockCore.setFailed).not.toHaveBeenCalled(); // Should not fail the workflow
});
});
});
56 changes: 56 additions & 0 deletions actions/setup/js/github_api_helpers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* GitHub API helper functions
* Provides common GitHub API operations with consistent error handling
*/

const { getErrorMessage } = require("./error_helpers.cjs");

/**
* Get file content from GitHub repository using the API
* @param {Object} github - GitHub API client (@actions/github)
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} path - File path within the repository
* @param {string} ref - Git reference (branch, tag, or commit SHA)
* @returns {Promise<string|null>} File content as string, or null if not found/error
*/
async function getFileContent(github, owner, repo, path, ref) {
try {
const response = await github.rest.repos.getContent({
owner,
repo,
path,
ref,
});

// Handle case where response is an array (directory listing)
if (Array.isArray(response.data)) {
core.info(`Path ${path} is a directory, not a file`);
return null;
}

// Check if this is a file (not a symlink or submodule)
if (response.data.type !== "file") {
core.info(`Path ${path} is not a file (type: ${response.data.type})`);
return null;
}

// Decode base64 content
if (response.data.encoding === "base64" && response.data.content) {
return Buffer.from(response.data.content, "base64").toString("utf8");
}

return response.data.content || null;
} catch (error) {
const errorMessage = getErrorMessage(error);
core.info(`Could not fetch content for ${path}: ${errorMessage}`);
return null;
}
}

module.exports = {
getFileContent,
};
118 changes: 118 additions & 0 deletions actions/setup/js/github_api_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, vi } from "vitest";

const mockCore = {
info: vi.fn(),
};

global.core = mockCore;

describe("github_api_helpers.cjs", () => {
let getFileContent;
let mockGithub;

beforeEach(async () => {
vi.clearAllMocks();

mockGithub = {
rest: {
repos: {
getContent: vi.fn(),
},
},
};

// Dynamically import the module
const module = await import("./github_api_helpers.cjs");
getFileContent = module.getFileContent;
});

describe("getFileContent", () => {
it("should fetch and decode base64 file content", async () => {
const fileContent = "Hello, World!";
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "file",
encoding: "base64",
content: Buffer.from(fileContent).toString("base64"),
},
});

const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");

expect(result).toBe(fileContent);
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
path: "file.txt",
ref: "main",
});
});

it("should handle non-base64 content", async () => {
const fileContent = "Plain text content";
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "file",
encoding: "utf-8",
content: fileContent,
},
});

const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");

expect(result).toBe(fileContent);
});

it("should return null for directory paths", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: [
{ name: "file1.txt", type: "file" },
{ name: "file2.txt", type: "file" },
],
});

const result = await getFileContent(mockGithub, "owner", "repo", "directory", "main");

expect(result).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is a directory"));
});

it("should return null for non-file types", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "symlink",
encoding: "base64",
content: "link-content",
},
});

const result = await getFileContent(mockGithub, "owner", "repo", "symlink.txt", "main");

expect(result).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is not a file"));
});

it("should handle API errors gracefully", async () => {
mockGithub.rest.repos.getContent.mockRejectedValueOnce(new Error("API error"));

const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");

expect(result).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not fetch content"));
});

it("should handle missing content field", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "file",
encoding: "base64",
// content field is missing
},
});

const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");

expect(result).toBeNull();
});
});
});
Loading