From 7e172d08946b37687b21d355d52f4f4b787b8e31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:18:46 +0000 Subject: [PATCH 1/4] Add action-pin mapping support in aw.json Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 43 +++++++++++ pkg/actionpins/actionpins_internal_test.go | 77 +++++++++++++++++++ pkg/actionpins/spec_test.go | 41 ++++++++++ pkg/parser/schemas/repo_config_schema.json | 12 +++ .../compiler_orchestrator_workflow.go | 1 + pkg/workflow/compiler_repo_config.go | 12 +++ pkg/workflow/compiler_string_api.go | 1 + pkg/workflow/compiler_types.go | 2 + pkg/workflow/compiler_types_test.go | 23 ++++++ pkg/workflow/repo_config.go | 18 +++-- pkg/workflow/repo_config_test.go | 51 ++++++++++++ 11 files changed, 276 insertions(+), 5 deletions(-) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 6eec826b540..b9089b146cc 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -104,6 +104,10 @@ type PinContext struct { // host and fail, so silently falling back to bundled pins would produce unverified // SHA pins and mask the real misconfiguration. SkipHardcodedFallback bool + // Mappings redirects action repository@version references to replacement + // repository@version references before pin resolution. Keys and values use + // the format "owner/repo@ref". Set from aw.json action_pins. + Mappings map[string]string } var ( @@ -311,6 +315,9 @@ func ResolveActionPin(actionRepo, version string, ctx *PinContext) (string, erro } actionPinsLog.Printf("Resolving action pin: repo=%s, version=%s, strict_mode=%t", actionRepo, version, ctx.StrictMode) + // Apply repository/version mapping from aw.json action_pins before resolution. + actionRepo, version = applyActionPinMapping(actionRepo, version, ctx) + isAlreadySHA := gitutil.IsValidFullSHA(version) if pinnedRef, ok := resolveActionPinDynamically(actionRepo, version, isAlreadySHA, ctx); ok { return pinnedRef, nil @@ -470,3 +477,39 @@ func ResolveLatestActionPin(repo string, ctx *PinContext) string { } return pinnedRef } + +// applyActionPinMapping checks ctx.Mappings for a redirect of actionRepo@version and +// returns the (possibly updated) repo and version. An informational message is printed +// to stderr the first time each mapping is applied (deduplicated via ctx.Warnings). +func applyActionPinMapping(actionRepo, version string, ctx *PinContext) (string, string) { + if len(ctx.Mappings) == 0 { + return actionRepo, version + } + + cacheKey := FormatCacheKey(actionRepo, version) + mapped, ok := ctx.Mappings[cacheKey] + if !ok { + return actionRepo, version + } + + mappedRepo := ExtractRepo(mapped) + mappedVersion := ExtractVersion(mapped) + if mappedRepo == "" || mappedVersion == "" { + actionPinsLog.Printf("Invalid action_pins mapping value %q for key %q; skipping", mapped, cacheKey) + return actionRepo, version + } + + actionPinsLog.Printf("Action pin mapping applied: %s → %s", cacheKey, mapped) + + // Emit informational message once per unique (source → target) pair. + initWarnings(ctx) + notifyKey := "map:" + cacheKey + if !ctx.Warnings[notifyKey] { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage( + fmt.Sprintf("Action pin mapping applied: %s → %s", cacheKey, mapped), + )) + ctx.Warnings[notifyKey] = true + } + + return mappedRepo, mappedVersion +} diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index 83f7afd01cf..b5cd1a7f64d 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -4,6 +4,7 @@ package actionpins import ( "context" + "strings" "testing" "github.com/github/gh-aw/pkg/constants" @@ -240,3 +241,79 @@ func TestResolveActionPinFromHardcodedPins_SkipHardcodedFallback(t *testing.T) { assert.NotEmpty(t, result, "Expected a pinned result when SkipHardcodedFallback is not set") }) } + +func TestApplyActionPinMapping_NoMapping(t *testing.T) { + ctx := &PinContext{Warnings: make(map[string]bool)} + + repo, version := applyActionPinMapping("actions/checkout", "v4", ctx) + + assert.Equal(t, "actions/checkout", repo, "repo should be unchanged when no mapping exists") + assert.Equal(t, "v4", version, "version should be unchanged when no mapping exists") +} + +func TestApplyActionPinMapping_AppliesMapping(t *testing.T) { + ctx := &PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/checkout@v4": "acme-corp/checkout@v4", + }, + } + + repo, version := applyActionPinMapping("actions/checkout", "v4", ctx) + + assert.Equal(t, "acme-corp/checkout", repo, "repo should be replaced by mapping") + assert.Equal(t, "v4", version, "version should be replaced by mapping") +} + +func TestApplyActionPinMapping_OnlyMatchesExact(t *testing.T) { + ctx := &PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/checkout@v4": "acme-corp/checkout@v4", + }, + } + + // Different version — should not match. + repo, version := applyActionPinMapping("actions/checkout", "v5", ctx) + + assert.Equal(t, "actions/checkout", repo, "repo should be unchanged when version does not match") + assert.Equal(t, "v5", version, "version should be unchanged when version does not match") +} + +func TestApplyActionPinMapping_DeduplicatesInfoMessage(t *testing.T) { + ctx := &PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/checkout@v4": "acme-corp/checkout@v4", + }, + } + + applyActionPinMapping("actions/checkout", "v4", ctx) + applyActionPinMapping("actions/checkout", "v4", ctx) + + notifyKey := "map:actions/checkout@v4" + assert.True(t, ctx.Warnings[notifyKey], "Expected notification key to be marked as seen") + // Count should be exactly one entry (deduplication). + mapNotifications := 0 + for k := range ctx.Warnings { + if strings.HasPrefix(k, "map:") { + mapNotifications++ + } + } + assert.Equal(t, 1, mapNotifications, "Expected exactly one deduplicated mapping notification") +} + +func TestApplyActionPinMapping_InvalidMappingValueSkipped(t *testing.T) { + ctx := &PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/checkout@v4": "no-at-sign", // invalid: missing @version + }, + } + + repo, version := applyActionPinMapping("actions/checkout", "v4", ctx) + + // Invalid value should be silently skipped. + assert.Equal(t, "actions/checkout", repo, "repo should be unchanged for invalid mapping value") + assert.Equal(t, "v4", version, "version should be unchanged for invalid mapping value") +} diff --git a/pkg/actionpins/spec_test.go b/pkg/actionpins/spec_test.go index 109c2dde4d6..90da668ec9e 100644 --- a/pkg/actionpins/spec_test.go +++ b/pkg/actionpins/spec_test.go @@ -590,3 +590,44 @@ func TestSpec_PublicAPI_RecordResolutionFailure_WarningDedup(t *testing.T) { assert.Len(t, failures, 2, "second call should append another resolution failure") assert.True(t, ctx.Warnings[cacheKey], "warning dedup key should remain set after second call") } + +// TestSpec_PublicAPI_ResolveActionPin_AppliesMapping validates that ctx.Mappings redirects +// action resolution to the mapped repository and version. +func TestSpec_PublicAPI_ResolveActionPin_AppliesMapping(t *testing.T) { + // actions/checkout is in the embedded pins; acme-corp/checkout is not. + // After mapping, resolution should succeed using the mapped repo's pins. + checkoutPins := actionpins.GetActionPinsByRepo("actions/checkout") + require.NotEmpty(t, checkoutPins, "prerequisite: embedded pins for actions/checkout must exist") + + t.Run("mapping redirects to a known pinned repo", func(t *testing.T) { + ctx := &actionpins.PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/setup-node@v4": "actions/checkout@v4", + }, + } + // Request setup-node@v4 which is mapped to checkout@v4 — both exist in embedded pins. + result, err := actionpins.ResolveActionPin("actions/setup-node", "v4", ctx) + require.NoError(t, err) + assert.Contains(t, result, "actions/checkout@", "result should reference the mapped repo") + }) + + t.Run("mapping notification key is recorded in warnings", func(t *testing.T) { + ctx := &actionpins.PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/setup-node@v4": "actions/checkout@v4", + }, + } + _, _ = actionpins.ResolveActionPin("actions/setup-node", "v4", ctx) + assert.True(t, ctx.Warnings["map:actions/setup-node@v4"], "mapping notification key should be set in warnings") + }) + + t.Run("no mapping leaves resolution unchanged", func(t *testing.T) { + ctx := &actionpins.PinContext{Warnings: make(map[string]bool)} + + result, err := actionpins.ResolveActionPin("actions/checkout", "v4", ctx) + require.NoError(t, err) + assert.Contains(t, result, "actions/checkout@", "result should reference the original repo when no mapping exists") + }) +} diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index 1311df33249..8c6b4390a61 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -7,6 +7,18 @@ "type": "object", "additionalProperties": false, "properties": { + "action_pins": { + "description": "Mapping of action repository@version references to replacement repository@version references. Enables enterprises running in a private cloud to redirect actions to internal mirrors. Keys and values must use the format 'owner/repo@ref'.", + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[A-Za-z0-9._/+:-]+$", + "description": "Replacement repository@version to use instead of the source action." + }, + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[A-Za-z0-9._/+:-]+$" + } + }, "ghes": { "description": "Enable GitHub Enterprise Server (GHES) compatibility mode. When true, the compiler emits GHES-compatible artifact action versions (upload-artifact@v3, download-artifact@v3) instead of the latest v7/v8 which are not supported on GHES.", "type": "boolean" diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 563b76806cb..506e89b6c6c 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -227,6 +227,7 @@ func (c *Compiler) attachSharedActionResolver(workflowData *WorkflowData) { workflowData.ActionCache = actionCache workflowData.ActionResolver = actionResolver workflowData.ActionPinWarnings = c.actionPinWarnings + workflowData.ActionPinMappings = c.getActionPinMappings() } func (c *Compiler) mergeImportedWorkflowConfiguration(ctx *workflowBuildContext) error { diff --git a/pkg/workflow/compiler_repo_config.go b/pkg/workflow/compiler_repo_config.go index 056324ba4de..f9346aeb2d5 100644 --- a/pkg/workflow/compiler_repo_config.go +++ b/pkg/workflow/compiler_repo_config.go @@ -29,3 +29,15 @@ func (c *Compiler) getCompiledProjectUTCOffset() string { } return strings.TrimSpace(repoConfig.UTC) } + +// getActionPinMappings returns the action-pin mapping table from aw.json, or nil +// when the file is absent, contains no mappings, or fails to load. +// The returned map is safe to read concurrently but must not be mutated. +func (c *Compiler) getActionPinMappings() map[string]string { + repoConfig, err := c.loadRepoConfig() + if err != nil || repoConfig == nil || len(repoConfig.ActionPins) == 0 { + return nil + } + repoConfigLog.Printf("getActionPinMappings: loaded %d action-pin mapping(s) from aw.json", len(repoConfig.ActionPins)) + return repoConfig.ActionPins +} diff --git a/pkg/workflow/compiler_string_api.go b/pkg/workflow/compiler_string_api.go index d7c744791c6..d88edded5be 100644 --- a/pkg/workflow/compiler_string_api.go +++ b/pkg/workflow/compiler_string_api.go @@ -188,6 +188,7 @@ func (c *Compiler) ParseWorkflowString(content string, virtualPath string) (*Wor workflowData.ActionCache = actionCache workflowData.ActionResolver = actionResolver workflowData.ActionPinWarnings = c.actionPinWarnings + workflowData.ActionPinMappings = c.getActionPinMappings() // Extract YAML configuration sections if err := c.extractYAMLSections(parseResult.frontmatterResult.Frontmatter, workflowData); err != nil { diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 34b1425ecdc..351e4aa8e5b 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -607,6 +607,7 @@ type WorkflowData struct { KnownActionCredentialEnvVars map[string]struct{} // env vars for clean_known_action_credentials.sh; keyed by GH_AW_CLEAN_* names; nil when no known credential-leaking actions are detected ModelMappings map[string][]string // merged model alias map (builtins + imported workflow aliases + main frontmatter overrides, in priority order); NOT yet emitted to AWF config JSON — pending AWF firewall support (config.models) ModelCosts map[string]any // model pricing data from frontmatter `models` field (providers structure); merged with built-in models.json at runtime by generate_aw_info.cjs + ActionPinMappings map[string]string // action-pin redirect table from aw.json action_pins: maps "owner/repo@version" → "owner/repo@version" } // PinContext returns an actionpins.PinContext backed by this WorkflowData. @@ -625,6 +626,7 @@ func (d *WorkflowData) PinContext() *actionpins.PinContext { EnforcePinned: true, AllowActionRefs: d.AllowActionRefs, Warnings: d.ActionPinWarnings, + Mappings: d.ActionPinMappings, RecordResolutionFailure: func(f actionpins.ResolutionFailure) { d.ActionResolutionFailures = append(d.ActionResolutionFailures, GHAWManifestResolutionFailure{ Repo: f.Repo, diff --git a/pkg/workflow/compiler_types_test.go b/pkg/workflow/compiler_types_test.go index 44fd2660cbb..5ffc4ad2c31 100644 --- a/pkg/workflow/compiler_types_test.go +++ b/pkg/workflow/compiler_types_test.go @@ -87,3 +87,26 @@ func TestWorkflowData_PinContext_SkipHardcodedFallback(t *testing.T) { assert.Nil(t, ctx) }) } + +func TestWorkflowData_PinContext_ActionPinMappings(t *testing.T) { + t.Run("propagates ActionPinMappings to PinContext", func(t *testing.T) { + mappings := map[string]string{ + "actions/checkout@v4": "acme-corp/checkout@v4", + } + d := &WorkflowData{ActionPinMappings: mappings} + + ctx := d.PinContext() + + require.NotNil(t, ctx) + assert.Equal(t, mappings, ctx.Mappings, "ActionPinMappings should be forwarded to PinContext.Mappings") + }) + + t.Run("nil ActionPinMappings results in nil Mappings", func(t *testing.T) { + d := &WorkflowData{} + + ctx := d.PinContext() + + require.NotNil(t, ctx) + assert.Nil(t, ctx.Mappings, "Mappings should be nil when ActionPinMappings is not set") + }) +} diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 04fd44ee8e1..12694937476 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -144,6 +144,12 @@ type RepoConfig struct { // and an object was provided (nil when maintenance is not configured or is // disabled). Maintenance *MaintenanceConfig + + // ActionPins maps action repository@version references to replacement + // repository@version references. Enterprises running in a private cloud + // can use this to redirect actions to internal mirrors. Keys and values + // must use the format "owner/repo@ref". + ActionPins map[string]string } // IsAutoUpgradeEnabled returns true only when auto_upgrade is explicitly set to true. @@ -160,11 +166,12 @@ func (r *RepoConfig) IsAutoUpgradeEnabled() bool { func (r *RepoConfig) UnmarshalJSON(data []byte) error { // Use an intermediate struct with json.RawMessage to defer maintenance parsing. var raw struct { - GHES bool `json:"ghes,omitempty"` - HelpCommand *bool `json:"help_command,omitempty"` // nil = use default (enabled) - UTC string `json:"utc,omitempty"` - AutoUpgrade *bool `json:"auto_upgrade,omitempty"` - Maintenance json.RawMessage `json:"maintenance,omitempty"` + GHES bool `json:"ghes,omitempty"` + HelpCommand *bool `json:"help_command,omitempty"` // nil = use default (enabled) + UTC string `json:"utc,omitempty"` + AutoUpgrade *bool `json:"auto_upgrade,omitempty"` + Maintenance json.RawMessage `json:"maintenance,omitempty"` + ActionPins map[string]string `json:"action_pins,omitempty"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -174,6 +181,7 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { r.HelpCommand = raw.HelpCommand r.UTC = strings.TrimSpace(raw.UTC) r.AutoUpgrade = raw.AutoUpgrade + r.ActionPins = raw.ActionPins if len(raw.Maintenance) == 0 || string(raw.Maintenance) == "null" { return nil diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index a3d78a47cdd..6740a8bd300 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -317,6 +317,57 @@ func TestIsAutoUpgradeEnabled_NilConfig(t *testing.T) { assert.False(t, r.IsAutoUpgradeEnabled(), "IsAutoUpgradeEnabled should return false for nil RepoConfig") } +func TestLoadRepoConfig_ActionPins(t *testing.T) { + t.Run("loads action_pins mapping", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"action_pins": {"actions/checkout@v4": "acme-corp/checkout@v4"}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json with action_pins should load without error") + require.NotNil(t, cfg.ActionPins, "action_pins should be populated") + assert.Equal(t, "acme-corp/checkout@v4", cfg.ActionPins["actions/checkout@v4"]) + }) + + t.Run("allows multiple mappings", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"action_pins": { + "actions/checkout@v4": "acme-corp/checkout@v4", + "actions/setup-node@v4": "acme-corp/setup-node@v4" + }}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err) + assert.Len(t, cfg.ActionPins, 2) + assert.Equal(t, "acme-corp/checkout@v4", cfg.ActionPins["actions/checkout@v4"]) + assert.Equal(t, "acme-corp/setup-node@v4", cfg.ActionPins["actions/setup-node@v4"]) + }) + + t.Run("action_pins absent results in nil map", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err) + assert.Nil(t, cfg.ActionPins, "action_pins should be nil when not specified") + }) + + t.Run("rejects key without version", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"action_pins": {"actions/checkout": "acme-corp/checkout@v4"}}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "key without @version should fail schema validation") + }) + + t.Run("rejects value without version", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"action_pins": {"actions/checkout@v4": "acme-corp/checkout"}}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "value without @version should fail schema validation") + }) +} + // writeAWJSON creates .github/workflows/aw.json with the given JSON content. func writeAWJSON(t *testing.T, gitRoot, content string) { t.Helper() From 40882a8d0635186e3032065d7895d84d2bed9b4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:19:44 +0000 Subject: [PATCH 2/4] Address review feedback: improve error message and fix comment typo Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 2 +- pkg/actionpins/actionpins_internal_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index b9089b146cc..8ea95b1c0e1 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -495,7 +495,7 @@ func applyActionPinMapping(actionRepo, version string, ctx *PinContext) (string, mappedRepo := ExtractRepo(mapped) mappedVersion := ExtractVersion(mapped) if mappedRepo == "" || mappedVersion == "" { - actionPinsLog.Printf("Invalid action_pins mapping value %q for key %q; skipping", mapped, cacheKey) + actionPinsLog.Printf("Invalid action_pins mapping value %q for key %q (must be in format owner/repo@ref); skipping", mapped, cacheKey) return actionRepo, version } diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index b5cd1a7f64d..fbae401e041 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -307,7 +307,7 @@ func TestApplyActionPinMapping_InvalidMappingValueSkipped(t *testing.T) { ctx := &PinContext{ Warnings: make(map[string]bool), Mappings: map[string]string{ - "actions/checkout@v4": "no-at-sign", // invalid: missing @version + "actions/checkout@v4": "no-at-sign", // invalid: missing at sign and version }, } From d9c96cca253a1f60629345752e18a49408762496 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:27:22 +0000 Subject: [PATCH 3/4] docs(adr): add draft ADR-41579 for action-pin mapping in aw.json Documents the architectural decision to add action_pins redirect mapping to aw.json for private-cloud enterprise mirror support. --- ...79-action-pin-mapping-for-private-cloud.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/adr/41579-action-pin-mapping-for-private-cloud.md diff --git a/docs/adr/41579-action-pin-mapping-for-private-cloud.md b/docs/adr/41579-action-pin-mapping-for-private-cloud.md new file mode 100644 index 00000000000..fda12ae22f7 --- /dev/null +++ b/docs/adr/41579-action-pin-mapping-for-private-cloud.md @@ -0,0 +1,51 @@ +# ADR-41579: Action-Pin Mapping for Private-Cloud Enterprises via aw.json + +**Date**: 2026-06-26 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +Enterprises operating GitHub Actions runners in private clouds cannot access public GitHub-hosted actions directly. When the `aw.json` compiler resolves action pins (e.g., `actions/checkout@v4`), it queries public pin tables and hardcoded SHA maps that are unreachable in air-gapped or internally mirrored environments. Without a way to redirect action references at the compiler layer, such enterprises must either patch action references directly in every workflow file or forgo pin enforcement entirely. The project's `aw.json` configuration file already serves as the canonical per-repository compiler configuration, making it the natural place to add a redirect table. + +### Decision + +We will add an `action_pins` map to `aw.json` that redirects action `owner/repo@ref` references to replacement `owner/repo@ref` references before pin resolution occurs. The mapping is applied as the first step of `ResolveActionPin`, so the rest of the resolution pipeline (dynamic lookup, hardcoded pins, SHA validation) operates on the mapped target. Schema validation enforces the `owner/repo@ref` format on both keys and values. The feature is intentionally not supported in workflow frontmatter to keep the configuration scoped to repository-level infrastructure concerns in `aw.json`. + +### Alternatives Considered + +#### Alternative 1: Frontmatter-level action overrides in individual workflow files + +Each workflow could declare action redirects in its own frontmatter. This is rejected because it requires updating every workflow file to add redirect entries, does not provide a single authoritative source of mappings, and conflates workflow authoring with infrastructure configuration. The PR body explicitly notes this alternative is not supported. + +#### Alternative 2: Proxy/registry endpoint configuration + +A configurable registry mirror URL could intercept all action resolution at the network layer. This would require additional network infrastructure (a proxy service or registry mirror), configuration of TLS certificates and routing, and significant complexity both in the compiler and in customer environments. The `action_pins` map approach is self-contained in `aw.json`, requires no infrastructure changes, and lets enterprises selectively redirect only the specific actions they have mirrored. + +#### Alternative 3: Wildcard or prefix-based matching + +Mappings could support wildcards (e.g., `actions/*` → `acme-corp/*`). This was implicitly not chosen: the implementation uses exact `owner/repo@ref` key lookup via `FormatCacheKey`. Exact matching makes the mapping table auditable, avoids ambiguity when multiple patterns could match, and prevents accidental redirects. Each version must be mapped explicitly. + +### Consequences + +#### Positive +- Enterprises in private clouds can pin and resolve mirrored actions without modifying workflow YAML files. +- JSON schema validation (`propertyNames` + `additionalProperties` pattern) rejects malformed keys/values at configuration load time, catching mistakes early. +- Deduplication of console output (`ctx.Warnings["map:…"]`) ensures users see each active mapping exactly once per compiler invocation, keeping output readable. +- The mapping table is nil-safe and loaded lazily via `loadRepoConfig`; zero overhead when `action_pins` is absent. + +#### Negative +- Exact `owner/repo@ref` key semantics mean each major version of each action must be mapped individually; there is no wildcard support. Large mirror sets require verbose configuration. +- The mapped target must itself be resolvable by the existing pin machinery (dynamic lookup or hardcoded pins). If the mirror repo is not in the embedded pin table and dynamic resolution is unavailable, resolution fails. +- `getActionPinMappings` calls `loadRepoConfig` independently from other callers; if `aw.json` is read multiple times per compilation, there is duplicated I/O (though this is consistent with the existing pattern for other `aw.json` fields). + +#### Neutral +- The feature is only configurable in `aw.json`, not workflow frontmatter, which is a conscious scope boundary. +- Mapping application is logged at `Printf` level (not a user-visible warning), so diagnosing missed mappings requires debug log inspection. +- The implementation threads `ActionPinMappings` through `WorkflowData` and `PinContext`, following the existing propagation pattern for `ActionPinWarnings` and `AllowActionRefs`. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 4f67ba14eeebc41f34fec152b868d1e6f2f44a69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:04:15 +0000 Subject: [PATCH 4/4] fix: defensive map copy in getActionPinMappings; move log into dedup block Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 5 ++--- pkg/workflow/compiler_repo_config.go | 15 ++++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 8ea95b1c0e1..0b99764ccb1 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -499,12 +499,11 @@ func applyActionPinMapping(actionRepo, version string, ctx *PinContext) (string, return actionRepo, version } - actionPinsLog.Printf("Action pin mapping applied: %s → %s", cacheKey, mapped) - - // Emit informational message once per unique (source → target) pair. + // Emit informational message once per source key. initWarnings(ctx) notifyKey := "map:" + cacheKey if !ctx.Warnings[notifyKey] { + actionPinsLog.Printf("Action pin mapping applied: %s → %s", cacheKey, mapped) fmt.Fprintln(os.Stderr, console.FormatInfoMessage( fmt.Sprintf("Action pin mapping applied: %s → %s", cacheKey, mapped), )) diff --git a/pkg/workflow/compiler_repo_config.go b/pkg/workflow/compiler_repo_config.go index f9346aeb2d5..f8375e8b6a7 100644 --- a/pkg/workflow/compiler_repo_config.go +++ b/pkg/workflow/compiler_repo_config.go @@ -1,6 +1,9 @@ package workflow -import "strings" +import ( + "maps" + "strings" +) // loadRepoConfig loads and caches repository-level configuration from aw.json. func (c *Compiler) loadRepoConfig() (*RepoConfig, error) { @@ -30,14 +33,16 @@ func (c *Compiler) getCompiledProjectUTCOffset() string { return strings.TrimSpace(repoConfig.UTC) } -// getActionPinMappings returns the action-pin mapping table from aw.json, or nil -// when the file is absent, contains no mappings, or fails to load. -// The returned map is safe to read concurrently but must not be mutated. +// getActionPinMappings returns a defensive copy of the action-pin mapping table +// from aw.json, or nil when the file is absent, contains no mappings, or fails +// to load. Callers may freely mutate the returned map. func (c *Compiler) getActionPinMappings() map[string]string { repoConfig, err := c.loadRepoConfig() if err != nil || repoConfig == nil || len(repoConfig.ActionPins) == 0 { return nil } repoConfigLog.Printf("getActionPinMappings: loaded %d action-pin mapping(s) from aw.json", len(repoConfig.ActionPins)) - return repoConfig.ActionPins + cp := make(map[string]string, len(repoConfig.ActionPins)) + maps.Copy(cp, repoConfig.ActionPins) + return cp }