Skip to content

Commit 9b92323

Browse files
committed
test2json: Passthrough github actions commands to stderr
Because `go test` seems to be capturing stderr and passing it to stdout instead (and thus is getting captured by test2jsongha), check if a given event output looks like a github actions command and pass that through. Signed-off-by: Brian Goff <[email protected]>
1 parent 4ea0dd8 commit 9b92323

File tree

6 files changed

+125
-45
lines changed

6 files changed

+125
-45
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ jobs:
159159
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
160160
- name: Run integration tests
161161
run: |
162+
env | grep GITHUB
162163
set -ex
163164
if [ -n "${TEST_SUITE}" ] && [ ! "${TEST_SUITE}" = "other" ]; then
164165
run="-run=${TEST_SUITE}"

cmd/test2json2gha/event.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"math"
88
"os"
99
"path"
10+
"regexp"
1011
"strings"
12+
"sync"
1113
"time"
1214

1315
"github.com/Azure/dalec"
@@ -47,7 +49,29 @@ type outputStreamer struct {
4749

4850
func (h *outputStreamer) HandleEvent(te *TestEvent) error {
4951
if te.Output != "" {
50-
_, err := h.out.Write([]byte(te.Output))
52+
if !githubActionsCommand().MatchString(te.Output) {
53+
_, err := h.out.Write([]byte(te.Output))
54+
return err
55+
}
56+
}
57+
return nil
58+
}
59+
60+
var githubActionsCommand = sync.OnceValue(func() *regexp.Regexp {
61+
return regexp.MustCompile(`^::(error|warning|notice|add-mask)(?:\s+file=([^,:]+)(?:,line=(\d+))?)?::`)
62+
})
63+
64+
type githubActionsCommandPassthrough struct {
65+
out io.Writer
66+
}
67+
68+
func (h *githubActionsCommandPassthrough) HandleEvent(te *TestEvent) error {
69+
if te.Output != "" && githubActionsCommand().MatchString(te.Output) {
70+
out := h.out
71+
if out == nil {
72+
out = os.Stderr
73+
}
74+
_, err := out.Write([]byte(te.Output))
5175
return err
5276
}
5377
return nil
@@ -109,9 +133,11 @@ func (h *resultsHandler) HandleEvent(te *TestEvent) error {
109133
tr.elapsed = te.Elapsed
110134

111135
if te.Output != "" {
112-
_, err := tr.output.WriteString(te.Output)
113-
if err != nil {
114-
return errors.Wrap(err, "error collecting test event output")
136+
if !githubActionsCommand().MatchString(te.Output) {
137+
_, err := tr.output.WriteString(te.Output)
138+
if err != nil {
139+
return errors.Wrap(err, "error collecting test event output")
140+
}
115141
}
116142
}
117143

cmd/test2json2gha/event_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ const (
7373
{"Time":"2025-06-04T09:51:34.185052-07:00","Action":"output","Package":"some_package","Test":"TestGenTimeout","Output":"exit status 2\n"}
7474
{"Time":"2025-03-31T09:46:20.328007-07:00","Action":"output","Package":"some_package","Output":"FAIL\n"}
7575
{"Time":"2025-03-31T09:46:20.328475-07:00","Action":"output","Package":"some_package","Output":"FAIL\tsome_package\t0.249s\n"}
76+
{"Time": "2025-03-31T09:46:20.32851-07:00","Action":"output","Package":"some_package","Output":"::warning file=foo_test.go,line=38::hello warning\n"}
77+
{"Time": "2025-03-31T09:46:20.32851-07:00","Action":"output","Package":"some_package","Output":"::notice::hello notice\n"}
7678
{"Time":"2025-03-31T09:46:20.32851-07:00","Action":"fail","Package":"some_package","Elapsed":0.25}
7779
`
7880

@@ -122,6 +124,8 @@ exit status 2
122124
testLogsAnnotation = " foo_test.go:42: some error\n foo_test.go:43: some fatal error\n"
123125

124126
testPackageName = "some_package"
127+
128+
ghaCommandsOutput = "::warning file=foo_test.go,line=38::hello warning\n::notice::hello notice\n"
125129
)
126130

127131
func mockTestResults(t *testing.T) iter.Seq[*TestResult] {
@@ -275,3 +279,15 @@ func TestWriteLogs(t *testing.T) {
275279
assert.Equal(t, string(content), string(output), "log file content does not match expected output")
276280
}
277281
}
282+
283+
func TestGithubActionsPassthrough(t *testing.T) {
284+
var output strings.Builder
285+
out := &githubActionsCommandPassthrough{out: &output}
286+
287+
for event := range readTestEvents(t) {
288+
err := out.HandleEvent(event)
289+
assert.NilError(t, err)
290+
}
291+
292+
assert.Equal(t, output.String(), ghaCommandsOutput)
293+
}

cmd/test2json2gha/main.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import (
1515
)
1616

1717
type config struct {
18-
slowThreshold time.Duration
19-
modName string
20-
verbose bool
21-
stream bool
22-
logDir string
18+
slowThreshold time.Duration
19+
modName string
20+
verbose bool
21+
stream bool
22+
logDir string
23+
ghaCommandsOut io.Writer
2324
}
2425

2526
func main() {
@@ -107,6 +108,7 @@ func do(in io.Reader, out io.Writer, cfg config) (bool, error) {
107108
handlers := []EventHandler{
108109
results,
109110
&anyFailed,
111+
&githubActionsCommandPassthrough{out: cfg.ghaCommandsOut},
110112
}
111113

112114
if cfg.stream {

cmd/test2json2gha/main_test.go

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"gotest.tools/v3/assert"
12+
"gotest.tools/v3/assert/cmp"
1213
)
1314

1415
const (
@@ -28,11 +29,13 @@ func TestDo(t *testing.T) {
2829
input := strings.NewReader(testEventJSON)
2930
var output bytes.Buffer
3031

32+
ghaBuff := bytes.NewBuffer(nil)
3133
cfg := config{
32-
slowThreshold: 200 * time.Millisecond,
33-
modName: testModuleName,
34-
verbose: false,
35-
stream: false,
34+
slowThreshold: 200 * time.Millisecond,
35+
modName: testModuleName,
36+
verbose: false,
37+
stream: false,
38+
ghaCommandsOut: ghaBuff,
3639
}
3740

3841
anyFail, err := do(input, &output, cfg)
@@ -41,18 +44,21 @@ func TestDo(t *testing.T) {
4144

4245
// Non-verbose output should only include the grouped fail events + error annotations
4346
expected := failGroup + timeoutGroup + annotation
44-
assert.Equal(t, expected, output.String())
47+
assert.Check(t, cmp.Equal(expected, output.String()))
48+
assert.Check(t, cmp.Equal(ghaCommandsOutput, ghaBuff.String()))
4549
})
4650

4751
t.Run("verbose=true", func(t *testing.T) {
4852
input := strings.NewReader(testEventJSON)
4953
var output bytes.Buffer
5054

55+
ghaBuff := bytes.NewBuffer(nil)
5156
cfg := config{
52-
slowThreshold: 200 * time.Millisecond,
53-
modName: testModuleName,
54-
verbose: true,
55-
stream: false,
57+
slowThreshold: 200 * time.Millisecond,
58+
modName: testModuleName,
59+
verbose: true,
60+
stream: false,
61+
ghaCommandsOut: ghaBuff,
5662
}
5763

5864
anyFail, err := do(input, &output, cfg)
@@ -62,17 +68,21 @@ func TestDo(t *testing.T) {
6268
// verbose output should include grouped events for all test results + error annotations
6369
expected := failGroup + passGroup + skipGroup + timeoutGroup + annotation
6470
assert.Equal(t, output.String(), expected)
71+
assert.Check(t, cmp.Equal(expected, output.String()))
72+
assert.Check(t, cmp.Equal(ghaCommandsOutput, ghaBuff.String()))
6573
})
6674

6775
t.Run("stream=true", func(t *testing.T) {
6876
input := strings.NewReader(testEventJSON)
6977
var output bytes.Buffer
7078

79+
ghaBuff := bytes.NewBuffer(nil)
7180
cfg := config{
72-
slowThreshold: 200 * time.Millisecond,
73-
modName: testModuleName,
74-
verbose: false,
75-
stream: true,
81+
slowThreshold: 200 * time.Millisecond,
82+
modName: testModuleName,
83+
verbose: false,
84+
stream: true,
85+
ghaCommandsOut: ghaBuff,
7686
}
7787

7888
anyFail, err := do(input, &output, cfg)
@@ -81,7 +91,8 @@ func TestDo(t *testing.T) {
8191

8292
// Stream output should include all the raw events + the grouped fail events + the annotation
8393
expected := testEventPassOutput + testEventFailOutput + testEventSkipOutput + testEventTimeoutOutput + testEventPackageOutput + failGroup + timeoutGroup + annotation
84-
assert.Equal(t, output.String(), expected)
94+
assert.Check(t, cmp.Equal(expected, output.String()))
95+
assert.Check(t, cmp.Equal(ghaCommandsOutput, ghaBuff.String()))
8596
})
8697

8798
t.Run("summary", func(t *testing.T) {
@@ -94,11 +105,13 @@ func TestDo(t *testing.T) {
94105

95106
input := strings.NewReader(testEventJSON)
96107

108+
ghaBuff := bytes.NewBuffer(nil)
97109
cfg := config{
98-
slowThreshold: 200 * time.Millisecond,
99-
modName: testModuleName,
100-
verbose: false,
101-
stream: false,
110+
slowThreshold: 200 * time.Millisecond,
111+
modName: testModuleName,
112+
verbose: false,
113+
stream: false,
114+
ghaCommandsOut: ghaBuff,
102115
}
103116

104117
anyFail, err := do(input, io.Discard, cfg)
@@ -125,20 +138,23 @@ some_package: 0.250s
125138
126139
`
127140

128-
assert.Equal(t, string(output), expect)
141+
assert.Check(t, cmp.Equal(string(output), expect))
142+
assert.Check(t, cmp.Equal(ghaCommandsOutput, ghaBuff.String()))
129143
})
130144

131145
t.Run("LogDir", func(t *testing.T) {
132146
input := strings.NewReader(testEventJSON)
133147
var output bytes.Buffer
134148

135149
logDir := t.TempDir()
150+
ghaBuff := bytes.NewBuffer(nil)
136151
cfg := config{
137-
slowThreshold: 500,
138-
modName: "github.com/Azure/dalec/cmd/test2json2gha",
139-
verbose: false,
140-
stream: false,
141-
logDir: logDir,
152+
slowThreshold: 500,
153+
modName: "github.com/Azure/dalec/cmd/test2json2gha",
154+
verbose: false,
155+
stream: false,
156+
logDir: logDir,
157+
ghaCommandsOut: ghaBuff,
142158
}
143159

144160
anyFail, err := do(input, &output, cfg)
@@ -148,6 +164,7 @@ some_package: 0.250s
148164
// Validate that logs are written to the specified directory
149165
entries, err := os.ReadDir(logDir)
150166
assert.NilError(t, err)
151-
assert.Assert(t, len(entries) > 0, "expected log files to be written to the log directory")
167+
assert.Check(t, len(entries) > 0, "expected log files to be written to the log directory")
168+
assert.Check(t, cmp.Equal(ghaCommandsOutput, ghaBuff.String()))
152169
})
153170
}

test/testenv/build.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,47 @@ func lookupProjectRoot(cur string) (string, error) {
224224
return cur, nil
225225
}
226226

227-
var ciLoadCacheOptions = sync.OnceValue(func() (out []client.CacheOptionsEntry) {
227+
func ghaAnnotation(skipFrames int, cmd string, msg string) {
228+
ghaAnnotationf(skipFrames+1, cmd, "%s", msg)
229+
}
230+
231+
func ghaAnnotationf(skipFrames int, cmd string, format string, args ...any) {
232+
_, f, l, _ := runtime.Caller(skipFrames + 1)
228233
if os.Getenv("GITHUB_ACTIONS") != "true" {
229234
// not running in a github action, nothing to do
230235
return
231236
}
232237

238+
format = "::%s file=%s,line=%d::%s\n" + format
239+
args = append([]any{cmd, f, l}, args...)
240+
fmt.Printf(format, args...)
241+
}
242+
243+
var ciLoadCacheOptions = sync.OnceValue(func() (out []client.CacheOptionsEntry) {
244+
const (
245+
ghaEnv = "GITHUB_ACTIONS"
246+
tokenEnv = "ACTIONS_RUNTIME_TOKEN"
247+
urlEnv = "ACTIONS_CACHE_URL"
248+
)
249+
if os.Getenv(ghaEnv) != "true" {
250+
// not running in a github action, nothing to do
251+
return
252+
}
253+
254+
ghaAnnotation(0, "notice", "Loading cache options for GitHub Actions")
255+
233256
// token and url are required for the cache to work.
234257
// These need to be exposed as environment variables in the GitHub Actions workflow.
235258
// See the crazy-max/ghaction-github-runtime@v3 action.
236-
token := os.Getenv("ACTIONS_RUNTIME_TOKEN")
259+
token := os.Getenv(tokenEnv)
237260
if token == "" {
238-
fmt.Fprintln(os.Stderr, "::warning::GITHUB_ACTIONS_RUNTIME_TOKEN is not set, skipping cache export")
261+
ghaAnnotationf(0, "warning", "%s is not set, skipping cache export", tokenEnv)
239262
return nil
240263
}
241264

242265
url := os.Getenv("ACTIONS_CACHE_URL")
243266
if url == "" {
244-
fmt.Fprintln(os.Stderr, "::warning::ACTIONS_CACHE_URL is not set, skipping cache export")
267+
ghaAnnotationf(0, "warning", "%s is not set, skipping cache export", urlEnv)
245268
return nil
246269
}
247270

@@ -254,6 +277,7 @@ var ciLoadCacheOptions = sync.OnceValue(func() (out []client.CacheOptionsEntry)
254277

255278
for _, r := range plugins.Graph(filter) {
256279
target := path.Join(r.ID, "worker")
280+
ghaAnnotationf(0, "notice", "Adding cache import: type: gha target: %q", "gha", target)
257281
out = append(out, client.CacheOptionsEntry{
258282
Type: "gha",
259283
Attrs: map[string]string{
@@ -264,14 +288,8 @@ var ciLoadCacheOptions = sync.OnceValue(func() (out []client.CacheOptionsEntry)
264288
})
265289
}
266290

267-
fmt.Fprintln(os.Stderr, "::add-mask::"+token)
268-
_, f, l, _ := runtime.Caller(1)
269291
if len(out) == 0 {
270-
_, f, l, _ := runtime.Caller(1)
271-
fmt.Fprintf(os.Stderr, "::error file=%s,line=%d::No build targets found, skipping cache export\n", f, l)
272-
}
273-
for _, o := range out {
274-
fmt.Fprintf(os.Stderr, "::notice file=%s,line=%d::Adding cache import: %s %v\n", f, l, o.Type, o.Attrs)
292+
ghaAnnotation(0, "error", "No build targets found, skipping cache export")
275293
}
276294
return out
277295
})

0 commit comments

Comments
 (0)