Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
51 changes: 51 additions & 0 deletions docs/adr/41579-action-pin-mapping-for-private-cloud.md
Original file line number Diff line number Diff line change
@@ -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.*
42 changes: 42 additions & 0 deletions pkg/actionpins/actionpins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -470,3 +477,38 @@ 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 (must be in format owner/repo@ref); skipping", mapped, cacheKey)
Comment thread
pelikhan marked this conversation as resolved.
return actionRepo, version
}

// Emit informational message once per source key.
initWarnings(ctx)
notifyKey := "map:" + cacheKey
Comment thread
pelikhan marked this conversation as resolved.
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),
))
ctx.Warnings[notifyKey] = true
}

return mappedRepo, mappedVersion
}
77 changes: 77 additions & 0 deletions pkg/actionpins/actionpins_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package actionpins

import (
"context"
"strings"
"testing"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -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) {

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] The dedup test verifies the Warnings map state but never confirms that fmt.Fprintln(os.Stderr, ...) fired only once. A regression emitting stderr on every call would be invisible to this test.

💡 Suggested fix — capture stderr inline

Add an os.Pipe()-based capture so the assertion covers the actual side effect:

r, w, _ := os.Pipe()
old := os.Stderr; os.Stderr = w

applyActionPinMapping("actions/checkout", "v4", ctx)
applyActionPinMapping("actions/checkout", "v4", ctx)

w.Close(); os.Stderr = old
var buf bytes.Buffer
_, _ = buf.ReadFrom(r)

assert.Equal(t, 1, strings.Count(buf.String(), "Action pin mapping applied"),
    "deduplication broken: message should appear exactly once on stderr")

The existing Warnings map assertions can stay alongside this.

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 at sign and 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")
}
41 changes: 41 additions & 0 deletions pkg/actionpins/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
12 changes: 12 additions & 0 deletions pkg/parser/schemas/repo_config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",

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.

[/grill-with-docs] The PR description says action_pins is "Not supported in frontmatter", but that constraint isn't reflected in the schema description. IDE users relying on JSON schema autocomplete won't know about this limitation.

Consider appending to the description:

"description": "... Keys and values must use the format 'owner/repo@ref'. Only read from aw.json — not supported in workflow frontmatter."

This costs one sentence and prevents a frustrating debugging session for users who try action_pins in frontmatter and see it silently ignored.

"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"
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion pkg/workflow/compiler_repo_config.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -29,3 +32,17 @@ func (c *Compiler) getCompiledProjectUTCOffset() string {
}
return strings.TrimSpace(repoConfig.UTC)
}

// 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))
cp := make(map[string]string, len(repoConfig.ActionPins))
maps.Copy(cp, repoConfig.ActionPins)
return cp
}
1 change: 1 addition & 0 deletions pkg/workflow/compiler_string_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions pkg/workflow/compiler_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
18 changes: 13 additions & 5 deletions pkg/workflow/repo_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading