diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index 05ec62454c..a3cd2d3f27 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -130,31 +130,67 @@ async function main() { // Check if workflow file is newer than lock file if (workflowTime > lockTime) { - // Clear case: workflow file is newer - needs recompilation - await compareFrontmatterHashes(); // Log for diagnostic purposes - 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.`; - - // Format timestamps and commits for display - const workflowTimestamp = workflowDate.toISOString(); - const lockTimestamp = lockDate.toISOString(); - - // Add summary to GitHub Step Summary - let summary = core.summary - .addRaw("### ⚠️ Workflow Lock File Warning\n\n") - .addRaw("**WARNING**: Lock file is outdated and needs to be regenerated.\n\n") - .addRaw("**Files:**\n") - .addRaw(`- Source: \`${workflowMdPath}\`\n`) - .addRaw(` - Last commit: ${workflowTimestamp}\n`) - .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`) - .addRaw(`- Lock: \`${lockFilePath}\`\n`) - .addRaw(` - Last commit: ${lockTimestamp}\n`) - .addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\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(warningMessage); + // Workflow file is newer - check frontmatter hash to determine if recompilation needed + core.info("Workflow file is newer - checking frontmatter hash"); + 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! The workflow file '${workflowMdPath}' has been modified more recently. Run 'gh aw compile' to regenerate the lock file.`; + + // Format timestamps and commits for display + const workflowTimestamp = workflowDate.toISOString(); + const lockTimestamp = lockDate.toISOString(); + + // Add summary to GitHub Step Summary + let summary = core.summary + .addRaw("### ⚠️ Workflow Lock File Warning\n\n") + .addRaw("**WARNING**: Lock file is outdated and needs to be regenerated.\n\n") + .addRaw("**Files:**\n") + .addRaw(`- Source: \`${workflowMdPath}\`\n`) + .addRaw(` - Last commit: ${workflowTimestamp}\n`) + .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`) + .addRaw(`- Lock: \`${lockFilePath}\`\n`) + .addRaw(` - Last commit: ${lockTimestamp}\n`) + .addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\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(warningMessage); + } else if (hashComparison.match) { + // Hashes match - lock file is up to date despite timestamp difference + core.info("✅ Lock file is up to date (frontmatter hashes match despite timestamp difference)"); + } 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.`; + + // Format timestamps and commits for display + const workflowTimestamp = workflowDate.toISOString(); + const lockTimestamp = lockDate.toISOString(); + + // Add summary to GitHub Step Summary + 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(` - Last commit: ${workflowTimestamp}\n`) + .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`) + .addRaw(` - Frontmatter hash: \`${hashComparison.recomputedHash.substring(0, 12)}...\`\n`) + .addRaw(`- Lock: \`${lockFilePath}\`\n`) + .addRaw(` - Last commit: ${lockTimestamp}\n`) + .addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\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(warningMessage); + } } else if (workflowCommit.sha === lockCommit.sha) { // Same commit - definitely up to date core.info("✅ Lock file is up to date (same commit)"); @@ -173,7 +209,7 @@ async function main() { core.info("✅ Lock file is up to date (hashes match)"); } else { // Hashes differ - lock file needs recompilation - const warningMessage = `Lock file '${lockFilePath}' is outdated! Frontmatter hash mismatch detected. Run 'gh aw compile' to regenerate the lock file.`; + const warningMessage = `Lock file '${lockFilePath}' is outdated! The workflow file '${workflowMdPath}' frontmatter has changed. Run 'gh aw compile' to regenerate the lock file.`; // Format timestamps and commits for display const workflowTimestamp = workflowDate.toISOString(); diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 60d06b5d87..0d8b885567 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -196,7 +196,24 @@ describe("check_workflow_timestamp_api.cjs", () => { process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; }); - it("should fail when source file is newer than lock file", async () => { + it("should fail when source file is newer than lock file and hashes differ", async () => { + const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${storedHash} +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "test"`; + + // Different frontmatter - will produce different hash + const mdFileContent = `--- +engine: claude +model: claude-sonnet-4 +--- +# Test Workflow`; + mockGithub.rest.repos.listCommits .mockResolvedValueOnce({ data: [ @@ -221,17 +238,48 @@ describe("check_workflow_timestamp_api.cjs", () => { ], }); // Lock file - older + 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.setFailed).toHaveBeenCalledWith(expect.stringContaining("Lock file")); expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("frontmatter has changed")); expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("gh aw compile")); expect(mockCore.summary.addRaw).toHaveBeenCalled(); expect(mockCore.summary.write).toHaveBeenCalled(); }); - it("should include file paths in failure message", async () => { - process.env.GH_AW_WORKFLOW_FILE = "my-workflow.lock.yml"; + it("should pass when source file is newer than lock file but hashes match", async () => { + // Hash for frontmatter "engine: copilot" + const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${validHash} +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({ @@ -257,48 +305,37 @@ describe("check_workflow_timestamp_api.cjs", () => { ], }); // Lock file - older - await main(); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringMatching(/my-workflow\.lock\.yml.*outdated/)); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringMatching(/my-workflow\.md/)); - }); - - it("should add step summary with warning details", async () => { - mockGithub.rest.repos.listCommits + mockGithub.rest.repos.getContent .mockResolvedValueOnce({ - data: [ - { - sha: "src123abc", - commit: { - committer: { date: "2024-01-01T13:00:00Z" }, - message: "Source commit", - }, - }, - ], - }) // Source file - newer + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) .mockResolvedValueOnce({ - data: [ - { - sha: "lock456def", - commit: { - committer: { date: "2024-01-01T12:00:00Z" }, - message: "Lock commit", - }, - }, - ], - }); // Lock file - older + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); await main(); - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Workflow Lock File Warning")); - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("WARNING")); - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("gh aw compile")); - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("src123a")); // Short SHA - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("lock456")); // Short SHA - expect(mockCore.summary.write).toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("hashes match")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.summary.addRaw).not.toHaveBeenCalled(); }); - it("should include timestamps in summary", async () => { + it("should fail when source file is newer and hash check cannot be performed", async () => { + const lockFileContent = `name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest`; + mockGithub.rest.repos.listCommits .mockResolvedValueOnce({ data: [ @@ -323,57 +360,28 @@ describe("check_workflow_timestamp_api.cjs", () => { ], }); // Lock file - older - await main(); - - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("2024-01-01T13:00:00")); // Source timestamp - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("2024-01-01T12:00:00")); // Lock timestamp - expect(mockCore.summary.write).toHaveBeenCalled(); - }); - }); - - describe("error handling", () => { - beforeEach(() => { - process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; - }); - - it("should handle API errors gracefully", async () => { - mockGithub.rest.repos.listCommits.mockRejectedValue(new Error("API error")); - - await main(); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not fetch commit")); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping timestamp check")); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should handle missing committer date", async () => { - mockGithub.rest.repos.listCommits.mockResolvedValueOnce({ - data: [ - { - sha: "src123", - commit: { - committer: {}, // Missing date - message: "Source 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("Skipping timestamp check")); - expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Lock file")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); }); - }); - describe("frontmatter hash comparison", () => { - beforeEach(() => { - process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; - }); + it("should include file paths in failure message when hashes differ", async () => { + process.env.GH_AW_WORKFLOW_FILE = "my-workflow.lock.yml"; - it("should log frontmatter hash comparison when both files exist", async () => { - const validHash = "cdb5fdf551a14f93f6a8bb32b4f8ee5a6e93a8075052ecd915180be7fbc168ca"; - const lockFileContent = `# frontmatter-hash: ${validHash} + const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${storedHash} name: Test Workflow on: push jobs: @@ -382,8 +390,9 @@ jobs: steps: - run: echo "test"`; + // Different frontmatter const mdFileContent = `--- -engine: copilot +engine: claude --- # Test Workflow`; @@ -393,23 +402,23 @@ engine: copilot { sha: "src123", commit: { - committer: { date: "2024-01-01T13:00:00Z" }, // Source is newer + committer: { date: "2024-01-01T13:00:00Z" }, message: "Source commit", }, }, ], - }) + }) // Source file - newer .mockResolvedValueOnce({ data: [ { sha: "lock123", commit: { - committer: { date: "2024-01-01T12:00:00Z" }, // Lock is older + committer: { date: "2024-01-01T12:00:00Z" }, message: "Lock commit", }, }, ], - }); + }); // Lock file - older mockGithub.rest.repos.getContent .mockResolvedValueOnce({ @@ -429,63 +438,95 @@ engine: copilot 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:")); + const failMessage = mockCore.setFailed.mock.calls[0][0]; + expect(failMessage).toMatch(/my-workflow\.lock\.yml/); + expect(failMessage).toMatch(/my-workflow\.md/); + expect(failMessage).toMatch(/outdated/); }); - it("should handle missing frontmatter hash in lock file", async () => { - const lockFileContent = `name: Test Workflow + it("should add step summary with warning details when hashes differ", async () => { + const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${storedHash} +name: Test Workflow on: push jobs: test: - runs-on: ubuntu-latest`; + runs-on: ubuntu-latest + steps: + - run: echo "test"`; + + // Different frontmatter - will produce different hash + const mdFileContent = `--- +engine: claude +--- +# Test Workflow`; mockGithub.rest.repos.listCommits .mockResolvedValueOnce({ data: [ { - sha: "src123", + sha: "src123abc", commit: { - committer: { date: "2024-01-01T13:00:00Z" }, // Source is newer + committer: { date: "2024-01-01T13:00:00Z" }, message: "Source commit", }, }, ], - }) + }) // Source file - newer .mockResolvedValueOnce({ data: [ { - sha: "lock123", + sha: "lock456def", commit: { - committer: { date: "2024-01-01T12:00:00Z" }, // Lock is older + committer: { date: "2024-01-01T12:00:00Z" }, message: "Lock commit", }, }, ], - }); + }); // Lock file - older - mockGithub.rest.repos.getContent.mockResolvedValueOnce({ - data: { - type: "file", - encoding: "base64", - content: Buffer.from(lockFileContent).toString("base64"), - }, - }); + 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("No frontmatter hash found")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Workflow Lock File Warning")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("WARNING")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("gh aw compile")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("src123a")); // Short SHA + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("lock456")); // Short SHA + expect(mockCore.summary.write).toHaveBeenCalled(); }); - it("should handle errors during hash computation gracefully", async () => { - const validHash = "cdb5fdf551a14f93f6a8bb32b4f8ee5a6e93a8075052ecd915180be7fbc168ca"; - const lockFileContent = `# frontmatter-hash: ${validHash} + it("should include timestamps in summary when hashes differ", async () => { + const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${storedHash} name: Test Workflow on: push jobs: test: - runs-on: ubuntu-latest`; + runs-on: ubuntu-latest + steps: + - run: echo "test"`; + + // Different frontmatter + const mdFileContent = `--- +engine: claude +--- +# Test Workflow`; mockGithub.rest.repos.listCommits .mockResolvedValueOnce({ @@ -493,23 +534,23 @@ jobs: { sha: "src123", commit: { - committer: { date: "2024-01-01T13:00:00Z" }, // Source is newer + committer: { date: "2024-01-01T13:00:00Z" }, message: "Source commit", }, }, ], - }) + }) // Source file - newer .mockResolvedValueOnce({ data: [ { sha: "lock123", commit: { - committer: { date: "2024-01-01T12:00:00Z" }, // Lock is older + committer: { date: "2024-01-01T12:00:00Z" }, message: "Lock commit", }, }, ], - }); + }); // Lock file - older mockGithub.rest.repos.getContent .mockResolvedValueOnce({ @@ -519,12 +560,54 @@ jobs: content: Buffer.from(lockFileContent).toString("base64"), }, }) - .mockRejectedValueOnce(new Error("Failed to fetch workflow file")); + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); + + await main(); + + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("2024-01-01T13:00:00")); // Source timestamp + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("2024-01-01T12:00:00")); // Lock timestamp + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + beforeEach(() => { + process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; + }); + + it("should handle API errors gracefully", async () => { + mockGithub.rest.repos.listCommits.mockRejectedValue(new Error("API error")); await main(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not compute frontmatter hash")); - expect(mockCore.setFailed).toHaveBeenCalled(); // Should fail because timestamp check failed + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not fetch commit")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping timestamp check")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should handle missing committer date", async () => { + mockGithub.rest.repos.listCommits.mockResolvedValueOnce({ + data: [ + { + sha: "src123", + commit: { + committer: {}, // Missing date + message: "Source commit", + }, + }, + ], + }); + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping timestamp check")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); }); }); @@ -660,7 +743,7 @@ model: claude-sonnet-4 expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Timestamps are equal")); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("⚠️ Hashes differ")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Frontmatter hash mismatch")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("frontmatter has changed")); expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("frontmatter hash mismatch")); });