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
2 changes: 1 addition & 1 deletion .github/workflows/aw.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"auto_upgrade": { "cron": "0 9 * * 1" },
"ghes": true,
"ghes": false,
Comment thread
github-actions[bot] marked this conversation as resolved.
"maintenance": {
"action_failure_issue_expires": 12,
"label_triggers": true
Expand Down
2 changes: 1 addition & 1 deletion cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ func configureCompileToolFlags() {
compileCmd.Flags().Bool("approve", false, "Approve all safe update changes. When strict mode is active (the default), the compiler emits warnings for new restricted secrets or unapproved action additions/removals not present in the existing gh-aw-manifest. Use this flag to approve and skip safe update enforcement")
compileCmd.Flags().Bool("validate-images", false, "Require Docker to be available for container image validation. Without this flag, container image validation is silently skipped when Docker is not installed or the daemon is not running")
compileCmd.Flags().String("prior-manifest-file", "", "Path to a JSON file containing pre-cached gh-aw-manifests (map[lockFile]*GHAWManifest); used by the MCP server to supply a tamper-proof manifest baseline captured at startup")
compileCmd.Flags().Bool("ghes", false, "Enable GitHub Enterprise Server (GHES) compatibility mode. Artifact actions continue using latest non-v3 pins (v3 is deprecated). Overrides the aw.json ghes field.")
compileCmd.Flags().Bool("ghes", false, "Enable GitHub Enterprise Server (GHES) compatibility mode: emit upload-artifact@v3.2.2 and download-artifact@v3.1.0. Overrides the aw.json ghes field.")
}

func finalizeCompileFlags() {
Expand Down
48 changes: 48 additions & 0 deletions docs/adr/53966-emit-ghes-v3-artifact-pins-in-compatibility-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR-53966: Emit Pinned v3 Artifact Actions in GHES Compatibility Mode

**Date**: 2026-08-19
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

GHES instances running versions prior to `@actions/artifact` v2.0.0 (GHES 3.21.x and earlier) cannot use `actions/upload-artifact@v4+` or `actions/download-artifact@v4+` because those versions depend on the newer artifact backend. When gh-aw compiled workflows for GHES targets, the existing GHES compatibility mode continued to emit the latest non-v3 pins (v7 for upload, v8 for download), which still required the v4 artifact backend. This caused compiled workflows to fail at runtime on older GHES instances with a `GHESNotSupportedError` before any agent execution could begin. GHES compatibility mode therefore had no practical effect for the use case it was designed to address.

### Decision

We will make GHES compatibility mode actively emit SHA-pinned v3 artifact action references: `upload-artifact@v3.2.2` (SHA `c6a366c9...`) and `download-artifact@v3.1.0` (SHA `a9bc5e6e...`). The `ghesArtifactPins` lookup table in `pkg/actionpins/resolve.go` is checked before dynamic resolution whenever `PinContext.GHES` is true, bypassing the normal latest-pin logic. The `GHES` flag propagates from the CLI flag or `aw.json` through the compiler, `WorkflowData`, and `PinContext` so every code path that resolves artifact action pins respects the override. This path explicitly supports GHES 3.21.x and earlier; later GHES releases that support the v4 artifact backend should eventually disable compatibility mode.

### Alternatives Considered

#### Alternative 1: Dynamic resolution with a GHES-aware version constraint

The resolver could be configured to cap `upload-artifact` and `download-artifact` to the latest available v3.x tag via the GitHub API rather than hardcoding a specific SHA. This would pick up v3 patch releases automatically.

Not chosen because dynamic resolution adds a network call at compile time and removes the SHA-pinning guarantee that is central to gh-aw's security model. Hardcoded, audited SHAs are the pattern used elsewhere in the codebase and align with the tool's supply-chain integrity goals.

#### Alternative 2: Leave pin selection to the workflow author

Users could manually specify `uses: actions/upload-artifact@v3.2.2` in their workflow source files rather than having the compiler override the version in GHES mode.

Not chosen because it defeats the purpose of the `ghes` compatibility flag: the flag exists precisely so authors do not need to maintain separate workflow files per deployment target. Compiler-managed pinning is the established convention in gh-aw.

### Consequences

#### Positive
- GHES 3.21.x and earlier instances can now successfully run compiled workflows; artifact upload/download steps no longer fail with `GHESNotSupportedError`.
- The existing `aw.json` `ghes: true` and `gh aw compile --ghes` surface area is preserved; no changes to the public API or configuration schema are required.
- SHA-pinned references maintain the same supply-chain integrity guarantee as other pinned actions in the compiled output.

#### Negative
- The v3 artifact actions are deprecated by GitHub; GHES users running in compatibility mode are consuming end-of-life action versions.
- The hardcoded SHAs in `ghesArtifactPins` require a manual code change if the GHES-compatible target versions need to be updated in the future.

#### Neutral
- The `GHES` field is added to both `WorkflowData` and `PinContext`, widening the surface area of those structs slightly.
- `configureGHESCompatibility()` is extracted into its own method and called from both `ParseWorkflowFile` and `CompileWorkflowData` to ensure consistency between the parse and compile paths.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 1 addition & 1 deletion docs/enterprise-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ When GHES compatibility mode is active, the compiler emits:
| `actions/upload-artifact` | `@v7` (latest) | `@v3.2.2` |
Comment thread
github-actions[bot] marked this conversation as resolved.
| `actions/download-artifact` | `@v4` (latest) | `@v3.1.0` |

All other actions are unaffected.
All other actions are unaffected. These GHES-compatible artifact pins require Actions Runner 2.327.1 or later because they run on Node.js 24.

## Related Documentation

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/enterprise-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ This page covers configuration options specific to GitHub Enterprise Server (GHE

GHES instances running versions that predate `@actions/artifact` v2.0.0 support cannot use `actions/upload-artifact@v4+` or `actions/download-artifact@v4+`. Attempting to run compiled workflows on these instances produces a `GHESNotSupportedError`.

gh-aw includes a GHES compatibility mode toggle (`aw.json` `ghes` or `gh aw compile --ghes`) so GHES-targeted repositories can compile with explicit GHES mode enabled. Artifact actions continue using the latest non-v3 pins because v3 artifact actions are deprecated.
gh-aw includes a GHES compatibility mode toggle (`aw.json` `ghes` or `gh aw compile --ghes`) for GHES releases that require the v3 artifact backend. Compatibility mode emits `upload-artifact@v3.2.2` and `download-artifact@v3.1.0`; default GitHub.com compilation continues to use the latest artifact actions.

This compatibility path supports GHES 3.21.x and earlier when the workflow runs on Actions Runner 2.327.1 or later, which is required by the Node.js 24 runtime used by these pinned artifact actions. For later GHES releases, keep compatibility mode enabled until your instance supports the v4 artifact backend.

#### Enable via `aw.json` (recommended)

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/self-hosted-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ Or compile with `--ghes` for one-off workflow generation:
gh aw compile --ghes my-workflow.md
```

Artifact actions continue using the latest non-v3 pins because v3 artifact actions are deprecated.
Compatibility mode emits `upload-artifact@v3.2.2` and `download-artifact@v3.1.0`, which use the artifact backend supported by GHES. Default GitHub.com compilation continues to use the latest artifact actions.

This path supports GHES 3.21.x and earlier when the workflow runs on Actions Runner 2.327.1 or later, which is required by the Node.js 24 runtime used by these pinned artifact actions. Keep compatibility mode enabled on later releases until the instance supports the v4 artifact backend.

### API endpoint

Expand Down
60 changes: 60 additions & 0 deletions pkg/actionpins/actionpins_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,66 @@ func (r *countingResolver) ResolveSHA(_ context.Context, _, _ string) (string, e
return "", nil
}

type fixedResolver struct {
sha string
called int
}

func (r *fixedResolver) ResolveSHA(_ context.Context, _, _ string) (string, error) {
r.called++
return r.sha, nil
}

func TestResolveActionPin_GHESArtifactCompatibility(t *testing.T) {
t.Parallel()

tests := []struct {
repo string
want string
}{
{
repo: "actions/upload-artifact",
want: "actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2",
},
{
repo: "actions/download-artifact",
want: "actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0",
},
}

for _, tt := range tests {
t.Run(tt.repo, func(t *testing.T) {
t.Parallel()
resolver := &countingResolver{}
got, err := ResolveActionPin(tt.repo, "latest", &PinContext{
GHES: true,
Resolver: resolver,
})
require.NoError(t, err)
assert.Equal(t, tt.want, got)
assert.Zero(t, resolver.called, "GHES compatibility pins should not require dynamic resolution")
})
}
}

func TestResolveActionPin_GHESMappingTakesPrecedence(t *testing.T) {
t.Parallel()

resolver := &fixedResolver{sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
got, err := ResolveActionPin("actions/upload-artifact", "v7", &PinContext{
GHES: true,
Resolver: resolver,
Comment thread
github-actions[bot] marked this conversation as resolved.
Mappings: map[string]string{
"actions/upload-artifact@v7": "enterprise/upload-artifact@v3",
},
})

require.NoError(t, err)
assert.Contains(t, got, "enterprise/upload-artifact@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assert.NotContains(t, got, "c6a366c94c3e0affe28c06c8df20a878f24da3cf")
assert.Equal(t, 1, resolver.called, "mapped enterprise action should use normal resolution")
}

func TestBuildByRepoIndex_GroupsByRepoAndSortsDescending(t *testing.T) {
t.Parallel()
pins := []ActionPin{
Expand Down
31 changes: 31 additions & 0 deletions pkg/actionpins/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ import (
"github.com/github/gh-aw/pkg/semverutil"
)

var ghesArtifactPins = map[string]ActionPin{
"actions/upload-artifact": {
Repo: "actions/upload-artifact",
Version: "v3.2.2",
SHA: "c6a366c94c3e0affe28c06c8df20a878f24da3cf",
},
"actions/download-artifact": {
Repo: "actions/download-artifact",
Version: "v3.1.0",
SHA: "a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59",
},
}

// recordPinResolutionFailure silently records an unresolved action-ref pinning event
// to the audit callback (ctx.RecordResolutionFailure), if one is configured.
// If ctx is nil or ctx.RecordResolutionFailure is nil, the function returns early without recording.
Expand All @@ -34,7 +47,16 @@ 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.
originalRepo, originalVersion := actionRepo, version
actionRepo, version = applyActionPinMapping(actionRepo, version, ctx)
mapped := actionRepo != originalRepo || version != originalVersion

if ctx.GHES && !mapped {
if pin, ok := ghesArtifactPins[actionRepo]; ok {
actionPinsLog.Printf("GHES mode: using %s@%s", actionRepo, pin.Version)
return FormatPinnedActionReference(pin.Repo, pin.SHA, pin.Version), nil
}
}

isAlreadySHA := gitutil.IsValidFullSHA(version)
if pinnedRef, ok := resolveActionPinDynamically(actionRepo, version, isAlreadySHA, ctx); ok {
Expand Down Expand Up @@ -71,6 +93,15 @@ func ResolveActionPin(actionRepo, version string, ctx *PinContext) (string, erro
return "", nil
}

// ResolveGHESActionPin returns the GHES-compatible pin for repo, if one is required.
func ResolveGHESActionPin(repo string) (string, bool) {
pin, ok := ghesArtifactPins[repo]
if !ok {
return "", false
}
return FormatPinnedActionReference(pin.Repo, pin.SHA, pin.Version), true
}

func resolveActionPinDynamically(actionRepo, version string, isAlreadySHA bool, ctx *PinContext) (string, bool) {
if ctx.Resolver == nil || isAlreadySHA {
logDynamicResolutionSkipped(ctx.Resolver != nil, isAlreadySHA)
Expand Down
2 changes: 2 additions & 0 deletions pkg/actionpins/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type PinContext struct {
// AllowActionRefs lowers unresolved pinning failures to warnings.
// When false, unresolved action refs return an error.
AllowActionRefs bool
// GHES selects action versions compatible with GitHub Enterprise Server.
GHES bool
// Warnings is a shared map for deduplicating warning messages.
// Keys are cache keys in the form "repo@version".
Warnings map[string]bool
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,10 @@ func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) {
}

// Set GHES compatibility mode when the --ghes flag is passed.
// v3 artifact pins are deprecated, so artifact actions continue to use latest pins.
// When enabled, artifact actions use versions supported by GHES.
compiler.SetGHESCompat(config.GHESCompat)
if config.GHESCompat {
compileCompilerSetupLog.Print("GHES compatibility mode enabled via --ghes flag: artifact actions will use latest non-v3 pins")
compileCompilerSetupLog.Print("GHES compatibility mode enabled via --ghes flag: artifact actions will use v3-compatible pins")
}

// Load pre-cached manifests from file (written by MCP server at startup).
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/compile_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type CompileConfig struct {
Approve bool // Approve all safe update changes, skipping safe update enforcement regardless of strict mode setting.
ValidateImages bool // Require Docker to be available for container image validation (fail instead of skipping when Docker is unavailable)
PriorManifestFile string // Path to a JSON file containing pre-cached manifests (map[lockFile]*GHAWManifest) collected at MCP server startup; takes precedence over git HEAD / filesystem reads for safe update enforcement
GHESCompat bool // Enable GHES compatibility mode (overrides aw.json ghes field); artifact actions still use latest non-v3 pins
GHESCompat bool // Enable GHES-compatible v3 artifact actions (overrides aw.json ghes field)
}

func (c CompileConfig) shellcheckEnabled() bool {
Expand Down
44 changes: 44 additions & 0 deletions pkg/cli/compile_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,50 @@ Please check the repository for any open issues and create a summary.
t.Logf("Successfully compiled workflow to %s", lockFilePath)
}

func TestCompileGHESArtifactPinsIntegration(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

testWorkflowPath := filepath.Join(setup.workflowsDir, "ghes-artifacts.md")
err := os.WriteFile(testWorkflowPath, []byte(`---
on: workflow_dispatch
permissions:
contents: read
engine: copilot
strict: false
steps:
- uses: actions/upload-artifact@v7
with:
name: test
path: test.txt
---
# GHES artifact pins
`), 0o600)
if err != nil {
t.Fatalf("Failed to write test workflow: %v", err)
}

cmd := exec.Command(setup.binaryPath, "compile", "--ghes", testWorkflowPath)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output))
}

lockContent, err := os.ReadFile(filepath.Join(setup.workflowsDir, "ghes-artifacts.lock.yml"))
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
contents := string(lockContent)
if !strings.Contains(contents, "actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2") {
t.Error("Lock file should contain the GHES-compatible upload-artifact pin")
}
if !strings.Contains(contents, "actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0") {
t.Error("Lock file should contain the GHES-compatible download-artifact pin")
}
if strings.Contains(contents, "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1") {
t.Error("Lock file should not contain the default upload-artifact pin")
}
}

func removeAllWithRetry(path string) error {
attempts := 1
if runtime.GOOS == "windows" {
Expand Down
2 changes: 1 addition & 1 deletion pkg/parser/schemas/repo_config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
}
},
"ghes": {
"description": "Enable GitHub Enterprise Server (GHES) compatibility mode. Artifact actions continue to use latest non-v3 pins because upload-artifact/download-artifact v3 are deprecated.",
"description": "Enable GitHub Enterprise Server (GHES) compatibility mode. Artifact actions use upload-artifact@v3.2.2 and download-artifact@v3.1.0.",
"type": "boolean"
},
"help_command": {
Expand Down
13 changes: 13 additions & 0 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ func getActionPin(repo string) string {
return actionpins.FormatPinnedActionReference(repo, pins[0].SHA, pins[0].Version)
}

func getActionPinForData(repo string, data *WorkflowData) string {
if data != nil {
return getCachedActionPin(repo, data)
}
return getActionPin(repo)
}

// getActionPin returns the pinned reference for the given repo.
//
// This is the preferred call site for code running inside a Compiler method because it
Expand All @@ -78,6 +85,12 @@ func getActionPin(repo string) string {
// any existing entry and mark it as "used" for orphan pruning. This ensures compiler-generated
// action references (e.g., actions/cache/save in notify steps) are tracked.
func (c *Compiler) getActionPin(repo string) string {
if c.ghesArtifactCompat {
if pin, ok := actionpins.ResolveGHESActionPin(repo); ok {
return pin
}
Comment thread
github-actions[bot] marked this conversation as resolved.
}

// Check the cache for any existing entry for this repo (regardless of version).
// Compiler-generated actions don't specify versions, so prefer a cached entry only
// when it is at least as new as the latest embedded pin.
Expand Down
Loading
Loading