| name | javascript-refactoring |
| description | Split large JavaScript files into maintainable modules safely. |
JavaScript Code Refactoring Guide
Use this guide to refactor JavaScript into separate .cjs files in gh-aw.
Overview
gh-aw uses CommonJS modules (.cjs) for JavaScript in GitHub Actions workflows. These files are:
- Embedded in the Go binary using
//go:embed directives
- Bundled using a custom JavaScript bundler that inlines local
require() calls
- Executed in GitHub Actions using
actions/github-script@v8
Top-Level Script Pattern
Top-level .cjs scripts executed directly in workflows follow this pattern:
✅ Correct Pattern - Export main, but don't call it:
async function main() {
core.info("Running the script");
}
module.exports = { main };
❌ Incorrect Pattern - Don't call main in the file:
async function main() {
core.info("Running the script");
}
await main();
module.exports = { main };
Why this pattern?
- The bundler automatically injects
await main() during inline execution in GitHub Actions
- This allows the script to be both imported (for testing) and executed (in workflows)
- It provides a clean separation between module definition and execution
- It enables better testing by allowing tests to import and call
main() with mocks
Examples of top-level scripts:
create_issue.cjs - Creates GitHub issues
add_comment.cjs - Adds comments to issues/PRs
add_labels.cjs - Adds labels to issues/PRs
update_project.cjs - Updates GitHub Projects
All of these files export main but do not call it directly.
Step 1: Create the New .cjs File
Create your new file in /home/runner/work/gh-aw/gh-aw/pkg/workflow/js/ with a descriptive name:
File naming convention:
- Use snake_case for filenames (e.g.,
sanitize_content.cjs, load_agent_output.cjs)
- Use
.cjs extension (CommonJS module)
- Choose names that clearly describe the module's purpose
Example file structure:
function myFunction(input) {
return input;
}
module.exports = {
myFunction,
};
Key points:
- Include
// @ts-check for TypeScript checking
- Include
/// <reference types="@actions/github-script" /> for GitHub Actions types
- Use JSDoc comments for documentation
- Export functions using
module.exports = { ... }
- Do NOT import
@actions/core or @actions/github - these are available globally in GitHub Actions
Step 2: Add Tests
Create a test file with the same base name plus .test.cjs:
Example: pkg/workflow/js/my_module.test.cjs
import { describe, it, expect, beforeEach, vi } from "vitest";
const mockCore = {
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(),
},
};
global.core = mockCore;
describe("myFunction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle basic input", async () => {
const { myFunction } = await import("./my_module.cjs");
const result = myFunction("test input");
expect(result).toBe("expected output");
});
it("should handle edge cases", async () => {
const { myFunction } = await import("./my_module.cjs");
const result = myFunction("");
expect(result).toBe("");
});
});
Testing guidelines:
- Use vitest for testing framework
- Mock
core and github globals as needed
- Use dynamic imports (
await import()) to allow mocking before module load
- Clear mocks in
beforeEach to ensure test isolation
- Test both success cases and error handling
- Follow existing test patterns in
pkg/workflow/js/*.test.cjs files
Run tests:
make test-js
Step 3: Add Embedded Variable in Go
Add an //go:embed directive and variable in the appropriate Go file:
For shared utility functions (used by multiple scripts):
Add to pkg/workflow/js.go:
var myModuleScript string
Then add to the GetJavaScriptSources() function:
func GetJavaScriptSources() map[string]string {
return map[string]string{
"sanitize_content.cjs": sanitizeContentScript,
"sanitize_label_content.cjs": sanitizeLabelContentScript,
"sanitize_workflow_name.cjs": sanitizeWorkflowNameScript,
"load_agent_output.cjs": loadAgentOutputScript,
"staged_preview.cjs": stagedPreviewScript,
"is_truthy.cjs": isTruthyScript,
"my_module.cjs": myModuleScript,
}
}
For main scripts (top-level scripts that use bundling):
Add to pkg/workflow/scripts.go:
var myScriptSource string
Then create a getter function with bundling:
var (
myScript string
myScriptOnce sync.Once
)
func getMyScript() string {
myScriptOnce.Do(func() {
sources := GetJavaScriptSources()
bundled, err := BundleJavaScriptFromSources(myScriptSource, sources, "")
if err != nil {
scriptsLog.Printf("Bundling failed for my_script, using source as-is: %v", err)
myScript = myScriptSource
} else {
myScript = bundled
}
})
return myScript
}
Important:
- Variables in
js.go are for shared utilities that get bundled into other scripts
- Variables in
scripts.go are for main scripts that use the bundler to inline dependencies
- Use
sync.Once pattern for lazy bundling in scripts.go
- The bundler will inline all local
require() calls at runtime
Step 4: Register in the Bundler (if creating a shared utility)
If you're creating a shared utility that will be used by other scripts via require(), it's automatically available through the GetJavaScriptSources() map (Step 3).
The bundler will:
- Detect
require('./my_module.cjs') in any script
- Look up the file in the
GetJavaScriptSources() map
- Inline the required module's content
- Remove the
require() statement
- Deduplicate if the same module is required multiple times
No additional bundler registration needed - just ensure the file is in the GetJavaScriptSources() map.
Step 5: Use Local Require in Other JavaScript Files
To use your new module in other JavaScript files, use CommonJS require():
Example usage in another .cjs file:
const { myFunction } = require("./my_module.cjs");
async function main() {
const result = myFunction("some input");
core.info(`Result: ${result}`);
}
module.exports = { main };
Important: Top-level scripts should export main but NOT call it directly. The bundler injects await main() during inline execution in GitHub Actions.
Require guidelines:
- Use relative paths starting with
./
- Include the
.cjs extension
- Use destructuring to import specific functions
- The bundler will inline the required module at compile time
Multiple requires example:
const { sanitizeContent } = require("./sanitize_content.cjs");
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { generateStagedPreview } = require("./staged_preview.cjs");
Complete Example: Creating a New Utility Module
Let's walk through creating a new format_timestamp.cjs utility:
1. Create the file: pkg/workflow/js/format_timestamp.cjs
function formatTimestamp(timestamp) {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toISOString();
}
function formatTimestampHuman(timestamp) {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short'
});
}
module.exports = {
formatTimestamp,
formatTimestampHuman,
};
2. Create tests: pkg/workflow/js/format_timestamp.test.cjs
import { describe, it, expect } from "vitest";
describe("formatTimestamp", () => {
it("should format Date object to ISO 8601", async () => {
const { formatTimestamp } = await import("./format_timestamp.cjs");
const date = new Date('2024-01-15T12:30:00Z');
const result = formatTimestamp(date);
expect(result).toBe('2024-01-15T12:30:00.000Z');
});
it("should format timestamp number to ISO 8601", async () => {
const { formatTimestamp } = await import("./format_timestamp.cjs");
const timestamp = 1705323000000;
const result = formatTimestamp(timestamp);
expect(result).toBe('2024-01-15T12:30:00.000Z');
});
});
describe("formatTimestampHuman", () => {
it("should format Date object to human-readable string", async () => {
const { formatTimestampHuman } = await import("./format_timestamp.cjs");
const date = new Date('2024-01-15T12:30:00Z');
const result = formatTimestampHuman(date);
expect(result).toContain('Jan');
expect(result).toContain('15');
expect(result).toContain('2024');
});
});
3. Add to pkg/workflow/js.go:
var formatTimestampScript string
func GetJavaScriptSources() map[string]string {
return map[string]string{
"format_timestamp.cjs": formatTimestampScript,
}
}
4. Use in another script:
const { formatTimestamp } = require("./format_timestamp.cjs");
async function main() {
const now = new Date();
core.info(`Current time: ${formatTimestamp(now)}`);
}
module.exports = { main };
Note: The script exports main but does not call it. The bundler will inject await main() when the script is executed inline in GitHub Actions.
5. Build and test:
make fmt-cjs
make test-js
make test-unit
make build
Verification Checklist
Before committing your refactored code:
Common Patterns
Pattern 1: Shared Utility Function
Files like sanitize_content.cjs, load_agent_output.cjs that provide reusable functions:
- Add to
js.go with //go:embed
- Add to
GetJavaScriptSources() map
- Use via
require() in other scripts
Pattern 2: Main Workflow Script
Files like create_issue.cjs, add_labels.cjs that are top-level scripts:
- Add to
scripts.go with //go:embed as xxxSource variable
- Create bundling getter function with
sync.Once pattern
- These scripts can
require() utilities from GetJavaScriptSources()
- Must export
main function but NOT call it - the bundler injects await main() during execution
Pattern 3: Log Parser
Files like parse_claude_log.cjs that parse AI engine logs:
- Add to
js.go with //go:embed
- Add case in
GetLogParserScript() function
- Used by workflow compilation system
Troubleshooting
Issue: "required file not found in sources"
Cause: File not added to GetJavaScriptSources() map
Solution: Add the file to the map in pkg/workflow/js.go
Issue: Tests fail with "core is not defined"
Cause: Missing global mocks
Solution: Add proper mocks before importing the module:
global.core = mockCore;
global.github = mockGithub;
Issue: Bundler fails with circular dependency
Cause: File A requires File B which requires File A
Solution: Restructure to break the circular dependency, or combine the modules
Issue: Changes not reflected after rebuild
Cause: Go build cache not recognizing embedded file changes
Solution:
make clean
make build
References
- Bundler implementation:
pkg/workflow/bundler.go
- JavaScript sources registry:
pkg/workflow/js.go
- Script bundling:
pkg/workflow/scripts.go
- Existing test examples:
pkg/workflow/js/*.test.cjs
- GitHub Actions script documentation: actions/toolkit