Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
19 changes: 19 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2690,6 +2690,25 @@
}
}
]
},
"restore-memory": {
"type": "object",
"description": "Inject read-only memory restore steps into this custom job. Each enabled memory type must already be configured in the workflow's tools: section. No write-back or commit steps are ever emitted.",
"additionalProperties": false,
"properties": {
"cache-memory": {
"type": "boolean",
"description": "Restore cache-memory file share data (read-only). Requires cache-memory to be configured in tools:."
},
"repo-memory": {
"type": "boolean",
"description": "Clone repo-memory branch for reading (read-only). Requires repo-memory to be configured in tools:."
},
"comment-memory": {
"type": "boolean",
"description": "Prepare comment-memory files for reading (read-only). Requires comment-memory to be configured in tools:."
}
}
}
}
}
Expand Down
222 changes: 222 additions & 0 deletions pkg/workflow/compiler_custom_job_memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package workflow

import (
"fmt"
"strings"
)

// restoreMemoryConfig holds the parsed restore-memory configuration for a custom job.
// When a memory type is true, the compiler injects read-only restore steps for that store.
// No write-back or commit steps are ever emitted for restore-memory.
type restoreMemoryConfig struct {
CacheMemory bool
RepoMemory bool
CommentMemory bool
}

// extractRestoreMemoryConfig parses the restore-memory field from a custom job config map.
// Returns nil if the field is absent or all values are false.
func extractRestoreMemoryConfig(configMap map[string]any, jobName string) (*restoreMemoryConfig, error) {
rawVal, hasField := configMap["restore-memory"]
if !hasField {
return nil, nil
}

rmMap, ok := rawVal.(map[string]any)
if !ok {
return nil, fmt.Errorf("jobs.%s.restore-memory must be an object with memory-type keys (cache-memory, repo-memory, comment-memory)", jobName)
}

cfg := &restoreMemoryConfig{}

if v, ok := rmMap["cache-memory"]; ok {
if b, ok := v.(bool); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Non-bool values for cache-memory, repo-memory, or comment-memory are silently dropped — there's no error returned when a user writes cache-memory: 1 or cache-memory: yes (which YAML parsers may resolve to non-bool types). The value is simply ignored, leading to confusing behaviour where the requested restore never appears.

Suggestion

Return an error on type mismatch rather than silently ignoring the value:

if v, ok := rmMap["cache-memory"]; ok {
    b, ok := v.(bool)
    if !ok {
        return nil, fmt.Errorf("jobs.%s.restore-memory.cache-memory: expected bool, got %T", jobName, v)
    }
    cfg.CacheMemory = b
}

Add a test case such as cache-memory: 1 to TestExtractRestoreMemoryConfig to document the expectation.

@copilot please address this.

cfg.CacheMemory = b
}
}
if v, ok := rmMap["repo-memory"]; ok {
if b, ok := v.(bool); ok {
cfg.RepoMemory = b
}
}
if v, ok := rmMap["comment-memory"]; ok {
if b, ok := v.(bool); ok {
cfg.CommentMemory = b
}
}

if !cfg.CacheMemory && !cfg.RepoMemory && !cfg.CommentMemory {
return nil, nil
}
return cfg, nil
}

// validateRestoreMemoryConfig ensures that every requested memory type is actually
// configured in the workflow's tools: section. Custom jobs cannot declare memory
// stores independently; they can only restore from stores the workflow already uses.
func validateRestoreMemoryConfig(cfg *restoreMemoryConfig, data *WorkflowData, jobName string) error {
if cfg == nil {
return nil
}
if cfg.CacheMemory && (data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0) {
return fmt.Errorf("jobs.%s.restore-memory.cache-memory: cache-memory must be configured in tools: to use restore-memory", jobName)
}
if cfg.RepoMemory && (data.RepoMemoryConfig == nil || len(data.RepoMemoryConfig.Memories) == 0) {
return fmt.Errorf("jobs.%s.restore-memory.repo-memory: repo-memory must be configured in tools: to use restore-memory", jobName)
}
if cfg.CommentMemory && (data.SafeOutputs == nil || data.SafeOutputs.CommentMemory == nil) {
return fmt.Errorf("jobs.%s.restore-memory.comment-memory: comment-memory must be configured in tools: to use restore-memory", jobName)
}
return nil
}

// buildRestoreMemorySteps returns two slices:
// - setupLines: gh-aw checkout + setup steps (only when repo-memory or comment-memory
// is requested, since those need scripts from the setup action)
// - memoryLines: the actual memory restore/clone/prepare steps
//
// No write-back steps are ever emitted; all injected steps are read-only.
func (c *Compiler) buildRestoreMemorySteps(cfg *restoreMemoryConfig, data *WorkflowData) (setupLines []string, memoryLines []string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent no-op for non-bool memory values — should return a validation error

If the user writes cache-memory: "true" or any non-boolean YAML value, the v.(bool) type assertion fails silently and the field is ignored (left false). The schema says "type": "boolean" so the YAML parser may already reject this, but at the Go level this pattern allows a misconfigured workflow to compile without any indication that the requested memory restore was skipped.

Consider returning an error for unexpected non-bool values to give the user an actionable message:

if v, ok := rmMap["cache-memory"]; ok {
    switch b := v.(type) {
    case bool:
        cfg.CacheMemory = b
    default:
        return nil, fmt.Errorf("jobs.%s.restore-memory.cache-memory must be a boolean, got %T", jobName, v)
    }
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Superseded by the API simplification: restore-memory now accepts only a top-level bool. The extractRestoreMemoryConfig function already returns fmt.Errorf("jobs.%s.restore-memory must be a boolean (true or false)", jobName) for any non-bool value. The per-type rmMap no longer exists, so silent no-ops for sub-keys are not possible.

if cfg == nil {
return nil, nil
}

// repo-memory and comment-memory rely on scripts installed by the gh-aw setup action.
// Inject the setup step before any memory steps so those scripts are available.
if cfg.RepoMemory || cfg.CommentMemory {
setupActionRef := c.resolveActionReference("./actions/setup", data)
if setupActionRef != "" || c.actionMode.IsScript() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] Silent no-op when resolveActionReference returns "" and actionMode.IsScript() is false — setupLines is empty but memoryLines for repo-memory/comment-memory are still emitted, causing the injected steps to reference scripts (clone_repo_memory_branch.sh, setup_comment_memory_files.cjs) that were never installed.

Suggestion

Either surface an error or short-circuit all memory lines when setup cannot be satisfied:

if cfg.RepoMemory || cfg.CommentMemory {
    setupActionRef := c.resolveActionReference("./actions/setup", data)
    if setupActionRef == "" && !c.actionMode.IsScript() {
        // Scripts unavailable; skip injecting dependent memory lines.
        return nil, nil
    }
    setupLines = append(setupLines, c.generateCheckoutActionsFolder(data)...)
    setupLines = append(setupLines, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...)
}

A unit test covering this code path would prevent silent regressions.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e413231: raised to a compile-time error. When resolveActionReference("./actions/setup", data) returns "" and !c.actionMode.IsScript(), buildRestoreMemorySteps now returns an error message identifying the job and the requirement.

setupLines = append(setupLines, c.generateCheckoutActionsFolder(data)...)
// Pass empty trace IDs — custom jobs do not inherit the activation span.
setupLines = append(setupLines, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...)
}
}

if cfg.CacheMemory {
memoryLines = append(memoryLines, generateCacheMemoryRestoreLines(data)...)
}
if cfg.RepoMemory {
memoryLines = append(memoryLines, generateRepoMemoryRestoreLines(data)...)
}
if cfg.CommentMemory {
memoryLines = append(memoryLines, generateCommentMemoryRestoreLines(data)...)
}

return setupLines, memoryLines
}

// generateCacheMemoryRestoreLines produces read-only cache-memory restore steps for a
// custom job. Unlike the agent-job path, these steps:
// - always use actions/cache/restore (never actions/cache, so no auto-save)
// - create the cache directory inline with mkdir -p (no script needed)
// - omit the git integrity setup step (only relevant for the write path)
func generateCacheMemoryRestoreLines(data *WorkflowData) []string {
if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 {
return nil
}

var lines []string
lines = append(lines, " # restore-memory: cache-memory (read-only restore)\n")

var githubConfig *GitHubToolConfig
if data.ParsedTools != nil {
githubConfig = data.ParsedTools.GitHub
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent skip of setup step leaves repo-memory/comment-memory broken at runtime

When setupActionRef == "" and !c.actionMode.IsScript(), the if guard exits without appending any setup lines — but the generateRepoMemoryRestoreLines / generateCommentMemoryRestoreLines steps are still emitted unconditionally in the block below. Both of those steps call scripts installed by the setup action (clone_repo_memory_branch.sh, setup_comment_memory_files.cjs). If the setup action is not injected, those scripts will not exist on the runner and the steps will fail at runtime.

Other callers (e.g. compiler_safe_outputs_job.go:124) follow the same guard and also skip their memory steps when the setup action is absent, or they log the situation. This site should either (a) also skip the memory lines, or (b) treat the missing setup action as a compilation error:

if setupActionRef == "" && !c.actionMode.IsScript() {
    return nil, fmt.Errorf("jobs.%s.restore-memory: repo-memory/comment-memory requires the setup action; no action ref found", jobName)
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e413231: buildRestoreMemorySteps now returns a compile-time error when resolveActionReference returns "" and !c.actionMode.IsScript(). The method signature was updated to (setupLines, memoryLines []string, err error) and the call site propagates the error.


for i, cache := range data.CacheMemoryConfig.Caches {
cacheDir := cacheMemoryDirFor(cache.ID)
restoreStepID := fmt.Sprintf("restore_cache_memory_%d", i)

// Create the cache directory inline; no script required.
Comment on lines +102 to +106

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The ADR (docs/adr/44037-restore-memory-read-only-access-custom-jobs.md) now shows the correct canonical paths (/tmp/gh-aw/cache-memory for the default cache, /tmp/gh-aw/cache-memory-<id> for named caches) and the updated restore-memory: true boolean API (committed in 1fe026c). The PR description is stale from the original code generation and reflects the pre-simplification per-type map API; the ADR is the authoritative documentation going forward.

lines = append(lines, fmt.Sprintf(" - name: Create cache-memory directory (%s)\n", cache.ID))
lines = append(lines, " run: |\n")
lines = append(lines, fmt.Sprintf(" mkdir -p %s\n", cacheDir))

// Compute the same integrity-aware cache key as the agent job uses.
cacheKey := computeIntegrityCacheKey(cache, githubConfig)

// Restore step — always read-only in custom jobs.
lines = append(lines, fmt.Sprintf(" - name: Restore cache-memory (%s)\n", cache.ID))
lines = append(lines, fmt.Sprintf(" id: %s\n", restoreStepID))
lines = append(lines, fmt.Sprintf(" uses: %s\n", getActionPin("actions/cache/restore")))
lines = append(lines, " with:\n")
lines = append(lines, fmt.Sprintf(" key: %s\n", cacheKey))
lines = append(lines, fmt.Sprintf(" path: %s\n", cacheDir))

// Build restore keys using the same logic as generateCacheMemorySteps.
var restoreKeys []string
runIDSuffix := "-${{ github.run_id }}"
if strings.HasSuffix(cacheKey, runIDSuffix) {
restoreKeys = append(restoreKeys, strings.TrimSuffix(cacheKey, "${{ github.run_id }}"))
} else {
keyParts := strings.Split(cacheKey, "-")
if len(keyParts) >= 2 {
restoreKeys = append(restoreKeys, strings.Join(keyParts[:len(keyParts)-1], "-")+"-")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] The restore-key construction logic here is a near-duplicate of the same logic in cache.go (lines ~529-560). Any future change to restore-key semantics (e.g. adding a new scope type) must now be applied in two places.

Suggestion

Extract the restore-key derivation into a shared helper, for example buildCacheRestoreKeys(cacheKey string, scope string) []string, and call it from both generateCacheMemoryRestoreLines and the existing generateCacheMemorySteps in cache.go.

This removes the duplication and makes the parallel structure explicit.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e413231: extracted buildCacheRestoreKeys(cacheKey, scope string) []string into cache.go and replaced both the inline logic in generateCacheMemorySteps (cache.go) and the duplicate in generateCacheMemoryRestoreLines (compiler_custom_job_memory.go) with calls to this shared helper.

}
}

scope := cache.Scope
if scope == "" {
scope = "workflow"
}
if scope == "repo" {
repoKey := strings.TrimSuffix(cacheKey, "${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}")
if repoKey != cacheKey && repoKey != "" {
restoreKeys = append(restoreKeys, repoKey)
}
}

lines = append(lines, " restore-keys: |\n")
for _, key := range restoreKeys {
lines = append(lines, fmt.Sprintf(" %s\n", key))
}
}

return lines
}

// generateRepoMemoryRestoreLines produces read-only repo-memory clone steps for a
// custom job by reusing the existing generateRepoMemorySteps builder and converting
// its output to the []string line format expected by job.Steps.
func generateRepoMemoryRestoreLines(data *WorkflowData) []string {
if data.RepoMemoryConfig == nil || len(data.RepoMemoryConfig.Memories) == 0 {
return nil
}

var b strings.Builder
generateRepoMemorySteps(&b, data)
raw := b.String()
if raw == "" {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] generateRepoMemoryRestoreLines splits a strings.Builder output on "\n" and re-appends "\n" to every part, which adds a spurious trailing "\n" for the last element (the final strings.Split result for "...\n" will have an empty string at the end, becoming "\n"). This produces a blank extra line appended to the step block.

Suggestion
parts := strings.SplitAfter(raw, "\n") // keeps the newline on each part
lines := make([]string, 0, len(parts))
for _, p := range parts {
    if p != "" {
        lines = append(lines, p)
    }
}

Or test that the generated YAML for a repo-memory custom job has no duplicate blank lines.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e413231: generateRepoMemoryRestoreLines now uses strings.SplitAfter(raw, "\n") and skips empty elements, so no phantom blank line is injected.

// Split into individual lines, preserving their trailing newlines.
parts := strings.Split(raw, "\n")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: double trailing newline in generateRepoMemoryRestoreLines

strings.Split(raw, "\n") on a newline-terminated string always produces an empty string as the last element. Adding "\n" to every part then emits an extra blank "\n" line at the end, injecting a spurious empty line between the repo-memory steps and whatever follows (pre-steps, regular steps).

Fix: skip the trailing empty element, or use strings.SplitAfter which preserves newlines without adding a phantom one:

// Using SplitAfter — each element keeps its trailing newline; no phantom entry.
parts := strings.SplitAfter(raw, "\n")
lines := make([]string, 0, len(parts))
for _, p := range parts {
    if p != "" {
        lines = append(lines, p)
    }
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e413231: generateRepoMemoryRestoreLines now uses strings.SplitAfter(raw, "\n") and skips empty parts, eliminating the spurious trailing blank line.

lines := make([]string, 0, len(parts))
for _, p := range parts {
lines = append(lines, p+"\n")
}
return lines
}

// generateCommentMemoryRestoreLines produces a read-only comment-memory prepare step
// for a custom job. The step fetches the comment-memory content from GitHub and
// materialises it as local files — the same operation performed in the agent job.
func generateCommentMemoryRestoreLines(data *WorkflowData) []string {
if data.SafeOutputs == nil || data.SafeOutputs.CommentMemory == nil {
return nil
}

var lines []string
lines = append(lines, " # restore-memory: comment-memory (read-only restore)\n")
lines = append(lines, " - name: Prepare comment memory files\n")
lines = append(lines, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data)))
lines = append(lines, " with:\n")
lines = append(lines, fmt.Sprintf(" github-token: %s\n", getEffectiveSafeOutputGitHubToken(data.SafeOutputs.CommentMemory.GitHubToken)))
lines = append(lines, " script: |\n")
lines = append(lines, " const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n")
lines = append(lines, " setupGlobals(core, github, context, exec, io, getOctokit);\n")
lines = append(lines, " const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_comment_memory_files.cjs');\n")
lines = append(lines, " await main();\n")
return lines
}
Loading
Loading