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
25 changes: 25 additions & 0 deletions .github/workflows/ci-normalize-cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: CI Normalizer CD

on:
push:
branches:
- main
paths:
- tools/ci-normalize
tags:
- "tools/ci-normalize/v[0-9]+.[0-9]+.[0-9]+**"
pull_request:
paths:
- tools/ci-normalize/workflows/cd.yaml
- .github/workflows/ci-normalize-cd.yaml
- .github/workflows/reusable-cd.yaml

jobs:
release:
uses: ./.github/workflows/reusable-cd.yaml
permissions:
contents: write
packages: write
with:
tool-directory: ./tools/ci-normalize
14 changes: 14 additions & 0 deletions .github/workflows/ci-normalize-ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
name: CI test normalizer CI

on:
pull_request:

jobs:
release:
uses: ./.github/workflows/reusable-ci.yaml
permissions:
contents: write
packages: write
with:
tool-directory: ./tools/ci-normalize
1 change: 1 addition & 0 deletions tools/ci-normalize/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/*
6 changes: 6 additions & 0 deletions tools/ci-normalize/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
TOOL_NAME = ci-normalize
PACKAGE_PATH = ./cmd/ci-normalize.go
VERSION = v0.0.1


include ../repo-release-tooling/tooling.mk
50 changes: 50 additions & 0 deletions tools/ci-normalize/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ci-normalize

CI utility to normalize test results and capture runner metadata.

## Usage

### cli

```sh
# Generate normalized results from junit xml:
ci-normalize junit --from-meta meta.json \
--tests tests.jsonl \
--suites suites.jsonl \
--suites s3://bucket/suites.jsonl \
--meta /dev/null \
junit/*.xml
```

### Github Action

To use within another workflow:
```yaml
- name: User test step

- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838
id: aws-setup
if: always()
with:
aws-region: <region>
role-to-assume: <role with bucket access>

- name: Normalize test results and push to s3
uses: gravitational/shared-workflows/tools/ci-normalize@<SHA>
if: always() && steps.aws-setup.outcome == 'success'
continue-on-error: true
with:
s3-bucket: "s3://<bucket to use>/ci-metrics"
junit-files: "$GITHUB_WORKSPACE/test-logs/*.xml"
```

## Supported Formats

Inputs:
- [x] JUnit XML

Output:
- [x] jsonl

The output file schema can be found in `record/record.go`.
38 changes: 38 additions & 0 deletions tools/ci-normalize/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
name: Upload normalized test results
description: Captures metadata, normalizes the test results and uploads to s3.

inputs:
junit-files:
required: true
description: "junit files for normalization"
s3-bucket:
description: "S3 bucket for outputs"
required: true
default: ""
timeout:
description: "Maximum runtime for normalization and upload (e.g. 30s, 2m). 0 means no timeout."
required: false
default: "20m"
runs:
using: composite
steps:
# Once released we can switch to use the binary directly, cache is flaky for actions but the build and download is
# very fast for this binary so for now this should suffice. Consider cache or binary in the future to save bandwidth and time.
- uses: actions/setup-go@v6
with:
go-version-file: ${{ github.action_path }}/go.mod

- name: Normalize and push
id: normalize_and_push
shell: bash
working-directory: ${{ github.action_path }}
run: |
go run ./cmd junit \
--timeout "${{ inputs.timeout }}" \
--meta "${{ inputs.s3-bucket }}/jsonl/{{META_VERSION}}/jobs/repository={{REPOSITORY}}/year={{YEAR}}/month={{MONTH}}/day={{DAY}}/{{TIMESTAMP}}-job-{{ID}}.json" \
--meta - \
--tests "${{ inputs.s3-bucket }}/jsonl/{{META_VERSION}}/tests/repository={{REPOSITORY}}/year={{YEAR}}/month={{MONTH}}/day={{DAY}}/{{TIMESTAMP}}-tests-{{ID}}.jsonl" \
--suites "${{ inputs.s3-bucket }}/jsonl/{{META_VERSION}}/suites/repository={{REPOSITORY}}/year={{YEAR}}/month={{MONTH}}/day={{DAY}}/{{TIMESTAMP}}-suites-{{ID}}.jsonl" \
${{ inputs.junit-files }}

187 changes: 187 additions & 0 deletions tools/ci-normalize/cmd/ci-normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright 2026 Gravitational, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"

kingpin "github.com/alecthomas/kingpin/v2"
"golang.org/x/sync/errgroup"

"github.com/gravitational/shared-workflows/tools/ci-normalize/dispatch"
"github.com/gravitational/shared-workflows/tools/ci-normalize/input"
"github.com/gravitational/shared-workflows/tools/ci-normalize/meta"
"github.com/gravitational/shared-workflows/tools/ci-normalize/record"
"github.com/gravitational/shared-workflows/tools/ci-normalize/writer"
"github.com/gravitational/trace"
)

func makeWriters(
ctx context.Context,
paths []string,
metadata *record.Meta,
) ([]dispatch.RecordWriter, error) {

var writers []dispatch.RecordWriter

for _, path := range paths {
w, err := writer.New(ctx, path, metadata, func(w io.Writer) writer.Encoder {
return json.NewEncoder(w)
})
if err != nil {
return nil, trace.Wrap(err)
}

writers = append(writers, w)
}

return writers, nil
}

func setupDispatcher(
ctx context.Context,
metadata *record.Meta,
suiteOuts, testOuts, metaOuts []string,
) (*dispatch.Dispatcher, error) {

suiteWriters, err := makeWriters(ctx, suiteOuts, metadata)
if err != nil {
return nil, trace.Wrap(err)
}

testWriters, err := makeWriters(ctx, testOuts, metadata)
if err != nil {
return nil, trace.Wrap(err)
}

metaWriters, err := makeWriters(ctx, metaOuts, metadata)
if err != nil {
return nil, trace.Wrap(err)
}

d, err := dispatch.New(
ctx,
suiteWriters,
testWriters,
metaWriters,
)
if err != nil {
return nil, trace.Wrap(err)
}

return d, nil
}
func createProducers(cmd string, junitCmd *kingpin.CmdClause, metadata *record.Meta, junitFiles *[]string) ([]input.Producer, error) {
producers := []input.Producer{}
switch cmd {
case junitCmd.FullCommand():
for _, f := range *junitFiles {
p, err := input.NewJUnitProducer(f, metadata)
if err != nil {
return nil, trace.Wrap(err)
}
producers = append(producers, p)
}
default:
return nil, trace.NotImplemented("unimplemented command %q", cmd)
}

return producers, nil
}

func run() error {
ctx := context.Background()
app := kingpin.New("ci-normalize", "Normalize test artifacts")
app.HelpFlag.Short('h')
timeout := app.Flag(
"timeout",
"Maximum execution time (e.g. 30s, 2m); 0 means no timeout",
).Default("0").Duration()

// JUnit command
junitCmd := app.Command("junit", "Normalize JUnit test results")
suiteOuts := junitCmd.Flag("suites", "Testsuite output(s) ('-' for stdout, /dev/null to ignore)").Default("-").Strings()
testOuts := junitCmd.Flag("tests", "Testcase output(s) ('-' for stdout, /dev/null to ignore)").Default("-").Strings()
junitFiles := junitCmd.Arg("files", "JUnit XML result files").Required().ExistingFiles()
metaFile := junitCmd.Flag("from-meta", "Optionally provide existing metadata").ExistingFile()
metaOuts := junitCmd.Flag(
"meta",
"Metadata output ('-' for stdout, /dev/null to ignore)",
).Short('o').Default("-").Strings()

cmd, err := app.Parse(os.Args[1:])
if err != nil {
return trace.Wrap(err, "failed to parse command line arguments")

}

// setup timeout context
var cancel context.CancelFunc
if *timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, *timeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()

metadata, err := meta.New(metaFile)
if err != nil {
return trace.Wrap(err, "reading metadata")
}

dispatcher, err := setupDispatcher(ctx, metadata, *suiteOuts, *testOuts, *metaOuts)
if err != nil {
return trace.Wrap(err)
}
defer func() {
// Clean up path, ignore the err
_ = dispatcher.Close()
}()

producers, err := createProducers(cmd, junitCmd, metadata, junitFiles)
if err != nil {
return trace.Wrap(err)
}

eg, ctx := errgroup.WithContext(ctx)

eg.Go(func() error {
// Always emit metadata record
return dispatcher.WriteMeta(metadata)
})

for _, p := range producers {
eg.Go(func() error {
return p.Produce(ctx, dispatcher)
})
}

if err := eg.Wait(); err != nil {
return trace.Wrap(err)
}

return dispatcher.Close() // flush and check for errors
}

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
Loading
Loading