diff --git a/.github/aw/actions-lock.json b/.github/workflows/aw-lock.json
similarity index 96%
rename from .github/aw/actions-lock.json
rename to .github/workflows/aw-lock.json
index 4432457f3b3..7046be2e035 100644
--- a/.github/aw/actions-lock.json
+++ b/.github/workflows/aw-lock.json
@@ -1,13 +1,14 @@
{
- "entries": {
+ "version": "1",
+ "actions": {
"actions-ecosystem/action-add-labels@v1.1.3": {
"repo": "actions-ecosystem/action-add-labels",
"version": "v1.1.3",
"sha": "c96b68fec76a0987cd93957189e9abd0b9a72ff1",
"inputs": {
"github_token": {
- "description": "A GitHub token.",
- "default": "${{ github.token }}"
+ "default": "${{ github.token }}",
+ "description": "A GitHub token."
},
"labels": {
"description": "The labels' name to be added. Must be separated with line breaks if there're multiple labels.",
@@ -17,8 +18,8 @@
"description": "The number of the issue or pull request."
},
"repo": {
- "description": "The owner and repository name. e.g.) Codertocat/Hello-World",
- "default": "${{ github.repository }}"
+ "default": "${{ github.repository }}",
+ "description": "The owner and repository name. e.g.) Codertocat/Hello-World"
}
},
"action_description": "Add labels to an issue or a pull request."
diff --git a/Makefile b/Makefile
index d9dd91b5a6d..6242ddbd65d 100644
--- a/Makefile
+++ b/Makefile
@@ -665,16 +665,12 @@ clean-docs:
@echo "✓ Documentation artifacts cleaned"
# Sync templates from .github to pkg/cli/templates
-# Sync action pins from .github/aw to pkg/workflow/data
+# Sync action pins from .github/workflows/aw-lock.json to pkg/workflow/data
.PHONY: sync-action-pins
sync-action-pins:
- @echo "Syncing actions-lock.json from .github/aw to pkg/workflow/data/action_pins.json..."
- @if [ -f .github/aw/actions-lock.json ]; then \
- cp .github/aw/actions-lock.json pkg/workflow/data/action_pins.json; \
- echo "✓ Action pins synced successfully"; \
- else \
- echo "⚠ Warning: .github/aw/actions-lock.json does not exist yet"; \
- fi
+ @echo "Syncing action pins from .github/workflows/aw-lock.json to pkg/workflow/data/action_pins.json..."
+ @go run ./scripts/sync-action-pins
+ @echo "✓ Action pins synced successfully"
# Sync action scripts
.PHONY: sync-action-scripts
@@ -806,7 +802,7 @@ help:
@echo " actionlint - Validate workflows with actionlint (depends on build)"
@echo " validate-workflows - Validate compiled workflow lock files (depends on build)"
@echo " install - Install binary locally"
- @echo " sync-action-pins - Sync actions-lock.json from .github/aw to pkg/workflow/data (runs automatically during build)"
+ @echo " sync-action-pins - Sync action pins from .github/workflows/aw-lock.json to pkg/workflow/data (runs automatically during build)"
@echo " sync-action-scripts - Sync install-gh-aw.sh to actions/setup-cli/install.sh (runs automatically during build)"
@echo " update - Update GitHub Actions and workflows, sync action pins, and rebuild binary"
@echo " fix - Apply automatic codemod-style fixes to workflow files (depends on build)"
diff --git a/docs/src/content/docs/introduction/architecture.mdx b/docs/src/content/docs/introduction/architecture.mdx
index 0eb27820eb1..1c547536ab9 100644
--- a/docs/src/content/docs/introduction/architecture.mdx
+++ b/docs/src/content/docs/introduction/architecture.mdx
@@ -416,7 +416,7 @@ flowchart TB
subgraph Pinning["Action Pinning"]
SHA["SHA Resolution
actions/checkout@sha # v4"]
- CACHE[/"actions-lock.json
(Cached SHAs)"/]
+ CACHE[/"aw-lock.json
(Cached SHAs)"/]
end
subgraph Scanners["Security Scanners"]
diff --git a/docs/src/content/docs/reference/compilation-process.md b/docs/src/content/docs/reference/compilation-process.md
index 23962e23873..f115ca7f2f9 100644
--- a/docs/src/content/docs/reference/compilation-process.md
+++ b/docs/src/content/docs/reference/compilation-process.md
@@ -203,15 +203,15 @@ Data flows via GitHub Actions artifacts: agent writes `agent_output.json` → de
## Action Pinning
-All GitHub Actions are pinned to commit SHAs (e.g., `actions/checkout@b4ffde6...11 # v6`) to prevent supply chain attacks. Tags can be moved to malicious commits, but SHA commits are immutable. The resolution order mirrors Phase 4: cache (`.github/aw/actions-lock.json`) → GitHub API → embedded pins.
+All GitHub Actions are pinned to commit SHAs (e.g., `actions/checkout@b4ffde6...11 # v6`) to prevent supply chain attacks. Tags can be moved to malicious commits, but SHA commits are immutable. The resolution order mirrors Phase 4: cache (`.github/workflows/aw-lock.json`) → GitHub API → embedded pins.
-### The actions-lock.json Cache
+### The aw-lock.json Cache
-`.github/aw/actions-lock.json` stores resolved `action@version` → SHA mappings so that compilation produces consistent results regardless of the token available. Resolving a version tag to a SHA requires querying the GitHub API, which can fail when the token has limited permissions — notably when compiling via GitHub Copilot Coding Agent (CCA), which uses a restricted token that may not have access to external repositories.
+`.github/workflows/aw-lock.json` stores resolved `action@version` → SHA mappings so that compilation produces consistent results regardless of the token available. Resolving a version tag to a SHA requires querying the GitHub API, which can fail when the token has limited permissions — notably when compiling via GitHub Copilot Coding Agent (CCA), which uses a restricted token that may not have access to external repositories.
By caching SHA resolutions from a prior compilation (done with a user PAT or a GitHub Actions token with broader scope), subsequent compilations reuse those SHAs without making API calls. Without the cache, compilation is unstable: it succeeds with a permissive token but fails when token access is restricted.
-**Commit `actions-lock.json` to version control.** This ensures all contributors and automated tools, including CCA, use the same immutable pins. Refresh it periodically with `gh aw update-actions`, or delete it and recompile with an appropriate token to force full re-resolution.
+**Commit `aw-lock.json` to version control.** This ensures all contributors and automated tools, including CCA, use the same immutable pins. Refresh it periodically with `gh aw update-actions`, or delete it and recompile with an appropriate token to force full re-resolution.
## The gh-aw-actions Repository
@@ -316,7 +316,7 @@ Pre-activation runs checks sequentially. Any failure sets `activated=false`, pre
## Performance Optimization
-**Compilation speed**: Simple workflows compile in ~100ms, complex workflows with imports in ~500ms, and workflows with dynamic action resolution in ~2s. Optimize by using action cache (`.github/aw/actions-lock.json`), minimizing import depth, and pre-compiling shared workflows.
+**Compilation speed**: Simple workflows compile in ~100ms, complex workflows with imports in ~500ms, and workflows with dynamic action resolution in ~2s. Optimize by using action cache (`.github/workflows/aw-lock.json`), minimizing import depth, and pre-compiling shared workflows.
**Runtime performance**: Safe output jobs without dependencies run in parallel. Enable `cache:` for dependencies, use `cache-memory:` for persistent agent memory, and cache action resolutions for faster compilation.
@@ -332,7 +332,7 @@ Pre-activation runs checks sequentially. Any failure sets `activated=false`, pre
**Security**: Always use action pinning (never floating tags), enable threat detection (`safe-outputs.threat-detection:`), limit tool access with `allowed:`, review generated `.lock.yml` files, and run security scanners (`--actionlint --zizmor --poutine`).
-**Maintainability**: Use imports for shared configuration, document complex workflows with `description:`, compile frequently during development, version control lock files and action pins (`.github/aw/actions-lock.json`).
+**Maintainability**: Use imports for shared configuration, document complex workflows with `description:`, compile frequently during development, version control lock files and action pins (`.github/workflows/aw-lock.json`).
**Performance**: Enable caching (`cache:` and `cache-memory:`), minimize imports to essentials, optimize tool configurations with restricted `allowed:` lists, use safe-jobs for custom logic.
diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md
index 9cd953ad3e1..08972c28f5d 100644
--- a/docs/src/content/docs/reference/faq.md
+++ b/docs/src/content/docs/reference/faq.md
@@ -270,13 +270,13 @@ Both files should be committed to version control:
- **`.md` file**: Your source - edit the prompt body freely; changes take effect at the next run without recompiling
- **`.lock.yml` file**: The compiled workflow GitHub Actions actually runs; must be regenerated after any frontmatter changes (permissions, tools, triggers)
-### What is the actions-lock.json file?
+### What is the aw-lock.json file?
-The `.github/aw/actions-lock.json` file is a cache of resolved `action@version` → ref mappings. During compilation, the compiler **tries** to pin each action reference to an immutable commit SHA for security. Resolving a version tag to a SHA requires querying the GitHub API (scanning releases), which can fail when the available token has limited permissions — for example, when compiling via GitHub Copilot Coding Agent (CCA) where the token may not have access to external repositories. In those cases, the compiler may fall back to leaving a stable version tag ref (such as `@v0`) instead of a SHA.
+The `.github/workflows/aw-lock.json` file is a cache of resolved `action@version` → ref mappings. During compilation, the compiler **tries** to pin each action reference to an immutable commit SHA for security. Resolving a version tag to a SHA requires querying the GitHub API (scanning releases), which can fail when the available token has limited permissions — for example, when compiling via GitHub Copilot Coding Agent (CCA) where the token may not have access to external repositories. In those cases, the compiler may fall back to leaving a stable version tag ref (such as `@v0`) instead of a SHA.
-The cache avoids this problem: if a ref (typically a SHA) was previously resolved (using a user PAT or a GitHub Actions token with broader access), the result is stored in `actions-lock.json` and reused on subsequent compilations, regardless of the current token's capabilities. Without this cache, compilation is unstable — it succeeds with a permissive token but fails when token access is restricted.
+The cache avoids this problem: if a ref (typically a SHA) was previously resolved (using a user PAT or a GitHub Actions token with broader access), the result is stored in `aw-lock.json` and reused on subsequent compilations, regardless of the current token's capabilities. Without this cache, compilation is unstable — it succeeds with a permissive token but fails when token access is restricted.
-Commit `actions-lock.json` to version control so that all contributors and automated tools (including CCA) use consistent action refs (SHAs or version tags) without needing to re-resolve them. Refresh the cache periodically with `gh aw update-actions`, or delete it and recompile to force a full re-resolution when you have an appropriate token. See [Action Pinning](/gh-aw/reference/compilation-process/#action-pinning) for details.
+Commit `aw-lock.json` to version control so that all contributors and automated tools (including CCA) use consistent action refs (SHAs or version tags) without needing to re-resolve them. Refresh the cache periodically with `gh aw update-actions`, or delete it and recompile to force a full re-resolution when you have an appropriate token. See [Action Pinning](/gh-aw/reference/compilation-process/#action-pinning) for details.
### What is `github/gh-aw-actions`?
diff --git a/docs/src/content/docs/reference/releases.md b/docs/src/content/docs/reference/releases.md
index d56ecfc5158..1d1bad0a20e 100644
--- a/docs/src/content/docs/reference/releases.md
+++ b/docs/src/content/docs/reference/releases.md
@@ -81,12 +81,12 @@ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
SHA pins are immutable — unlike tags, they cannot be silently redirected to a different commit. This protects workflows from supply-chain attacks.
-The resolved SHA mappings are cached in `.github/aw/actions-lock.json`. Commit this file to version control so that all contributors and automated tools (including GitHub Copilot Coding Agent) produce identical lock files without needing broad API access.
+The resolved SHA mappings are cached in `.github/workflows/aw-lock.json`. Commit this file to version control so that all contributors and automated tools (including GitHub Copilot Coding Agent) produce identical lock files without needing broad API access.
To refresh action pins:
```bash
-gh aw update-actions # Update actions-lock.json to latest SHAs
+gh aw update-actions # Update aw-lock.json to latest SHAs
gh aw compile # Recompile workflows using the refreshed pins
```
@@ -104,7 +104,7 @@ These two commands address different concerns:
1. Self-updates the `gh aw` extension to the latest version
2. Regenerates the dispatcher agent file (like `gh aw init`)
3. Applies codemods to fix deprecated syntax across all workflow markdown files
-4. Updates GitHub Actions versions in `actions-lock.json`
+4. Updates GitHub Actions versions in `aw-lock.json`
5. Recompiles all workflows to produce fresh `.lock.yml` files
Run `upgrade` after installing a new version of `gh aw`, or periodically to keep your repository current.
diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md
index cc8e01fe5b6..e5a593bf48c 100644
--- a/docs/src/content/docs/setup/cli.md
+++ b/docs/src/content/docs/setup/cli.md
@@ -534,7 +534,7 @@ gh aw remove my-workflow --keep-orphans # Remove but keep orphaned include file
Update workflows based on `source` field (`owner/repo/path@ref`). By default, performs a 3-way merge to preserve local changes; use `--no-merge` to override with upstream. Semantic versions update within same major version.
-By default, `update` also force-updates all GitHub Actions referenced in your workflows (both in `actions-lock.json` and workflow files) to their latest major version. Use `--disable-release-bump` to restrict force-updates to core `actions/*` actions only.
+By default, `update` also force-updates all GitHub Actions referenced in your workflows (both in `aw-lock.json` and workflow files) to their latest major version. Use `--disable-release-bump` to restrict force-updates to core `actions/*` actions only.
If no workflows in the repository contain a `source` field, the command exits gracefully with an informational message rather than an error. This is expected behavior for repositories that have not yet added updatable workflows.
diff --git a/pkg/cli/codemod_actions_lock_migration.go b/pkg/cli/codemod_actions_lock_migration.go
new file mode 100644
index 00000000000..d1ef5459de5
--- /dev/null
+++ b/pkg/cli/codemod_actions_lock_migration.go
@@ -0,0 +1,83 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/github/gh-aw/pkg/console"
+ "github.com/github/gh-aw/pkg/workflow"
+)
+
+// getActionsLockMigrationCodemod returns a file-level codemod that migrates the
+// old .github/aw/actions-lock.json to the new .github/workflows/aw-lock.json format.
+// The Apply function is a no-op (it doesn't modify workflow files); the actual
+// migration is performed by MigrateActionsLockFile which is called from fix_command.go.
+func getActionsLockMigrationCodemod() Codemod {
+ return Codemod{
+ ID: "migrate-actions-lock-file",
+ Name: "Migrate actions-lock.json to aw-lock.json",
+ Description: "Moves .github/aw/actions-lock.json to .github/workflows/aw-lock.json with the new JSON format",
+ IntroducedIn: "0.71.0",
+ Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
+ // This codemod is handled by MigrateActionsLockFile (called from fix_command.go).
+ // It doesn't modify workflow files, so return content unchanged.
+ return content, false, nil
+ },
+ }
+}
+
+// MigrateActionsLockFile moves .github/aw/actions-lock.json to
+// .github/workflows/aw-lock.json and migrates the format (entries → actions, adds version).
+// Returns (migrated, error): migrated is true when the migration was performed.
+func MigrateActionsLockFile(write bool, verbose bool) (bool, error) {
+ legacyPath := filepath.Join(".github", "aw", workflow.LegacyCacheFileName)
+ newPath := filepath.Join(".github", "workflows", workflow.CacheFileName)
+
+ // Check whether the legacy file exists.
+ if _, err := os.Stat(legacyPath); os.IsNotExist(err) {
+ return false, nil // nothing to migrate
+ }
+
+ if verbose || !write {
+ fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage(
+ fmt.Sprintf("Found legacy %s – migrating to %s", legacyPath, newPath)))
+ }
+
+ if !write {
+ fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage(
+ fmt.Sprintf("Would migrate %s to %s", legacyPath, newPath)))
+ return true, nil
+ }
+
+ // If the new file already exists, skip migration to avoid overwriting or
+ // discarding the legacy file without verifying the contents match.
+ if _, err := os.Stat(newPath); err == nil {
+ fmt.Fprintf(os.Stderr, "%s\n", console.FormatWarningMessage(
+ fmt.Sprintf("%s already exists; leaving legacy %s in place and skipping migration", newPath, legacyPath)))
+ return false, nil
+ }
+
+ // Load via ActionCache (which handles the legacy JSON format) and re-save
+ // to the new path with the updated schema (entries → actions, adds version).
+ cache := workflow.NewActionCache(".")
+ if err := cache.Load(); err != nil {
+ return false, fmt.Errorf("loading %s: %w", legacyPath, err)
+ }
+
+ // Force a save even if the cache appears clean (it was loaded from the old path).
+ cache.MarkDirty()
+
+ if err := cache.Save(); err != nil {
+ return false, fmt.Errorf("saving %s: %w", newPath, err)
+ }
+
+ // Remove the old file.
+ if err := os.Remove(legacyPath); err != nil {
+ return false, fmt.Errorf("removing legacy %s: %w", legacyPath, err)
+ }
+
+ fmt.Fprintf(os.Stderr, "%s\n", console.FormatSuccessMessage(
+ fmt.Sprintf("Migrated %s → %s", legacyPath, newPath)))
+ return true, nil
+}
diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go
index 8b4b891afba..d24ccbf5bb0 100644
--- a/pkg/cli/compile_integration_test.go
+++ b/pkg/cli/compile_integration_test.go
@@ -14,6 +14,7 @@ import (
"github.com/creack/pty"
"github.com/github/gh-aw/pkg/fileutil"
+ "github.com/github/gh-aw/pkg/workflow"
)
// Global binary path shared across all integration tests
@@ -67,7 +68,7 @@ func TestMain(m *testing.M) {
}
// Clean up any action cache files created during tests
- // Tests may create .github/aw/actions-lock.json in the pkg/cli directory
+ // Tests may create .github/workflows/aw-lock.yml in the pkg/cli directory
actionCacheDir := filepath.Join(wd, ".github")
if _, err := os.Stat(actionCacheDir); err == nil {
_ = os.RemoveAll(actionCacheDir)
@@ -1278,22 +1279,22 @@ Test workflow to verify actions-lock.json path handling when compiling from subd
t.Fatalf("Failed to change back to temp directory: %v", err)
}
- // Verify actions-lock.json is created at the repository root (.github/aw/actions-lock.json)
- // NOT at .github/workflows/.github/aw/actions-lock.json
- expectedLockPath := filepath.Join(setup.tempDir, ".github", "aw", "actions-lock.json")
- wrongLockPath := filepath.Join(setup.workflowsDir, ".github", "aw", "actions-lock.json")
+ // Verify aw-lock.yml is created at the repository root (.github/workflows/aw-lock.yml)
+ // NOT at .github/workflows/.github/workflows/aw-lock.yml
+ expectedLockPath := filepath.Join(setup.tempDir, ".github", "workflows", workflow.CacheFileName)
+ wrongLockPath := filepath.Join(setup.workflowsDir, ".github", "workflows", workflow.CacheFileName)
- // Check if actions-lock.json exists (it may or may not, depending on whether actions were pinned)
+ // Check if aw-lock.yml exists (it may or may not, depending on whether actions were pinned)
// The important part is that if it exists, it's in the right place
if _, err := os.Stat(expectedLockPath); err == nil {
- t.Logf("actions-lock.json correctly created at repo root: %s", expectedLockPath)
+ t.Logf("aw-lock.yml correctly created at repo root: %s", expectedLockPath)
} else if !os.IsNotExist(err) {
- t.Fatalf("Failed to check for actions-lock.json at expected path: %v", err)
+ t.Fatalf("Failed to check for aw-lock.yml at expected path: %v", err)
}
- // Verify actions-lock.json was NOT created in the wrong location
+ // Verify aw-lock.yml was NOT created in the wrong location
if _, err := os.Stat(wrongLockPath); err == nil {
- t.Errorf("actions-lock.json incorrectly created at nested path: %s (should be at repo root)", wrongLockPath)
+ t.Errorf("aw-lock.yml incorrectly created at nested path: %s (should be at repo root)", wrongLockPath)
}
// Verify the workflow lock file was created
@@ -1302,7 +1303,7 @@ Test workflow to verify actions-lock.json path handling when compiling from subd
t.Fatalf("Expected lock file %s was not created", lockFilePath)
}
- t.Logf("Integration test passed - actions-lock.json created at correct location")
+ t.Logf("Integration test passed - aw-lock.yml created at correct location")
}
// TestCompileSafeOutputsActions verifies that a workflow with safe-outputs.actions
diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go
index 4d509c8e824..4ccaccd2a40 100644
--- a/pkg/cli/fix_codemods.go
+++ b/pkg/cli/fix_codemods.go
@@ -51,6 +51,7 @@ func GetAllCodemods() []Codemod {
getPluginsToDependenciesCodemod(), // Migrate plugins to dependencies (plugins removed in favour of APM)
getGitHubReposToAllowedReposCodemod(), // Rename deprecated tools.github.repos to tools.github.allowed-repos
getDIFCProxyToIntegrityProxyCodemod(), // Migrate deprecated features.difc-proxy to tools.github.integrity-proxy
+ getActionsLockMigrationCodemod(), // Migrate .github/aw/actions-lock.json to .github/workflows/aw-lock.json
}
fixCodemodsLog.Printf("Loaded codemod registry: %d codemods available", len(codemods))
return codemods
diff --git a/pkg/cli/fix_command.go b/pkg/cli/fix_command.go
index 3c5cfc0997d..c780e3a813f 100644
--- a/pkg/cli/fix_command.go
+++ b/pkg/cli/fix_command.go
@@ -50,9 +50,10 @@ The command will:
Without --write (dry-run mode), no files are modified. With --write, the command performs
all steps and additionally:
4. Write updated files back to disk
- 5. Delete deprecated .github/aw/schemas/agentic-workflow.json file if it exists
- 6. Delete old template files from previous versions if present
- 7. Delete old workflow-specific .agent.md files from .github/agents/ if present
+ 5. Migrate .github/aw/actions-lock.json to .github/workflows/aw-lock.json if it exists
+ 6. Delete deprecated .github/aw/schemas/agentic-workflow.json file if it exists
+ 7. Delete old template files from previous versions if present
+ 8. Delete old workflow-specific .agent.md files from .github/agents/ if present
` + WorkflowIDExplanation + `
@@ -205,6 +206,13 @@ func runFixCommand(workflowIDs []string, write bool, verbose bool, workflowDir s
}
}
+ // Migrate legacy .github/aw/actions-lock.json → .github/workflows/aw-lock.json
+ fixLog.Print("Checking for legacy actions-lock.json to migrate")
+ if _, err := MigrateActionsLockFile(write, verbose); err != nil {
+ fixLog.Printf("Failed to migrate actions-lock.json: %v", err)
+ fmt.Fprintf(os.Stderr, "%s\n", console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to migrate actions-lock.json: %v", err)))
+ }
+
// Delete deprecated schema file if it exists
schemaPath := filepath.Join(".github", "aw", "schemas", "agentic-workflow.json")
if _, err := os.Stat(schemaPath); err == nil {
diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go
index 80ac06eb553..2696424de0f 100644
--- a/pkg/cli/update_actions.go
+++ b/pkg/cli/update_actions.go
@@ -22,7 +22,7 @@ func isCoreAction(repo string) bool {
return strings.HasPrefix(repo, "actions/")
}
-// UpdateActions updates GitHub Actions versions in .github/aw/actions-lock.json
+// UpdateActions updates GitHub Actions versions in .github/workflows/aw-lock.json
// It checks each action for newer releases and updates the SHA if a newer version is found.
// By default all actions are updated to the latest major version; pass disableReleaseBump=true
// to revert to the old behaviour where only core (actions/*) actions bypass the --major flag.
@@ -37,14 +37,18 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking for GitHub Actions updates..."))
}
- // Load the action cache (actions-lock.json) using the shared ActionCache helpers
+ // Load the action cache (aw-lock.json) using the shared ActionCache helpers
// so that cached inputs/descriptions for safe-outputs.actions entries are preserved.
- actionsLockPath := filepath.Join(".github", "aw", "actions-lock.json")
- if _, err := os.Stat(actionsLockPath); os.IsNotExist(err) {
- if verbose {
- fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Actions lock file not found: "+actionsLockPath))
+ // NewActionCache also handles loading from the legacy actions-lock.json path.
+ awLockPath := filepath.Join(".github", "workflows", workflow.CacheFileName)
+ legacyPath := filepath.Join(".github", "aw", workflow.LegacyCacheFileName)
+ if _, err := os.Stat(awLockPath); os.IsNotExist(err) {
+ if _, err2 := os.Stat(legacyPath); os.IsNotExist(err2) {
+ if verbose {
+ fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Actions lock file not found: "+awLockPath))
+ }
+ return nil // Not an error, just skip
}
- return nil // Not an error, just skip
}
actionCache := workflow.NewActionCache(".")
@@ -52,7 +56,7 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error {
return fmt.Errorf("failed to parse actions lock file: %w", err)
}
- updateLog.Printf("Loaded %d action entries from actions-lock.json", len(actionCache.Entries))
+ updateLog.Printf("Loaded %d action entries from aw-lock.json", len(actionCache.Entries))
// Track updates
var updatedActions []string
@@ -153,8 +157,8 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error {
return fmt.Errorf("failed to save actions lock file: %w", err)
}
- updateLog.Printf("Successfully wrote updated actions-lock.json with %d updates", len(updatedActions))
- fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Updated actions-lock.json file"))
+ updateLog.Printf("Successfully wrote updated aw-lock.json with %d updates", len(updatedActions))
+ fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Updated aw-lock.json file"))
}
return nil
diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go
index 9abc5f6c151..e9c53608ec4 100644
--- a/pkg/cli/update_command.go
+++ b/pkg/cli/update_command.go
@@ -118,13 +118,13 @@ func RunUpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool,
firstErr = fmt.Errorf("workflow update failed: %w", err)
}
- // Update GitHub Actions versions in actions-lock.json.
+ // Update GitHub Actions versions in aw-lock.json.
// By default all actions are updated to the latest major version.
// Pass --disable-release-bump to revert to only forcing updates for core (actions/*) actions.
- updateLog.Printf("Updating GitHub Actions versions in actions-lock.json: allowMajor=%v, disableReleaseBump=%v", allowMajor, disableReleaseBump)
+ updateLog.Printf("Updating GitHub Actions versions in aw-lock.json: allowMajor=%v, disableReleaseBump=%v", allowMajor, disableReleaseBump)
if err := UpdateActions(allowMajor, verbose, disableReleaseBump); err != nil {
// Non-fatal: warn but don't fail the update
- fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update actions-lock.json: %v", err)))
+ fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update aw-lock.json: %v", err)))
}
// Update action references in user-provided steps within workflow .md files.
diff --git a/pkg/cli/update_command_test.go b/pkg/cli/update_command_test.go
index bc48b591164..dbc4791cebf 100644
--- a/pkg/cli/update_command_test.go
+++ b/pkg/cli/update_command_test.go
@@ -738,7 +738,7 @@ func TestMarshalActionsLockSorted(t *testing.T) {
}
// Read the file back
- data, err := os.ReadFile(filepath.Join(tmpDir, ".github", "aw", "actions-lock.json"))
+ data, err := os.ReadFile(filepath.Join(tmpDir, ".github", "workflows", workflow.CacheFileName))
if err != nil {
t.Fatalf("Expected to read saved file, got: %v", err)
}
@@ -758,8 +758,8 @@ func TestMarshalActionsLockSorted(t *testing.T) {
}
// Check JSON structure
- if !strings.Contains(result, `"entries": {`) {
- t.Error("Expected 'entries' key in JSON output")
+ if !strings.Contains(result, `"actions":`) {
+ t.Error("Expected 'actions' key in JSON output")
}
if !strings.Contains(result, `"repo": "actions/checkout"`) {
diff --git a/pkg/cli/upgrade_command.go b/pkg/cli/upgrade_command.go
index db6530d876a..cebcea2dfbd 100644
--- a/pkg/cli/upgrade_command.go
+++ b/pkg/cli/upgrade_command.go
@@ -38,7 +38,7 @@ func NewUpgradeCommand() *cobra.Command {
This command:
1. Updates the dispatcher agent file to the latest template (like 'init' command)
2. Applies automatic codemods to fix deprecated fields in all workflows (like 'fix --write')
- 3. Updates GitHub Actions versions in .github/aw/actions-lock.json (unless --no-actions is set)
+ 3. Updates GitHub Actions versions in .github/workflows/aw-lock.json (unless --no-actions is set)
4. Compiles all workflows to generate lock files (like 'compile' command)
DEPENDENCY HEALTH AUDIT:
diff --git a/pkg/workflow/.github/aw/actions-lock.json b/pkg/workflow/.github/workflows/aw-lock.json
similarity index 96%
rename from pkg/workflow/.github/aw/actions-lock.json
rename to pkg/workflow/.github/workflows/aw-lock.json
index 122faf0f798..26acd621be4 100644
--- a/pkg/workflow/.github/aw/actions-lock.json
+++ b/pkg/workflow/.github/workflows/aw-lock.json
@@ -1,5 +1,6 @@
{
- "entries": {
+ "version": "1",
+ "actions": {
"actions/ai-inference@v1": {
"repo": "actions/ai-inference",
"version": "v1",
diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go
index 48ed232e494..71251d6aca3 100644
--- a/pkg/workflow/action_cache.go
+++ b/pkg/workflow/action_cache.go
@@ -14,8 +14,15 @@ import (
var actionCacheLog = logger.New("workflow:action_cache")
const (
- // CacheFileName is the name of the cache file in .github/aw/.
- CacheFileName = "actions-lock.json"
+ // CacheFileName is the name of the lock file in .github/workflows/.
+ CacheFileName = "aw-lock.json"
+
+ // LegacyCacheFileName is the old name of the lock file in .github/aw/.
+ // Used for backward-compatible loading and migration codemods.
+ LegacyCacheFileName = "actions-lock.json"
+
+ // awLockFileVersion is the current version of the aw-lock.json format.
+ awLockFileVersion = "1"
)
// ActionCacheEntry represents a cached action pin resolution.
@@ -27,54 +34,120 @@ type ActionCacheEntry struct {
ActionDescription string `json:"action_description,omitempty"` // cached description from action.yml
}
+// ContainerPinEntry represents a cached container image pin resolution.
+type ContainerPinEntry struct {
+ Image string `json:"image"`
+ Digest string `json:"digest"`
+}
+
+// awLockFileFormat is the on-disk representation of aw-lock.json.
+type awLockFileFormat struct {
+ Version string `json:"version"`
+ Actions map[string]ActionCacheEntry `json:"actions"`
+ Containers map[string]ContainerPinEntry `json:"containers,omitempty"`
+}
+
+// legacyActionsLockFormat is the on-disk representation of the old actions-lock.json.
+type legacyActionsLockFormat struct {
+ Entries map[string]ActionCacheEntry `json:"entries"`
+}
+
// ActionCache manages cached action pin resolutions.
type ActionCache struct {
- Entries map[string]ActionCacheEntry `json:"entries"` // key: "repo@version"
- path string
- dirty bool // tracks if cache has unsaved changes
+ Entries map[string]ActionCacheEntry // key: "repo@version"
+ Containers map[string]ContainerPinEntry // key: image name
+ path string
+ dirty bool // tracks if cache has unsaved changes
}
// NewActionCache creates a new action cache instance
func NewActionCache(repoRoot string) *ActionCache {
- cachePath := filepath.Join(repoRoot, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(repoRoot, ".github", "workflows", CacheFileName)
actionCacheLog.Printf("Creating action cache with path: %s", cachePath)
return &ActionCache{
- Entries: make(map[string]ActionCacheEntry),
- path: cachePath,
+ Entries: make(map[string]ActionCacheEntry),
+ Containers: make(map[string]ContainerPinEntry),
+ path: cachePath,
// dirty is initialized to false (zero value)
}
}
-// Load loads the cache from disk
+// Load loads the cache from disk.
+// It first tries the new JSON format at .github/workflows/aw-lock.json.
+// If that file does not exist, it falls back to the legacy JSON format at
+// .github/aw/actions-lock.json for backward compatibility.
func (c *ActionCache) Load() error {
actionCacheLog.Printf("Loading action cache from: %s", c.path)
data, err := os.ReadFile(c.path)
if err != nil {
- if os.IsNotExist(err) {
- // Cache file doesn't exist yet, that's OK
- actionCacheLog.Print("Cache file does not exist, starting with empty cache")
- return nil
+ if !os.IsNotExist(err) {
+ actionCacheLog.Printf("Failed to read cache file: %v", err)
+ return err
}
- actionCacheLog.Printf("Failed to read cache file: %v", err)
- return err
+ // New path doesn't exist — try legacy path for backward compatibility.
+ legacyPath := legacyCachePath(c.path)
+ actionCacheLog.Printf("Cache file not found; trying legacy path: %s", legacyPath)
+ data, err = os.ReadFile(legacyPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ actionCacheLog.Print("Cache file does not exist, starting with empty cache")
+ return nil
+ }
+ actionCacheLog.Printf("Failed to read legacy cache file: %v", err)
+ return err
+ }
+ actionCacheLog.Printf("Loaded from legacy cache path: %s", legacyPath)
+ return c.loadLegacyJSON(data)
}
- if err := json.Unmarshal(data, c); err != nil {
- actionCacheLog.Printf("Failed to unmarshal cache data: %v", err)
+ var lf awLockFileFormat
+ if err := json.Unmarshal(data, &lf); err != nil {
+ actionCacheLog.Printf("Failed to unmarshal JSON cache data: %v", err)
return err
}
+ if lf.Actions != nil {
+ c.Entries = lf.Actions
+ }
+ if lf.Containers != nil {
+ c.Containers = lf.Containers
+ }
+
// Mark cache as clean after successful load (it matches disk state)
c.dirty = false
- actionCacheLog.Printf("Successfully loaded cache with %d entries", len(c.Entries))
+ actionCacheLog.Printf("Successfully loaded cache with %d action entries", len(c.Entries))
+ return nil
+}
+
+// loadLegacyJSON populates the cache from the old actions-lock.json format.
+func (c *ActionCache) loadLegacyJSON(data []byte) error {
+ var legacy legacyActionsLockFormat
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ actionCacheLog.Printf("Failed to unmarshal legacy JSON cache data: %v", err)
+ return err
+ }
+ if legacy.Entries != nil {
+ c.Entries = legacy.Entries
+ }
+ c.dirty = false
+ actionCacheLog.Printf("Successfully loaded legacy cache with %d entries", len(c.Entries))
return nil
}
-// Save saves the cache to disk with sorted entries
-// If the cache is empty, the file is not created or is deleted if it exists
-// Deduplicates entries by keeping only the most precise version reference for each repo+SHA combination
-// Only saves if the cache has been modified (dirty flag is true)
+// legacyCachePath derives the old .github/aw/actions-lock.json path from
+// the new .github/workflows/aw-lock.json path.
+func legacyCachePath(newPath string) string {
+ dir := filepath.Dir(newPath) // .github/workflows
+ repoRoot := filepath.Dir(dir) // .github
+ repoRoot = filepath.Dir(repoRoot) // repo root
+ return filepath.Join(repoRoot, ".github", "aw", LegacyCacheFileName)
+}
+
+// Save saves the cache to disk with sorted entries.
+// If the cache is empty, the file is not created or is deleted if it exists.
+// Deduplicates entries by keeping only the most precise version reference for each repo+SHA combination.
+// Only saves if the cache has been modified (dirty flag is true).
func (c *ActionCache) Save() error {
// Skip saving if cache hasn't been modified
if !c.dirty {
@@ -85,7 +158,7 @@ func (c *ActionCache) Save() error {
actionCacheLog.Printf("Saving action cache to: %s with %d entries", c.path, len(c.Entries))
// If cache is empty, skip saving and delete the file if it exists
- if len(c.Entries) == 0 {
+ if len(c.Entries) == 0 && len(c.Containers) == 0 {
actionCacheLog.Print("Cache is empty, skipping file creation")
// Remove the file if it exists
if _, err := os.Stat(c.path); err == nil {
@@ -109,8 +182,8 @@ func (c *ActionCache) Save() error {
return err
}
- // Marshal with sorted entries
- data, err := c.marshalSorted()
+ // Marshal with sorted entries in JSON format
+ data, err := c.marshalSortedJSON()
if err != nil {
actionCacheLog.Printf("Failed to marshal cache data: %v", err)
return err
@@ -129,43 +202,71 @@ func (c *ActionCache) Save() error {
return nil
}
-// marshalSorted marshals the cache with entries sorted by key
-func (c *ActionCache) marshalSorted() ([]byte, error) {
- // Extract and sort the keys
- keys := make([]string, 0, len(c.Entries))
+// marshalSortedJSON marshals the cache as JSON with sorted action entries.
+func (c *ActionCache) marshalSortedJSON() ([]byte, error) {
+ // Sort action keys
+ actionKeys := make([]string, 0, len(c.Entries))
for key := range c.Entries {
- keys = append(keys, key)
+ actionKeys = append(actionKeys, key)
+ }
+ sort.Strings(actionKeys)
+
+ // Sort container keys
+ containerKeys := make([]string, 0, len(c.Containers))
+ for key := range c.Containers {
+ containerKeys = append(containerKeys, key)
}
- sort.Strings(keys)
+ sort.Strings(containerKeys)
- // Manually construct JSON with sorted keys
+ // Build the structure with sorted keys manually so JSON output is sorted.
var result []byte
- result = append(result, []byte("{\n \"entries\": {\n")...)
+ result = append(result, []byte("{\n \"version\": \""+awLockFileVersion+"\",\n \"actions\": {\n")...)
- for i, key := range keys {
+ for i, key := range actionKeys {
entry := c.Entries[key]
-
- // Marshal the entry
entryJSON, err := json.MarshalIndent(entry, " ", " ")
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("marshaling action entry %q: %w", key, err)
}
-
- // Add the key and entry
- result = append(result, []byte(" \""+key+"\": ")...)
+ result = append(result, []byte(" \""+jsonEscapeString(key)+"\": ")...)
result = append(result, entryJSON...)
-
- // Add comma if not the last entry
- if i < len(keys)-1 {
+ if i < len(actionKeys)-1 {
result = append(result, ',')
}
result = append(result, '\n')
}
- result = append(result, []byte(" }\n}")...)
+ result = append(result, []byte(" }")...)
+
+ if len(containerKeys) > 0 {
+ result = append(result, []byte(",\n \"containers\": {\n")...)
+ for i, key := range containerKeys {
+ entry := c.Containers[key]
+ entryJSON, err := json.MarshalIndent(entry, " ", " ")
+ if err != nil {
+ return nil, fmt.Errorf("marshaling container entry %q: %w", key, err)
+ }
+ result = append(result, []byte(" \""+jsonEscapeString(key)+"\": ")...)
+ result = append(result, entryJSON...)
+ if i < len(containerKeys)-1 {
+ result = append(result, ',')
+ }
+ result = append(result, '\n')
+ }
+ result = append(result, []byte(" }")...)
+ }
+
+ result = append(result, []byte("\n}")...)
return result, nil
}
+// jsonEscapeString returns a JSON-safe string (escaping backslash and double-quote).
+func jsonEscapeString(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ s = strings.ReplaceAll(s, `"`, `\"`)
+ return s
+}
+
// Delete removes the cache entry for the given repo and version.
// It first tries the canonical formatted key, then falls back to scanning all
// entries for a matching repo+version pair to handle key/version mismatches.
@@ -356,6 +457,14 @@ func (c *ActionCache) GetCachePath() string {
return c.path
}
+// MarkDirty forces the cache to be saved on the next call to Save,
+// even if no entries have changed via Set/Delete. This is useful when
+// the cache was loaded from a legacy format and needs to be written to the
+// new location/format.
+func (c *ActionCache) MarkDirty() {
+ c.dirty = true
+}
+
// deduplicateEntries removes duplicate entries by keeping only the most precise version reference
// for each repo+SHA combination. For example, if both "actions/cache@v4" and "actions/cache@v4.3.0"
// point to the same SHA and version, only "actions/cache@v4.3.0" is kept.
diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go
index f5bf5acb588..58d33062fe5 100644
--- a/pkg/workflow/action_cache_test.go
+++ b/pkg/workflow/action_cache_test.go
@@ -51,7 +51,7 @@ func TestActionCacheSaveLoad(t *testing.T) {
}
// Verify file exists
- cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
t.Fatalf("Cache file was not created at %s", cachePath)
}
@@ -97,7 +97,7 @@ func TestActionCacheGetCachePath(t *testing.T) {
tmpDir := testutil.TempDir(t, "test-*")
cache := NewActionCache(tmpDir)
- expectedPath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ expectedPath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
if cache.GetCachePath() != expectedPath {
t.Errorf("Expected cache path '%s', got '%s'", expectedPath, cache.GetCachePath())
}
@@ -118,7 +118,7 @@ func TestActionCacheTrailingNewline(t *testing.T) {
}
// Read the file and check for trailing newline
- cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("Failed to read cache file: %v", err)
@@ -149,7 +149,7 @@ func TestActionCacheSortedEntries(t *testing.T) {
}
// Read the file content
- cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("Failed to read cache file: %v", err)
@@ -180,15 +180,15 @@ func TestActionCacheSortedEntries(t *testing.T) {
}
// Also verify the file is valid JSON
- var loadedCache ActionCache
- err = json.Unmarshal(data, &loadedCache)
+ var loadedFile awLockFileFormat
+ err = json.Unmarshal(data, &loadedFile)
if err != nil {
t.Fatalf("Saved cache is not valid JSON: %v", err)
}
// Verify all entries are present
- if len(loadedCache.Entries) != 5 {
- t.Errorf("Expected 5 entries, got %d", len(loadedCache.Entries))
+ if len(loadedFile.Actions) != 5 {
+ t.Errorf("Expected 5 entries, got %d", len(loadedFile.Actions))
}
}
@@ -216,7 +216,7 @@ func TestActionCacheEmptySaveDoesNotCreateFile(t *testing.T) {
}
// Verify file does NOT exist
- cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
t.Error("Empty cache should not create a file")
}
@@ -235,7 +235,7 @@ func TestActionCacheEmptySaveDeletesExistingFile(t *testing.T) {
}
// Verify file exists
- cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(tmpDir, ".github", "workflows", CacheFileName)
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
t.Fatal("Cache file should exist after saving with entries")
}
diff --git a/pkg/workflow/action_sha_validation_test.go b/pkg/workflow/action_sha_validation_test.go
index 7922187f444..ab385dc71bb 100644
--- a/pkg/workflow/action_sha_validation_test.go
+++ b/pkg/workflow/action_sha_validation_test.go
@@ -221,7 +221,7 @@ jobs:
cache.Set("actions/checkout", "v5", "93cb6efe18208431cddfb8368fd83d5badbf9bfd")
// Verify cache file doesn't exist before validation
- cachePath := filepath.Join(testDir, ".github", "aw", CacheFileName)
+ cachePath := filepath.Join(testDir, ".github", "workflows", CacheFileName)
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
os.RemoveAll(filepath.Join(testDir, ".github"))
}
diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go
index 91fa861f4ab..69d61eedf88 100644
--- a/pkg/workflow/compiler_types.go
+++ b/pkg/workflow/compiler_types.go
@@ -96,7 +96,7 @@ func NewCompiler(opts ...CompilerOption) *Compiler {
version := GetVersion()
// Auto-detect git repository root for action cache path resolution
- // This ensures actions-lock.json is created at repo root regardless of CWD
+ // This ensures aw-lock.json is created at repo root regardless of CWD
gitRoot := findGitRoot()
// Create compiler with defaults
diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json
index 4432457f3b3..8a2e1b71801 100644
--- a/pkg/workflow/data/action_pins.json
+++ b/pkg/workflow/data/action_pins.json
@@ -20,8 +20,7 @@
"description": "The owner and repository name. e.g.) Codertocat/Hello-World",
"default": "${{ github.repository }}"
}
- },
- "action_description": "Add labels to an issue or a pull request."
+ }
},
"actions/ai-inference@v2.0.8": {
"repo": "actions/ai-inference",
diff --git a/pkg/workflow/safe_outputs_actions.go b/pkg/workflow/safe_outputs_actions.go
index ab9cf21a894..efbf34a6b71 100644
--- a/pkg/workflow/safe_outputs_actions.go
+++ b/pkg/workflow/safe_outputs_actions.go
@@ -166,7 +166,7 @@ func parseActionUsesField(uses string) (*actionRef, error) {
//
// Resolution priority (highest wins):
// 1. Inputs already specified in the frontmatter (config.Inputs != nil)
-// 2. Inputs cached in the ActionCache (actions-lock.json)
+// 2. Inputs cached in the ActionCache (aw-lock.json)
// 3. Inputs fetched from the remote action.yml (result cached for future runs)
//
// When available, the action reference is pinned to a commit SHA for security;
@@ -220,7 +220,7 @@ func (c *Compiler) fetchAndParseActionYAML(actionName string, config *SafeOutput
if !inputsFromFrontmatter {
// Check the ActionCache for previously-fetched inputs before going to the network.
// The cache key uses the original version tag from the `uses:` field (ref.Ref, e.g.
- // "v1") which matches the key stored in actions-lock.json.
+ // "v1") which matches the key stored in aw-lock.json.
if data.ActionCache != nil {
if cachedInputs, ok := data.ActionCache.GetInputs(ref.Repo, ref.Ref); ok {
safeOutputActionsLog.Printf("Using cached inputs for %q (%s@%s)", actionName, ref.Repo, ref.Ref)
diff --git a/scratchpad/debugging-action-pinning.md b/scratchpad/debugging-action-pinning.md
index 4574b2b8b6c..95c8fa7f755 100644
--- a/scratchpad/debugging-action-pinning.md
+++ b/scratchpad/debugging-action-pinning.md
@@ -69,17 +69,17 @@ DEBUG=workflow:action_* gh aw compile 2> debug.log
### 2. Inspect the Action Cache
-The action cache is stored at `.github/aw/actions-lock.json`. Examine it for duplicate entries:
+The action cache is stored at `.github/workflows/aw-lock.json`. Examine it for duplicate entries:
```bash
# Pretty-print the cache
-cat .github/aw/actions-lock.json | jq .
+cat .github/workflows/aw-lock.json | jq .
# Find entries for a specific action
-cat .github/aw/actions-lock.json | jq '.entries | to_entries[] | select(.value.repo == "actions/github-script")'
+cat .github/workflows/aw-lock.json | jq '.actions | to_entries[] | select(.value.repo == "actions/github-script")'
# Check for duplicate SHAs
-cat .github/aw/actions-lock.json | jq -r '.entries | to_entries[] | "\(.value.sha) \(.key)"' | sort | uniq -d -w 40
+cat .github/workflows/aw-lock.json | jq -r '.actions | to_entries[] | "\(.value.sha) \(.key)"' | sort | uniq -d -w 40
```
### 3. Check for Version Aliases
@@ -120,7 +120,7 @@ workflow:action_pins Dynamic resolution succeeded: actions/github-script@v8 →
#### Cache Operations
```
-workflow:action_cache Loading action cache from: .github/aw/actions-lock.json
+workflow:action_cache Loading action cache from: .github/workflows/aw-lock.json
workflow:action_cache Successfully loaded cache with 15 entries
workflow:action_cache Setting cache entry: key=actions/github-script@v8, sha=ed597411...
workflow:action_cache Deduplicating: keeping actions/github-script@v8.0.0, removing actions/github-script@v8
@@ -176,7 +176,7 @@ If you suspect cache corruption, clear it and recompile:
```bash
# Remove the cache file
-rm .github/aw/actions-lock.json
+rm .github/workflows/aw-lock.json
# Recompile all workflows
gh aw compile
@@ -223,7 +223,7 @@ If you're using shared workflows that reference actions differently than your lo
CI environments may use a fresh cache on each run, while local development persists the cache:
1. CI may show different version comments than local
-2. Solution: Commit `.github/aw/actions-lock.json` to version control
+2. Solution: Commit `.github/workflows/aw-lock.json` to version control
3. This ensures consistent resolution across environments
### Scenario 3: Upstream Version Changes
@@ -296,10 +296,10 @@ All workflow files must use full semantic versioning for actions:
### 2. Commit the Action Cache
-Add `.github/aw/actions-lock.json` to version control:
+Add `.github/workflows/aw-lock.json` to version control:
```bash
-git add .github/aw/actions-lock.json
+git add .github/workflows/aw-lock.json
git commit -m "chore: add action cache for consistent pinning"
```
@@ -311,9 +311,9 @@ Add to your maintenance workflow:
```bash
# Monthly: clear and regenerate cache
-rm .github/aw/actions-lock.json
+rm .github/workflows/aw-lock.json
gh aw compile
-git add .github/aw/actions-lock.json
+git add .github/workflows/aw-lock.json
git commit -m "chore: refresh action cache"
```
@@ -356,13 +356,13 @@ Track cache evolution across compiles:
```bash
# Before
-cp .github/aw/actions-lock.json before.json
+cp .github/workflows/aw-lock.json before.json
# Compile
gh aw compile
# After
-cp .github/aw/actions-lock.json after.json
+cp .github/workflows/aw-lock.json after.json
# Compare
diff -u before.json after.json
@@ -374,10 +374,10 @@ Check for unexpected cache entries:
```bash
# List all SHAs with their version tags
-jq -r '.entries | to_entries[] | "\(.value.sha) \(.value.version) \(.key)"' .github/aw/actions-lock.json | sort
+jq -r '.actions | to_entries[] | "\(.value.sha) \(.value.version) \(.key)"' .github/workflows/aw-lock.json | sort
# Find duplicate SHAs
-jq -r '.entries | to_entries[] | .value.sha' .github/aw/actions-lock.json | sort | uniq -d
+jq -r '.actions | to_entries[] | .value.sha' .github/workflows/aw-lock.json | sort | uniq -d
```
## Related Documentation
@@ -389,10 +389,10 @@ jq -r '.entries | to_entries[] | .value.sha' .github/aw/actions-lock.json | sort
## Troubleshooting Checklist
- [ ] Enabled debug logging: `DEBUG=workflow:action_* gh aw compile`
-- [ ] Checked `.github/aw/actions-lock.json` for duplicate entries
+- [ ] Checked `.github/workflows/aw-lock.json` for duplicate entries
- [ ] Verified version tags point to same SHA via GitHub API
- [ ] Searched workflows for inconsistent version formats
-- [ ] Cleared cache and recompiled: `rm .github/aw/actions-lock.json && gh aw compile`
+- [ ] Cleared cache and recompiled: `rm .github/workflows/aw-lock.json && gh aw compile`
- [ ] Checked for upstream version tag changes
- [ ] Reviewed action_pins.json for canonical versions
- [ ] Consulted team on preferred version format
@@ -404,7 +404,7 @@ jq -r '.entries | to_entries[] | .value.sha' .github/aw/actions-lock.json | sort
If the issue persists after following this guide:
1. Capture debug logs: `DEBUG=workflow:action_* gh aw compile 2> debug.log`
-2. Export cache state: `cat .github/aw/actions-lock.json > cache.json`
+2. Export cache state: `cat .github/workflows/aw-lock.json > cache.json`
3. List workflow action references: `grep -r "uses: " .github/workflows/ > actions.txt`
4. Create an issue with these artifacts attached
diff --git a/scratchpad/layout.md b/scratchpad/layout.md
index ccde7cd89f2..ddf6ce6b081 100644
--- a/scratchpad/layout.md
+++ b/scratchpad/layout.md
@@ -120,8 +120,8 @@ Common file paths referenced in workflow files:
| Path | Type | Description | Usage Context |
|------|------|-------------|---------------|
-| `.github/workflows/` | Directory | Workflow definition directory | Contains all `.md` and `.lock.yml` workflow files |
-| `.github/aw/` | Directory | Agentic workflow configuration | Contains `actions-lock.json` and other configs |
+| `.github/workflows/` | Directory | Workflow definition directory | Contains all `.md`, `.lock.yml` workflow files, and `aw-lock.json` |
+| `.github/aw/` | Directory | Agentic workflow configuration | Contains other configs and legacy `.github/aw/actions-lock.json` path |
| `.github/agents/` | Directory | Custom agent definitions | Contains agent markdown files (e.g., `test-agent.md`) |
| `/tmp/gh-aw/` | Directory | Temporary workflow data | Root temporary directory for all workflow artifacts |
| `/tmp/gh-aw/agent/` | Directory | Agent execution workspace | Agent's working directory during execution |
@@ -381,11 +381,12 @@ GitHub Actions runner images used across compiled workflows:
.github/
├── agents/ # Custom agent definitions
│ └── test-agent.md
-├── aw/ # Workflow configuration
-│ └── actions-lock.json
+├── aw/ # Legacy workflow configuration
+│ └── actions-lock.json # Legacy (migrated to .github/workflows/aw-lock.json)
└── workflows/ # Workflow files
├── *.md # Source workflows
├── *.lock.yml # Compiled workflows
+ ├── aw-lock.json # Canonical actions lock file
└── shared/ # Shared workflow components
````
diff --git a/scripts/sync-action-pins/main.go b/scripts/sync-action-pins/main.go
new file mode 100644
index 00000000000..1ecd4df60f7
--- /dev/null
+++ b/scripts/sync-action-pins/main.go
@@ -0,0 +1,85 @@
+// sync-action-pins converts .github/workflows/aw-lock.json (JSON) into
+// pkg/workflow/data/action_pins.json (the embedded JSON fallback used by the
+// compiler for SHA pinning when the GitHub API is unavailable).
+//
+// It also supports the legacy source path .github/aw/actions-lock.json for
+// repositories that have not yet migrated.
+//
+// Usage: go run ./scripts/sync-action-pins
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/github/gh-aw/pkg/workflow"
+)
+
+func main() {
+ sourcePaths := []string{
+ ".github/workflows/aw-lock.json",
+ ".github/aw/actions-lock.json",
+ }
+
+ foundSource := false
+ for _, path := range sourcePaths {
+ if _, err := os.Stat(path); err == nil {
+ foundSource = true
+ break
+ } else if !os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "Error checking %s: %v\n", path, err)
+ os.Exit(1)
+ }
+ }
+
+ if !foundSource {
+ fmt.Fprintf(os.Stderr, "Error: no action cache source file found; expected %q or %q\n", sourcePaths[0], sourcePaths[1])
+ os.Exit(1)
+ }
+
+ cache := workflow.NewActionCache(".")
+ if err := cache.Load(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: failed to load action cache: %v\n", err)
+ os.Exit(1)
+ }
+
+ type actionPin struct {
+ Repo string `json:"repo"`
+ Version string `json:"version"`
+ SHA string `json:"sha"`
+ Inputs map[string]*workflow.ActionYAMLInput `json:"inputs,omitempty"`
+ // ActionDescription is intentionally omitted: action_pins.json is the embedded
+ // fallback used only for SHA pinning; descriptions are only needed at compile time
+ // from the user-facing aw-lock.json cache and are not part of ActionPin.
+ }
+ type actionPinsData struct {
+ Entries map[string]actionPin `json:"entries"`
+ }
+
+ entries := make(map[string]actionPin, len(cache.Entries))
+ for key, e := range cache.Entries {
+ entries[key] = actionPin{
+ Repo: e.Repo,
+ Version: e.Version,
+ SHA: e.SHA,
+ Inputs: e.Inputs,
+ }
+ }
+
+ data := actionPinsData{Entries: entries}
+ out, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error marshaling action_pins.json: %v\n", err)
+ os.Exit(1)
+ }
+ out = append(out, '\n')
+
+ dest := "pkg/workflow/data/action_pins.json"
+ if err := os.WriteFile(dest, out, 0644); err != nil {
+ fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", dest, err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("✓ Synced %d action pins to %s\n", len(entries), dest)
+}