-
Notifications
You must be signed in to change notification settings - Fork 455
Add action-pin mapping support in aw.json #41579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e172d0
40882a8
d9c96cc
4f67ba1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The dedup test verifies the 💡 Suggested fix — capture stderr inlineAdd an 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 |
||
| 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") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/grill-with-docs] The PR description says 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 |
||
| "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" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.