From e626ada899467caf4efc6cfd0484b965da944ce8 Mon Sep 17 00:00:00 2001 From: v-mahabaleshwars <147705296+v-mahabaleshwars@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:35:51 +0530 Subject: [PATCH] Expose go env variables as action outputs (#791) * feature implementation - output go env * update action.yml * fix action.yml --------- Co-authored-by: mahabaleshwars <147705296+mahabaleshwars@users.noreply.github.com> --- .github/workflows/versions.yml | 25 ++++++++ __tests__/setup-go.test.ts | 104 +++++++++++++++++++++++++++++++++ action.yml | 18 ++++++ dist/cache-save/index.js | 9 +++ dist/setup/index.js | 53 +++++++++++++++++ docs/advanced-usage.md | 35 ++++++++++- src/constants.ts | 11 +++- src/main.ts | 57 ++++++++++++++++++ 8 files changed, 310 insertions(+), 2 deletions(-) diff --git a/.github/workflows/versions.yml b/.github/workflows/versions.yml index df57aac..d52701f 100644 --- a/.github/workflows/versions.yml +++ b/.github/workflows/versions.yml @@ -43,6 +43,31 @@ jobs: - name: Verify Go run: go version + go-env-outputs: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Go + id: setup-go + uses: ./ + with: + go-version: stable + - name: Show Go environment outputs + run: | + echo "go-path: ${{ steps.setup-go.outputs.go-path }}" + echo "go-bin: ${{ steps.setup-go.outputs.go-bin }}" + echo "go-bin-path: ${{ steps.setup-go.outputs.go-bin-path }}" + echo "go-root: ${{ steps.setup-go.outputs.go-root }}" + echo "go-cache: ${{ steps.setup-go.outputs.go-cache }}" + echo "go-mod-cache: ${{ steps.setup-go.outputs.go-mod-cache }}" + echo "go-os: ${{ steps.setup-go.outputs.go-os }}" + echo "go-arch: ${{ steps.setup-go.outputs.go-arch }}" + echo "go-tool-dir: ${{ steps.setup-go.outputs.go-tool-dir }}" + aliases-arch: runs-on: ${{ matrix.os }} strategy: diff --git a/__tests__/setup-go.test.ts b/__tests__/setup-go.test.ts index 3884a38..c595855 100644 --- a/__tests__/setup-go.test.ts +++ b/__tests__/setup-go.test.ts @@ -28,6 +28,8 @@ jest.unstable_mockModule('@actions/core', () => ({ getBooleanInput: jest.fn(), info: jest.fn(), debug: jest.fn(), + warning: jest.fn(), + setOutput: jest.fn(), exportVariable: jest.fn() })); @@ -110,6 +112,8 @@ describe('setup-go', () => { let findSpy: jest.Mock; let cnSpy: jest.SpiedFunction; let logSpy: jest.Mock; + let setOutputSpy: jest.Mock; + let warningSpy: jest.Mock; let getSpy: jest.Mock; let platSpy: jest.Mock; let archSpy: jest.Mock; @@ -198,6 +202,8 @@ describe('setup-go', () => { // writes cnSpy = jest.spyOn(process.stdout, 'write'); logSpy = core.info as jest.Mock; + setOutputSpy = core.setOutput as jest.Mock; + warningSpy = core.warning as jest.Mock; dbgSpy = core.debug as jest.Mock; getSpy.mockImplementation(() => goJsonData as IGoVersion[] | null); cnSpy.mockImplementation(() => true); @@ -646,6 +652,104 @@ describe('setup-go', () => { expect(added).toBeTruthy(); }); + describe('go env outputs', () => { + const goEnv = { + GOPATH: '/Users/testuser/go', + GOBIN: '', + GOROOT: '/usr/local/go', + GOCACHE: '/Users/testuser/.cache/go-build', + GOMODCACHE: '/Users/testuser/go/pkg/mod', + GOOS: 'darwin', + GOARCH: 'arm64', + GOTOOLDIR: '/usr/local/go/pkg/tool/darwin_arm64' + }; + + it('parses the output of go env -json', () => { + execFileSpy.mockImplementation(() => JSON.stringify(goEnv)); + + expect(main.readGoEnv('/usr/local/go/bin/go')).toEqual(goEnv); + }); + + it('returns undefined when go env -json is not supported', () => { + execFileSpy.mockImplementation(() => { + throw new Error('flag provided but not defined: -json'); + }); + + expect(main.readGoEnv('/usr/local/go/bin/go')).toBeUndefined(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('flag provided but not defined: -json') + ); + expect(warningSpy).not.toHaveBeenCalled(); + }); + + it('returns undefined when the output is not valid JSON', () => { + execFileSpy.mockImplementation(() => 'GOPATH="/Users/testuser/go"'); + + expect(main.readGoEnv('/usr/local/go/bin/go')).toBeUndefined(); + }); + + it('returns undefined when the output is not a JSON object', () => { + execFileSpy.mockImplementation(() => '[]'); + + expect(main.readGoEnv('/usr/local/go/bin/go')).toBeUndefined(); + }); + + it('sets an output for each exposed variable', () => { + main.setGoEnvOutputs(goEnv); + + expect(setOutputSpy).toHaveBeenCalledWith('go-path', goEnv.GOPATH); + expect(setOutputSpy).toHaveBeenCalledWith('go-bin', ''); + expect(setOutputSpy).toHaveBeenCalledWith('go-root', goEnv.GOROOT); + expect(setOutputSpy).toHaveBeenCalledWith('go-cache', goEnv.GOCACHE); + expect(setOutputSpy).toHaveBeenCalledWith( + 'go-mod-cache', + goEnv.GOMODCACHE + ); + expect(setOutputSpy).toHaveBeenCalledWith('go-os', goEnv.GOOS); + expect(setOutputSpy).toHaveBeenCalledWith('go-arch', goEnv.GOARCH); + expect(setOutputSpy).toHaveBeenCalledWith('go-tool-dir', goEnv.GOTOOLDIR); + }); + + it('falls back to $GOPATH/bin when GOBIN is empty', () => { + main.setGoEnvOutputs(goEnv); + + expect(setOutputSpy).toHaveBeenCalledWith( + 'go-bin-path', + '/Users/testuser/go/bin' + ); + }); + + it('prefers GOBIN over $GOPATH/bin when it is set', () => { + main.setGoEnvOutputs({...goEnv, GOBIN: '/Users/testuser/bin'}); + + expect(setOutputSpy).toHaveBeenCalledWith( + 'go-bin-path', + '/Users/testuser/bin' + ); + }); + + it('uses the first entry of a multi-entry GOPATH', () => { + const GOPATH = ['/Users/testuser/go', '/Users/testuser/other'].join( + path.delimiter + ); + + main.setGoEnvOutputs({...goEnv, GOPATH}); + + expect(setOutputSpy).toHaveBeenCalledWith('go-path', GOPATH); + expect(setOutputSpy).toHaveBeenCalledWith( + 'go-bin-path', + '/Users/testuser/go/bin' + ); + }); + + it('leaves variables missing from go env -json empty', () => { + main.setGoEnvOutputs({}); + + expect(setOutputSpy).toHaveBeenCalledWith('go-path', ''); + expect(setOutputSpy).toHaveBeenCalledWith('go-bin-path', ''); + }); + }); + interface Annotation { file: string; line: number; diff --git a/action.yml b/action.yml index 74a4e7e..919604e 100644 --- a/action.yml +++ b/action.yml @@ -26,6 +26,24 @@ outputs: description: 'The installed Go version. Useful when given a version range as input.' cache-hit: description: 'A boolean value to indicate if a cache was hit' + go-path: + description: 'The value of `go env GOPATH`.' + go-bin: + description: 'The value of `go env GOBIN`.' + go-bin-path: + description: 'The base Go binary directory: `GOBIN`, otherwise `bin` under the first `GOPATH` entry.' + go-root: + description: 'The value of `go env GOROOT`.' + go-cache: + description: 'The value of `go env GOCACHE`, the build cache directory.' + go-mod-cache: + description: 'The value of `go env GOMODCACHE`, the module cache directory.' + go-os: + description: 'The value of `go env GOOS`, the target OS in Go notation (linux, darwin, windows).' + go-arch: + description: 'The value of `go env GOARCH`, the target architecture in Go notation (amd64, arm64).' + go-tool-dir: + description: 'The value of `go env GOTOOLDIR`, the directory holding the toolchain binaries.' runs: using: 'node24' main: 'dist/setup/index.js' diff --git a/dist/cache-save/index.js b/dist/cache-save/index.js index a5b4218..adfef81 100644 --- a/dist/cache-save/index.js +++ b/dist/cache-save/index.js @@ -95641,6 +95641,15 @@ var State; var Outputs; (function (Outputs) { Outputs["CacheHit"] = "cache-hit"; + Outputs["GoPath"] = "go-path"; + Outputs["GoBin"] = "go-bin"; + Outputs["GoBinPath"] = "go-bin-path"; + Outputs["GoRoot"] = "go-root"; + Outputs["GoCache"] = "go-cache"; + Outputs["GoModCache"] = "go-mod-cache"; + Outputs["GoOs"] = "go-os"; + Outputs["GoArch"] = "go-arch"; + Outputs["GoToolDir"] = "go-tool-dir"; })(Outputs || (Outputs = {})); ;// CONCATENATED MODULE: ./src/package-managers.ts diff --git a/dist/setup/index.js b/dist/setup/index.js index c206971..2aa3b6d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -100441,6 +100441,15 @@ var State; var Outputs; (function (Outputs) { Outputs["CacheHit"] = "cache-hit"; + Outputs["GoPath"] = "go-path"; + Outputs["GoBin"] = "go-bin"; + Outputs["GoBinPath"] = "go-bin-path"; + Outputs["GoRoot"] = "go-root"; + Outputs["GoCache"] = "go-cache"; + Outputs["GoModCache"] = "go-mod-cache"; + Outputs["GoOs"] = "go-os"; + Outputs["GoArch"] = "go-arch"; + Outputs["GoToolDir"] = "go-tool-dir"; })(Outputs || (Outputs = {})); ;// CONCATENATED MODULE: ./src/package-managers.ts @@ -100567,6 +100576,7 @@ const findDependencyFile = (packageManager) => { + async function run() { try { // @@ -100616,6 +100626,10 @@ async function run() { core_debug(`add bin ${added}`); const goPath = await which('go'); const goVersion = (external_child_process_default().execSync(`${goPath} version`) || '').toString(); + const goEnvJson = readGoEnv(goPath); + if (goEnvJson) { + setGoEnvOutputs(goEnvJson); + } if (cache && isCacheFeatureAvailable()) { const packageManager = 'default'; const cacheDependencyPath = getInput('cache-dependency-path'); @@ -100641,6 +100655,45 @@ async function run() { setFailed(error.message); } } +function readGoEnv(goPath) { + try { + const rawGoEnv = external_child_process_default().execFileSync(goPath, ['env', '-json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + const parsed = JSON.parse(rawGoEnv); + if (typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed)) { + throw new Error("'go env -json' did not return a JSON object"); + } + return parsed; + } + catch (error) { + core_info(`Unable to read 'go env -json', the Go environment outputs will not be set: ${error.message}`); + return undefined; + } +} +const goEnvOutputs = [ + [Outputs.GoPath, 'GOPATH'], + [Outputs.GoBin, 'GOBIN'], + [Outputs.GoRoot, 'GOROOT'], + [Outputs.GoCache, 'GOCACHE'], + [Outputs.GoModCache, 'GOMODCACHE'], + [Outputs.GoOs, 'GOOS'], + [Outputs.GoArch, 'GOARCH'], + [Outputs.GoToolDir, 'GOTOOLDIR'] +]; +function setGoEnvOutputs(goEnv) { + for (const [output, variable] of goEnvOutputs) { + setOutput(output, goEnv[variable] ?? ''); + } + setOutput(Outputs.GoBinPath, goEnv['GOBIN'] || goPathBin(goEnv)); +} +function goPathBin(goEnv) { + const goPath = (goEnv['GOPATH'] ?? '').split((external_path_default()).delimiter)[0]; + return goPath ? external_path_default().join(goPath, 'bin') : ''; +} async function addBinToPath() { let added = false; const g = await which('go'); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index e0932f3..ef67034 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -12,6 +12,9 @@ - [Restore-only caches](advanced-usage.md#restore-only-caches) - [Parallel builds](advanced-usage.md#parallel-builds) - [Outputs](advanced-usage.md#outputs) + - [go-version](advanced-usage.md#go-version) + - [cache-hit](advanced-usage.md#cache-hit) + - [Go environment outputs](advanced-usage.md#go-environment-outputs) - [Custom download URL](advanced-usage.md#custom-download-url) - [Using `setup-go` on GHES](advanced-usage.md#using-setup-go-on-ghes) @@ -420,6 +423,36 @@ jobs: - run: echo "Was the Go cache restored? ${{ steps.go124.outputs.cache-hit }}" # true if cache-hit occurred ``` +### Go environment outputs + +Once Go is on the `PATH`, the action exposes the most commonly needed `go env` variables as outputs, so workflows don't have to query them in duplicated `bash`/`pwsh` steps — one expression works on Linux, macOS, and Windows. The values are a snapshot from setup time: anything a later step changes, such as setting `GOOS`/`GOARCH` to cross-compile, is not reflected. + +| Output | `go env` variable | Notes | +| --- | --- | --- | +| `go-path` | `GOPATH` | Go workspace root; `bin` and `pkg/mod` live under it | +| `go-bin` | `GOBIN` | Go 1.27+ reports an implicit `$GOPATH/bin` default; earlier releases are empty unless `GOBIN` is set | +| `go-bin-path` | `GOBIN` or `$GOPATH/bin` | The base Go binary directory, using the first `GOPATH` entry. Cross-compiled binaries go one level deeper, in `$GOPATH/bin/$GOOS_$GOARCH` | +| `go-root` | `GOROOT` | Installation directory of the Go toolchain in use | +| `go-cache` | `GOCACHE` | Build cache directory | +| `go-mod-cache` | `GOMODCACHE` | Module cache directory | +| `go-os` | `GOOS` | Go notation (`linux`, `darwin`, `windows`), unlike `runner.os` | +| `go-arch` | `GOARCH` | Go notation (`amd64`, `arm64`), unlike `runner.arch` or the `x64` in the action's cache key | +| `go-tool-dir` | `GOTOOLDIR` | Holds `compile`, `link`, `vet` and the other toolchain binaries | + +```yaml +steps: + - uses: actions/setup-go@v7 + id: setup-go + with: + go-version: '1.25.5' + - run: echo "Modules are cached in ${{ steps.setup-go.outputs.go-mod-cache }}" +``` + +Quote `go-bin-path` in shell commands, since it may contain spaces. Variables without a dedicated output are not exposed; run `go env ` in a step when you need one. + +> [!NOTE] +> These outputs need Go 1.9 or newer (`go env -json`); on older releases the action logs a message, leaves them unset and does not fail. `go-cache` needs Go 1.10 and `go-mod-cache` needs Go 1.15. + ## Custom download URL The `go-download-base-url` input lets you download Go from a mirror or alternative source instead of the default `https://go.dev/dl`. This can also be set via the `GO_DOWNLOAD_BASE_URL` environment variable; the input takes precedence over the environment variable. @@ -495,4 +528,4 @@ with: If the runner is not able to access github.com, any Go versions requested during a workflow run must come from the runner's tool cache. See "[Setting up the tool cache on self-hosted runners without internet access](https://docs.github.com/en/enterprise-server@3.2/admin/github-actions/managing-access-to-actions-from-githubcom/setting-up-the-tool-cache-on-self-hosted-runners-without-internet-access)" -for more information. +for more information. \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index b43d18c..685a563 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,5 +4,14 @@ export enum State { } export enum Outputs { - CacheHit = 'cache-hit' + CacheHit = 'cache-hit', + GoPath = 'go-path', + GoBin = 'go-bin', + GoBinPath = 'go-bin-path', + GoRoot = 'go-root', + GoCache = 'go-cache', + GoModCache = 'go-mod-cache', + GoOs = 'go-os', + GoArch = 'go-arch', + GoToolDir = 'go-tool-dir' } diff --git a/src/main.ts b/src/main.ts index 2bc2765..86487fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import cp from 'child_process'; import fs from 'fs'; import os from 'os'; import {Architecture} from './types.js'; +import {Outputs} from './constants.js'; export async function run() { try { @@ -82,6 +83,11 @@ export async function run() { const goPath = await io.which('go'); const goVersion = (cp.execSync(`${goPath} version`) || '').toString(); + const goEnvJson = readGoEnv(goPath); + + if (goEnvJson) { + setGoEnvOutputs(goEnvJson); + } if (cache && isCacheFeatureAvailable()) { const packageManager = 'default'; @@ -119,6 +125,57 @@ export async function run() { } } +export function readGoEnv(goPath: string): Record | undefined { + try { + const rawGoEnv = cp.execFileSync(goPath, ['env', '-json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + const parsed: unknown = JSON.parse(rawGoEnv); + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("'go env -json' did not return a JSON object"); + } + + return parsed as Record; + } catch (error) { + core.info( + `Unable to read 'go env -json', the Go environment outputs will not be set: ${ + (error as Error).message + }` + ); + return undefined; + } +} + +const goEnvOutputs: ReadonlyArray<[Outputs, string]> = [ + [Outputs.GoPath, 'GOPATH'], + [Outputs.GoBin, 'GOBIN'], + [Outputs.GoRoot, 'GOROOT'], + [Outputs.GoCache, 'GOCACHE'], + [Outputs.GoModCache, 'GOMODCACHE'], + [Outputs.GoOs, 'GOOS'], + [Outputs.GoArch, 'GOARCH'], + [Outputs.GoToolDir, 'GOTOOLDIR'] +]; + +export function setGoEnvOutputs(goEnv: Record): void { + for (const [output, variable] of goEnvOutputs) { + core.setOutput(output, goEnv[variable] ?? ''); + } + + core.setOutput(Outputs.GoBinPath, goEnv['GOBIN'] || goPathBin(goEnv)); +} + +function goPathBin(goEnv: Record): string { + const goPath = (goEnv['GOPATH'] ?? '').split(path.delimiter)[0]; + return goPath ? path.join(goPath, 'bin') : ''; +} + export async function addBinToPath(): Promise { let added = false; const g = await io.which('go');