diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json index b9f4da9915f..283aa66983b 100644 --- a/.github/workflows/aw.json +++ b/.github/workflows/aw.json @@ -1,6 +1,6 @@ { "auto_upgrade": { "cron": "0 9 * * 1" }, - "ghes": true, + "ghes": false, "maintenance": { "action_failure_issue_expires": 12, "label_triggers": true diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index e472ef90240..d8294ce4cee 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -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() { diff --git a/docs/adr/53966-emit-ghes-v3-artifact-pins-in-compatibility-mode.md b/docs/adr/53966-emit-ghes-v3-artifact-pins-in-compatibility-mode.md new file mode 100644 index 00000000000..622865a959b --- /dev/null +++ b/docs/adr/53966-emit-ghes-v3-artifact-pins-in-compatibility-mode.md @@ -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.* diff --git a/docs/enterprise-configuration.md b/docs/enterprise-configuration.md index 308d532333b..3132ac2787b 100644 --- a/docs/enterprise-configuration.md +++ b/docs/enterprise-configuration.md @@ -279,7 +279,7 @@ When GHES compatibility mode is active, the compiler emits: | `actions/upload-artifact` | `@v7` (latest) | `@v3.2.2` | | `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 diff --git a/docs/src/content/docs/reference/enterprise-configuration.md b/docs/src/content/docs/reference/enterprise-configuration.md index 79567fb8c9b..431a8a24a36 100644 --- a/docs/src/content/docs/reference/enterprise-configuration.md +++ b/docs/src/content/docs/reference/enterprise-configuration.md @@ -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) diff --git a/docs/src/content/docs/reference/self-hosted-runners.md b/docs/src/content/docs/reference/self-hosted-runners.md index 676183415b9..7e342018880 100644 --- a/docs/src/content/docs/reference/self-hosted-runners.md +++ b/docs/src/content/docs/reference/self-hosted-runners.md @@ -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 diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index 86e51b9628c..103bea41ebb 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -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, + 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{ diff --git a/pkg/actionpins/resolve.go b/pkg/actionpins/resolve.go index 07755f42b06..8d4f0b83784 100644 --- a/pkg/actionpins/resolve.go +++ b/pkg/actionpins/resolve.go @@ -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. @@ -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 { @@ -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) diff --git a/pkg/actionpins/types.go b/pkg/actionpins/types.go index b6cc68e0570..3309f3ff629 100644 --- a/pkg/actionpins/types.go +++ b/pkg/actionpins/types.go @@ -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 diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go index c529f5f8d0f..16764fbb8f8 100644 --- a/pkg/cli/compile_compiler_setup.go +++ b/pkg/cli/compile_compiler_setup.go @@ -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). diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 4539f7a7da9..40f62904b80 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -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 { diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go index 20be7dc3968..d78ab6ab55d 100644 --- a/pkg/cli/compile_integration_test.go +++ b/pkg/cli/compile_integration_test.go @@ -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" { diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index dec954312c6..56b7723e327 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -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": { diff --git a/pkg/workflow/action_pins.go b/pkg/workflow/action_pins.go index a98edc2fb59..7b0f0117053 100644 --- a/pkg/workflow/action_pins.go +++ b/pkg/workflow/action_pins.go @@ -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 @@ -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 + } + } + // 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. diff --git a/pkg/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index 291c3ac9772..2078c2db201 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -1622,7 +1622,7 @@ func TestSliceToStepsErrorHandling(t *testing.T) { } } -// TestGetActionPinGHESArtifactCompat verifies GHES compat mode does not emit deprecated v3 artifact pins. +// TestGetActionPinGHESArtifactCompat verifies GHES compat mode emits compatible v3 artifact pins. func TestGetActionPinGHESArtifactCompat(t *testing.T) { // Verify default (compat disabled) returns latest (v7/v8) defaultCompiler := NewCompiler() @@ -1653,19 +1653,19 @@ func TestGetActionPinGHESArtifactCompat(t *testing.T) { compatCompiler.ghesArtifactCompat = true uploadPinGHES := compatCompiler.getActionPin("actions/upload-artifact") - if strings.Contains(uploadPinGHES, "# v3") { - t.Errorf("With GHES compat, expected non-v3 upload-artifact pin, got: %s", uploadPinGHES) + if !strings.Contains(uploadPinGHES, "c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2") { + t.Errorf("With GHES compat, expected upload-artifact v3.2.2 pin, got: %s", uploadPinGHES) } - if uploadPinGHES != uploadPin { - t.Errorf("With GHES compat, expected upload-artifact pin to match default, default=%s compat=%s", uploadPin, uploadPinGHES) + if uploadPinGHES == uploadPin { + t.Errorf("With GHES compat, expected upload-artifact pin to differ from default, got: %s", uploadPinGHES) } downloadPinGHES := compatCompiler.getActionPin("actions/download-artifact") - if strings.Contains(downloadPinGHES, "# v3") { - t.Errorf("With GHES compat, expected non-v3 download-artifact pin, got: %s", downloadPinGHES) + if !strings.Contains(downloadPinGHES, "a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0") { + t.Errorf("With GHES compat, expected download-artifact v3.1.0 pin, got: %s", downloadPinGHES) } - if downloadPinGHES != downloadPin { - t.Errorf("With GHES compat, expected download-artifact pin to match default, default=%s compat=%s", downloadPin, downloadPinGHES) + if downloadPinGHES == downloadPin { + t.Errorf("With GHES compat, expected download-artifact pin to differ from default, got: %s", downloadPinGHES) } // Non-artifact actions should be unaffected by GHES compat @@ -1675,8 +1675,8 @@ func TestGetActionPinGHESArtifactCompat(t *testing.T) { } } -// TestGHESArtifactCompatDoesNotUseV3 verifies GHES compat mode never emits deprecated v3 artifact pins. -func TestGHESArtifactCompatDoesNotUseV3(t *testing.T) { +// TestGHESArtifactCompatPinsExist verifies GHES compatibility pins are complete. +func TestGHESArtifactCompatPinsExist(t *testing.T) { c := NewCompiler() c.ghesArtifactCompat = true for _, repo := range []string{"actions/upload-artifact", "actions/download-artifact"} { @@ -1685,13 +1685,44 @@ func TestGHESArtifactCompatDoesNotUseV3(t *testing.T) { if result == "" { t.Errorf("getActionPin(%s) returned empty with GHES compat enabled", repo) } - if strings.Contains(result, "# v3") { - t.Errorf("getActionPin(%s) should not return a v3 pin, got: %s", repo, result) + if !strings.Contains(result, "# v3") { + t.Errorf("getActionPin(%s) should return a v3 pin, got: %s", repo, result) } }) } } +func TestGeneratedArtifactStepsHonorGHESCompat(t *testing.T) { + t.Run("firewall log upload uses workflow data", func(t *testing.T) { + data := &WorkflowData{Name: "GHES Firewall", GHES: true} + step := strings.Join([]string(generateSquidLogsUploadStep(data.Name, data)), "\n") + if !strings.Contains(step, "actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2") { + t.Fatalf("expected GHES upload-artifact pin in firewall step, got:\n%s", step) + } + if strings.Contains(step, "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a") { + t.Fatalf("expected firewall step not to use default upload-artifact pin, got:\n%s", step) + } + }) + + t.Run("repo memory download uses compiler compatibility", func(t *testing.T) { + c := NewCompiler() + c.SetGHESCompat(true) + c.configureGHESCompatibility() + data := &WorkflowData{ + RepoMemoryConfig: &RepoMemoryConfig{ + Memories: []RepoMemoryEntry{{ID: "default"}}, + }, + } + step := strings.Join(c.buildPushRepoMemoryDownloadSteps(data), "\n") + if !strings.Contains(step, "actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0") { + t.Fatalf("expected GHES download-artifact pin in repo-memory step, got:\n%s", step) + } + if strings.Contains(step, "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c") { + t.Fatalf("expected repo-memory step not to use default download-artifact pin, got:\n%s", step) + } + }) +} + func TestGetActionPinPrefersLatestEmbeddedOverStaleCache(t *testing.T) { latestCacheRestorePin, ok := getLatestActionPinByRepo("actions/cache/restore") if !ok { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index ba08dcea2ff..3851e5d3768 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -69,6 +69,7 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error { if isFormattedCompilerError(err) { return err } + // Fallback for any unformatted error that slipped through. return formatCompilerError(markdownPath, "error", err.Error(), err) } @@ -76,6 +77,19 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error { return c.CompileWorkflowData(workflowData, markdownPath) } +func (c *Compiler) configureGHESCompatibility() { + if c.ghesCompatConfigured { + return + } + c.ghesCompatConfigured = true + c.ghesArtifactCompat = c.ghesCompatFromCLI + if !c.ghesArtifactCompat { + if repoConfig, err := c.loadRepoConfig(); err == nil && repoConfig != nil { + c.ghesArtifactCompat = repoConfig.GHES + } + } +} + // validateWorkflowData orchestrates all validation of workflow configuration by // delegating to four focused validators. Each validator is independently testable // and covers a distinct concern: @@ -441,17 +455,11 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath } // Enable GHES artifact compatibility from CLI flag or aw.json (CLI flag wins). - // c.ghesCompatFromCLI is set once per compiler instance via SetGHESCompat(). - c.ghesArtifactCompat = c.ghesCompatFromCLI - if !c.ghesArtifactCompat { - // Fall back to aw.json ghes field when CLI flag was not passed. - if repoConfig, err := c.loadRepoConfig(); err == nil && repoConfig != nil { - c.ghesArtifactCompat = repoConfig.GHES - } - } + c.configureGHESCompatibility() if c.ghesArtifactCompat { - actionPinsLog.Print("GHES compatibility mode enabled: artifact actions continue using latest non-v3 pins") + actionPinsLog.Print("GHES compatibility mode enabled: artifact actions will use v3-compatible pins") } + workflowData.GHES = c.ghesArtifactCompat // Generate lock file name lockFile := stringutil.MarkdownToLockFile(markdownPath) diff --git a/pkg/workflow/compiler_mutators.go b/pkg/workflow/compiler_mutators.go index 0807809297f..fa644a0ae23 100644 --- a/pkg/workflow/compiler_mutators.go +++ b/pkg/workflow/compiler_mutators.go @@ -121,9 +121,10 @@ func (c *Compiler) SetAllowActionRefs(allow bool) { // SetGHESCompat enables GHES compatibility mode via the --ghes CLI flag. // It overrides the aw.json ghes field for the current compilation run. -// Artifact actions still use the latest non-v3 pins. +// Artifact actions use versions supported by GHES. func (c *Compiler) SetGHESCompat(enabled bool) { c.ghesCompatFromCLI = enabled + c.ghesCompatConfigured = false } // SetRefreshStopTime configures whether to force regeneration of stop-after times diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 2046966ea2e..330bf12dcfb 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -31,6 +31,8 @@ type workflowBuildContext struct { // ParseWorkflowFile parses a workflow markdown file and returns a WorkflowData structure. // This is the main orchestration function that coordinates all compilation phases. func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) { + c.configureGHESCompatibility() + // Behavior-defined engines are contributed by a workflow's imports, so their // registry and catalog must not affect subsequent compilations. c.engineRegistry = NewEngineRegistry() diff --git a/pkg/workflow/compiler_string_api.go b/pkg/workflow/compiler_string_api.go index 5088397de96..985a6704b3c 100644 --- a/pkg/workflow/compiler_string_api.go +++ b/pkg/workflow/compiler_string_api.go @@ -20,6 +20,10 @@ func (c *Compiler) CompileToYAML(workflowData *WorkflowData, markdownPath string compilerStringAPILog.Printf("CompileToYAML: markdownPath=%s", markdownPath) c.markdownPath = markdownPath c.skipHeader = true + c.configureGHESCompatibility() + if workflowData != nil { + workflowData.GHES = c.ghesArtifactCompat + } // Clear contentOverride after compilation (set by ParseWorkflowString) defer func() { c.contentOverride = "" }() @@ -57,6 +61,8 @@ func (c *Compiler) CompileToYAML(workflowData *WorkflowData, markdownPath string func (c *Compiler) ParseWorkflowString(content string, virtualPath string) (*WorkflowData, error) { workflowLog.Printf("ParseWorkflowString: parsing %d bytes with virtual path %s", len(content), virtualPath) + c.configureGHESCompatibility() + cleanPath := filepath.Clean(virtualPath) contentBytes := []byte(content) diff --git a/pkg/workflow/compiler_string_api_test.go b/pkg/workflow/compiler_string_api_test.go index 7480d3e62b0..946cc270e7f 100644 --- a/pkg/workflow/compiler_string_api_test.go +++ b/pkg/workflow/compiler_string_api_test.go @@ -289,6 +289,39 @@ Greet the user warmly. assert.Contains(t, yaml, "jobs:") } +func TestCompileToYAML_GHESCompatPinsStringAPI(t *testing.T) { + markdown := `--- +name: ghes-string-api +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +strict: false +steps: + - name: Upload test artifact + uses: actions/upload-artifact@v7 + with: + name: test + path: test.txt +--- + +# GHES string API pins +` + + compiler := NewCompiler() + compiler.SetGHESCompat(true) + wd, err := compiler.ParseWorkflowString(markdown, "workflow.md") + require.NoError(t, err) + + yaml, err := compiler.CompileToYAML(wd, "workflow.md") + require.NoError(t, err) + assert.Contains(t, yaml, "actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2") + assert.Contains(t, yaml, "actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0") + assert.NotContains(t, yaml, "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7") + assert.NotContains(t, yaml, "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8") +} + func TestCompileToYAML_OutputContainsWorkflowName(t *testing.T) { markdown := `--- name: my-unique-workflow diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 972a543da0f..c25788ec36e 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -67,7 +67,8 @@ type Compiler struct { priorManifests map[string]*GHAWManifest // Pre-cached manifests keyed by lock file path; takes precedence over git HEAD / filesystem reads requireDocker bool // If true, fail validation when Docker is not available instead of silently skipping ghesCompatFromCLI bool // If true, GHES compat was requested via --ghes CLI flag (takes precedence over aw.json) - ghesArtifactCompat bool // If true, GHES compatibility mode is enabled; artifact actions still use latest non-v3 pins + ghesArtifactCompat bool // If true, emit GHES-compatible v3 pins for artifact actions + ghesCompatConfigured bool // True once GHES compatibility has been resolved from CLI/config ownerTypeCache map[string]string // Cached GitHub owner type ("User"/"Organization"/"") keyed by owner login; not goroutine-safe (Compiler is used sequentially) copilotRequestsTipShown map[string]bool // Tracks markdown paths that already emitted the copilot-requests enable tip in this compiler instance copilotTipNeeded bool // Tracks whether batch output should include the copilot-requests enable tip diff --git a/pkg/workflow/engine_firewall_support.go b/pkg/workflow/engine_firewall_support.go index c9eb306122c..52ae0fa480b 100644 --- a/pkg/workflow/engine_firewall_support.go +++ b/pkg/workflow/engine_firewall_support.go @@ -110,7 +110,7 @@ func generateSquidLogsUploadStep(workflowName string, workflowData *WorkflowData " - name: Upload Firewall Logs", " if: always()", " continue-on-error: true", - " uses: " + getActionPin("actions/upload-artifact"), + " uses: " + getActionPinForData("actions/upload-artifact", workflowData), " with:", " name: " + artifactName, " path: " + firewallLogsDir, diff --git a/pkg/workflow/ghes_artifact_pins_integration_test.go b/pkg/workflow/ghes_artifact_pins_integration_test.go new file mode 100644 index 00000000000..d0c34d5705a --- /dev/null +++ b/pkg/workflow/ghes_artifact_pins_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGHESArtifactPinsIntegration(t *testing.T) { + tests := []struct { + name string + configure func(*testing.T, *Compiler, string) + }{ + { + name: "compile flag", + configure: func(_ *testing.T, compiler *Compiler, _ string) { + compiler.SetGHESCompat(true) + }, + }, + { + name: "repository config", + configure: func(t *testing.T, compiler *Compiler, root string) { + configPath := filepath.Join(root, RepoConfigFileName) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) + require.NoError(t, os.WriteFile(configPath, []byte(`{"ghes":true}`), 0o600)) + compiler.gitRoot = root + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := testutil.TempDir(t, "ghes-artifact-pins") + workflowPath := filepath.Join(root, "artifact-pins.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(`--- +on: workflow_dispatch +permissions: + contents: read +engine: copilot +strict: false +steps: + - name: Upload test artifact + uses: actions/upload-artifact@v7 + with: + name: test + path: test.txt +--- +# GHES artifact pins +`), 0o600)) + + compiler := NewCompiler() + tt.configure(t, compiler, root) + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lock, err := os.ReadFile(stringutil.MarkdownToLockFile(workflowPath)) + require.NoError(t, err) + contents := string(lock) + assert.Contains(t, contents, "actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2") + assert.Contains(t, contents, "actions/download-artifact@a9bc5e6ef2cb54c177f32aa5726adaa15e7e2d59 # v3.1.0") + assert.NotContains(t, contents, "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1") + assert.NotContains(t, contents, "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1") + }) + } +} diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 57e0a090daa..f0ab6287b86 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -7,7 +7,7 @@ // Configuration reference: // // { -// "ghes": true, // enables GHES compatibility mode (artifact pins remain latest non-v3) +// "ghes": true, // enables GHES-compatible v3 artifact pins // "help_command": false, // disables builtin centralized /help comment handler // "utc": "-08:00", // project home UTC offset for rendered local times // "auto_upgrade": true, // set to true to generate agentic-auto-upgrade.yml with weekly schedule @@ -142,8 +142,7 @@ func (m *MaintenanceConfig) IsJobDisabled(jobName string) bool { // RepoConfig is the parsed representation of aw.json. type RepoConfig struct { // GHES enables GitHub Enterprise Server compatibility mode. - // When true, the compiler enables GHES compatibility behavior. Artifact actions - // continue to use latest non-v3 pins because v3 artifact actions are deprecated. + // When true, the compiler uses artifact action versions supported by GHES. GHES bool // UTC is the project's home UTC offset used for rendering local times in CLI output. diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index 6cc3aaa50be..dcb2172743b 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -571,7 +571,7 @@ func (c *Compiler) buildPushRepoMemoryDownloadSteps(data *WorkflowData) []string } else { fmt.Fprintf(&step, " - name: Download repo-memory artifact (%s)\n", memory.ID) } - fmt.Fprintf(&step, " uses: %s\n", getActionPin("actions/download-artifact")) + fmt.Fprintf(&step, " uses: %s\n", c.getActionPin("actions/download-artifact")) step.WriteString(" continue-on-error: true\n") step.WriteString(" with:\n") fmt.Fprintf(&step, " name: %srepo-memory-%s\n", repoMemoryPrefix, sanitizedID) diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index a8548365119..4eb768e9398 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -66,6 +66,7 @@ func (c *Compiler) buildInitialWorkflowData( AI: engineSetup.engineSetting, Model: engineSetup.model, EngineConfig: engineSetup.engineConfig, + GHES: c.ghesArtifactCompat, AgentFile: agentFile, AgentImportSpec: agentImportSpec, RepositoryImports: importsResult.RepositoryImports, diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index b294f09d103..112686e8911 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -203,6 +203,7 @@ type WorkflowData struct { DefaultAiCreditsPricing *AiCreditsPricingConfig // fallback per-token pricing from frontmatter models.default-ai-credits-pricing; used by AWF API proxy for unrecognized models ActionPinMappings map[string]string // action-pin redirect table from aw.json action_pins: maps "owner/repo@version" → "owner/repo@version" ContainerPinMappings map[string]string // container-pin redirect table from aw.json container_pins: maps source image → replacement image + GHES bool // select action versions compatible with GitHub Enterprise Server Evals *EvalsConfig // BinEval evaluation configuration parsed from frontmatter evals field ExcludedEnv []string // additional env var names to exclude from agent container via AWF --exclude-env (from frontmatter excluded-env field) } @@ -228,6 +229,7 @@ func (d *WorkflowData) PinContext() *actionpins.PinContext { StrictMode: d.StrictMode, EnforcePinned: true, AllowActionRefs: d.AllowActionRefs, + GHES: d.GHES, Warnings: d.ActionPinWarnings, Mappings: d.ActionPinMappings, ContainerMappings: d.ContainerPinMappings,