Skip to content

Commit 8fd5125

Browse files
Copilotpelikhangithub-actions[bot]gh-aw-bot
authored
Prefer root go.mod for generated Go setup (#68718)
* Initial plan * Prefer root go.mod for generated Go runtime setup Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Preserve Go runtime fallback when root module is unavailable after checkout Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add draft ADR-68718 for root go.mod Go setup preference * Verify effective checkout before selecting root Go module Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Honor cone mode for sparse root go.mod detection Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent 8982c23 commit 8fd5125

7 files changed

Lines changed: 451 additions & 3 deletions

File tree

‎.github/workflows/architecture-guardian.lock.yml‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/dead-code-remover.lock.yml‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ADR-68718: Prefer Root `go.mod` for Generated Go Setup Steps
2+
3+
**Date**: 2026-10-14
4+
**Status**: Draft
5+
**Deciders**: Pending maintainer review (originating PR [#68718](https://github.com/github/gh-aw/pull/68718), fixes #68678)
6+
7+
---
8+
9+
### Context
10+
11+
The compiler emits an `actions/setup-go` step whenever a workflow needs the Go runtime. Until now that step always carried a hard-coded default (`go-version: '1.26'`) unless the workflow author set `runtimes.go.version` explicitly. That constant is independent of the Go version declared in the consuming repository's root `go.mod`, so a repository requiring a newer toolchain would get a mismatched Go installation. Combined with `GOTOOLCHAIN=local`, which forbids on-demand toolchain downloads, Go commands failed outright instead of silently upgrading. The compiler needs a version source that tracks the repository, but it can only do so when the generated job actually checks out that repository at the workspace root before the setup step runs.
12+
13+
### Decision
14+
15+
We will have the compiler emit `go-version-file: go.mod` instead of a pinned `go-version` when three conditions all hold: the workflow does not set `runtimes.go.version`, a regular `go.mod` file exists at the compiler's detected git root, and the generated job performs a root checkout of the workflow's own repository before runtime setup. The primary driver is correctness of the toolchain selection: the repository's own module file is the authoritative statement of its minimum Go version. In every other case the compiler keeps emitting the existing default version, so no workflow loses a working setup step.
16+
17+
The "root checkout" predicate reads the emitted default and additional checkout steps, including trial-mode repository overrides, or the first custom checkout before runtime setup is inserted. It accepts only an unconditional, same-repository checkout with an empty or `.` path and no explicit ref. The current-repository expression is normalized for whitespace. Sparse checkouts qualify in cone mode, which retains root files, or when non-cone patterns include `go.mod`. Unverified refs, repositories, conditions, and sparse layouts retain the default version.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Read `go.mod` at compile time and inline the parsed version
22+
23+
The compiler could parse the `go` directive from the root `go.mod` and emit `go-version: '<parsed>'`. This keeps the generated YAML self-contained and works even without a checkout, since the version is resolved at compile time. It was rejected because the pinned value immediately goes stale: bumping `go.mod` would silently leave every previously compiled `.lock.yml` on the old toolchain until recompiled, reintroducing the exact drift this change is meant to remove. `go-version-file` defers resolution to run time, where the checked-out module file is always current.
24+
25+
#### Alternative 2: Always emit `go-version-file: go.mod` unconditionally
26+
27+
Dropping the checkout and file-existence guards would make the compiler simpler and remove the new helper entirely. It was rejected because `actions/setup-go` hard-fails when the referenced file is absent, which would break workflows that intentionally skip checkout (`checkout: false`), check out a different repository, check out into a subdirectory, use a wiki checkout, or run in repositories with no root module. The guard logic is the cost of preserving those cases, and the new test matrix enumerates each of them.
28+
29+
#### Alternative 3: Raise the hard-coded default to the repository's current Go version
30+
31+
A one-line constant bump would unblock the immediate failure reported in #68678. It was rejected as a non-fix: it resolves one snapshot of the mismatch while guaranteeing the same class of breakage on the next toolchain bump, and it still ignores the Go version of any other repository consuming gh-aw-generated workflows.
32+
33+
### Consequences
34+
35+
#### Positive
36+
- Generated Go setup tracks the repository's declared minimum Go version automatically, so `GOTOOLCHAIN=local` jobs stop failing after a `go.mod` bump without requiring workflow edits.
37+
- Recompiling is no longer required to pick up a Go version change; the existing `.lock.yml` resolves the version at run time from the checked-out module file.
38+
- Explicit `runtimes.go.version` still wins, preserving the escape hatch for workflows that deliberately pin a different toolchain.
39+
40+
#### Negative
41+
- The compiler now has a compile-time dependency on filesystem state (`c.gitRoot/go.mod`), so the generated output of the same workflow source differs between repositories and between environments where the git root is or is not detected — making lock files less reproducible from source alone.
42+
- The "root checkout" predicate duplicates checkout reasoning across configured checkouts and raw custom-step YAML; `customCheckoutHasRootGoMod` must stay in sync with checkout semantics (path, repository, `if`, sparse-checkout) or it will silently emit a setup step that fails at run time.
43+
- A workflow that reorders steps so that checkout no longer precedes runtime setup, or that adds a conditional to its checkout, will change the emitted Go version source in a way that is not obvious from the workflow source.
44+
45+
#### Neutral
46+
- Two in-repo lock files (`architecture-guardian.lock.yml`, `dead-code-remover.lock.yml`) changed as generated output; the diff for Go-using workflows is a one-line `with:` key swap.
47+
- The documented default for the `go` runtime becomes conditional ("root `go.mod`, or 1.26 if absent") rather than a single constant, which the frontmatter reference now spells out.
48+
- `constants.DefaultGoVersion` remains the fallback and is still exercised by the majority of the new test cases, so the constant continues to need maintenance.
49+
50+
---
51+
52+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

‎docs/src/content/docs/reference/frontmatter.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,7 @@ Each runtime takes a required `version` string plus optional `action-repo` and `
661661
|---------|----------------|---------------------|
662662
| `node` | 24 | `actions/setup-node@v7` |
663663
| `python` | 3.12 | `actions/setup-python@v5` |
664-
| `go` | 1.25 | `actions/setup-go@v5` |
664+
| `go` | Root `go.mod`, or 1.26 if absent | `actions/setup-go@v7` |
665665
| `uv` | latest | `astral-sh/setup-uv@v5` |
666666
| `bun` | 1.1 | `oven-sh/setup-bun@v2` |
667667
| `deno` | 2.x | `denoland/setup-deno@v2` |
@@ -685,6 +685,8 @@ runtimes:
685685

686686
Omitted runtimes use the defaults above. Runtimes from imported shared workflows are merged with your workflow's configuration.
687687

688+
When Go is required and `runtimes.go.version` is omitted, the generated setup step uses `go-version-file: go.mod` if the repository root contains that file and the workflow checks out the same repository at workspace root before setup without an explicit ref. Sparse checkouts qualify when they retain root files (cone mode) or include `go.mod` (non-cone mode). Unverified checkout refs, repositories, or layouts use the default version. An explicit `runtimes.go.version` takes precedence.
689+
688690
### `run-install-scripts`
689691

690692
Controls whether npm pre/post-install scripts are allowed during package installation. Configure this under `runtimes.node.run-install-scripts`. The default is `false`.

‎pkg/workflow/compiler_workflow_helpers.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package workflow
22

33
import (
4+
"path"
45
"path/filepath"
6+
"strconv"
57
"strings"
68

79
"github.com/goccy/go-yaml"
@@ -17,6 +19,93 @@ func ContainsCheckout(customSteps string) bool {
1719
return found
1820
}
1921

22+
// customCheckoutHasRootGoMod checks the first checkout, where runtime setup is inserted.
23+
func customCheckoutHasRootGoMod(customSteps string) bool {
24+
return checkoutStepsHaveRootGoMod(customSteps, true)
25+
}
26+
27+
func checkoutStepsHaveRootGoMod(checkoutSteps string, firstCheckoutOnly bool) bool {
28+
var wrapper struct {
29+
Steps []struct {
30+
Uses string `yaml:"uses"`
31+
If any `yaml:"if"`
32+
With struct {
33+
Path string `yaml:"path"`
34+
Repository string `yaml:"repository"`
35+
Ref string `yaml:"ref"`
36+
SparseCheckout string `yaml:"sparse-checkout"`
37+
ConeMode string `yaml:"sparse-checkout-cone-mode"`
38+
} `yaml:"with"`
39+
} `yaml:"steps"`
40+
}
41+
if err := yaml.Unmarshal([]byte(checkoutSteps), &wrapper); err != nil || len(wrapper.Steps) == 0 {
42+
if err := yaml.Unmarshal([]byte(checkoutSteps), &wrapper.Steps); err != nil {
43+
return false
44+
}
45+
}
46+
hasRootGoMod := false
47+
for _, step := range wrapper.Steps {
48+
if !isCheckoutActionReference(step.Uses) {
49+
continue
50+
}
51+
atRoot := step.With.Path == "" || filepath.Clean(step.With.Path) == "."
52+
if atRoot || firstCheckoutOnly {
53+
repository := strings.Join(strings.Fields(step.With.Repository), "")
54+
hasRootGoMod = atRoot &&
55+
(repository == "" || repository == "${{github.repository}}") &&
56+
step.If == nil && step.With.Ref == "" &&
57+
sparseCheckoutIncludesRootGoMod(step.With.SparseCheckout, step.With.ConeMode)
58+
}
59+
if firstCheckoutOnly {
60+
break
61+
}
62+
}
63+
return hasRootGoMod
64+
}
65+
66+
func sparseCheckoutIncludesRootGoMod(patterns, coneMode string) bool {
67+
if coneMode == "" {
68+
return true
69+
}
70+
cone, err := strconv.ParseBool(coneMode)
71+
if err != nil {
72+
return false
73+
}
74+
if cone {
75+
return true
76+
}
77+
if strings.Contains(patterns, "${{") {
78+
return false
79+
}
80+
if patterns == "" {
81+
return true
82+
}
83+
included := false
84+
for pattern := range strings.SplitSeq(patterns, "\n") {
85+
pattern = strings.TrimSpace(pattern)
86+
if pattern == "" || strings.HasPrefix(pattern, "#") {
87+
continue
88+
}
89+
// Git supports character classes that path.Match cannot interpret.
90+
if strings.Contains(pattern, "[:") || strings.Contains(pattern, "[!") {
91+
return false
92+
}
93+
excluded := strings.HasPrefix(pattern, "!")
94+
pattern = strings.TrimPrefix(strings.TrimPrefix(pattern, "!"), "/")
95+
for strings.HasPrefix(pattern, "**/") {
96+
pattern = strings.TrimPrefix(pattern, "**/")
97+
}
98+
matches, err := path.Match(pattern, "go.mod")
99+
if err != nil {
100+
return false
101+
}
102+
if matches {
103+
included = !excluded
104+
}
105+
}
106+
return included
107+
}
108+
20109
// findLastCheckoutStepIndex returns the zero-based index of the last
21110
// actions/checkout step in customSteps and true, or (0, false) if none is found.
22111
// It uses the same parsing strategy as findFirstCheckoutStepIndex.

‎pkg/workflow/compiler_yaml_runtime_setup.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"path/filepath"
89
"strings"
910

1011
"github.com/github/gh-aw/pkg/console"
@@ -118,6 +119,30 @@ func (c *Compiler) prepareRuntimeSetupAndCheckoutInfo(data *WorkflowData) ([]Git
118119
return nil, false, err
119120
}
120121

122+
// Prefer the repository's Go version unless the workflow specifies one.
123+
checkoutMgr := NewCheckoutManager(data.CheckoutConfigs)
124+
var checkoutSteps strings.Builder
125+
if c.shouldAddCheckoutStep(data) {
126+
for _, step := range checkoutMgr.GenerateDefaultCheckoutStep(c.trialMode, c.trialLogicalRepoSlug, c.getActionPin) {
127+
checkoutSteps.WriteString(step)
128+
}
129+
}
130+
for _, step := range checkoutMgr.GenerateAdditionalCheckoutSteps(c.getActionPin) {
131+
checkoutSteps.WriteString(step)
132+
}
133+
hasCheckout := checkoutStepsHaveRootGoMod(checkoutSteps.String(), false)
134+
if ContainsCheckout(data.CustomSteps) && !c.shouldAddCheckoutStep(data) {
135+
hasCheckout = customCheckoutHasRootGoMod(data.CustomSteps)
136+
}
137+
for i := range runtimeRequirements {
138+
req := &runtimeRequirements[i]
139+
if req.Runtime.ID == "go" && req.Version == "" && req.GoModFile == "" && c.gitRoot != "" && hasCheckout {
140+
if info, err := os.Stat(filepath.Join(c.gitRoot, "go.mod")); err == nil && info.Mode().IsRegular() {
141+
req.GoModFile = "go.mod"
142+
}
143+
}
144+
}
145+
121146
// Generate runtime setup steps (after filtering out user-customized ones)
122147
runtimeSetupSteps := GenerateRuntimeSetupSteps(runtimeRequirements, data)
123148
compilerYamlLog.Printf("Detected runtime requirements: %d runtimes, %d setup steps", len(runtimeRequirements), len(runtimeSetupSteps))

0 commit comments

Comments
 (0)