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
23 changes: 11 additions & 12 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,28 +217,27 @@ jobs:
go-version: ${{ inputs.go-version }}
cache-dependency-path: _go-infra/go.sum

- name: Build benchcheck
run: go build -C _go-infra/cmd/benchcheck -o "$RUNNER_TEMP/benchcheck" .

- name: Fetch job URLs
env:
GH_TOKEN: ${{ github.token }}
run: |
# Reusable-workflow matrix jobs are named "<caller-job-name> / bench (<label>)";
# strip the "<caller-job-name> / " prefix and "bench (...)" wrapper to get the
# label, which matches the artifact-name suffix used by the report.
gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
--paginate --jq '
.jobs[] | select(.name | test(" / bench \\(")) |
(.name | sub("^.* / bench \\("; "") | sub("\\)$"; "")) as $label |
# Find the Compare step that actually ran (not skipped).
(first(.steps[] | select((.name | test("[Cc]ompare")) and .conclusion != "skipped") | .number) // null) as $step |
"\($label)\t\(.html_url)\(if $step then "#step:\($step):1" else "" end)"' \
> job_urls.tsv || true
# benchcheck job-urls calls the GitHub jobs API (via gh, which handles
# auth and pagination) and writes a TSV mapping each matrix job
# "<caller-job-name> / bench (<label>)" to its URL, deep-linked to the
# compare step, matching the artifact-name suffix the report keys on.
"$RUNNER_TEMP/benchcheck" job-urls \
-repo "${{ github.repository }}" \
-id "${{ github.run_id }}" \
> job_urls.tsv
cat job_urls.tsv

- name: Build report
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
go build -C _go-infra/cmd/benchcheck -o "$RUNNER_TEMP/benchcheck" .
"$RUNNER_TEMP/benchcheck" report \
-run-url="$RUN_URL" \
-job-urls=job_urls.tsv \
Expand Down
176 changes: 176 additions & 0 deletions cmd/benchcheck/jobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package main

import (
"bufio"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"strings"
)

// jobsResponse is one page of the GitHub "list jobs for a workflow run" API
// response (https://docs.github.com/rest/actions/workflow-jobs). Only the
// fields benchcheck needs are decoded.
type jobsResponse struct {
Jobs []jobInfo `json:"jobs"`
}

type jobInfo struct {
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Steps []jobStep `json:"steps"`
}

type jobStep struct {
Name string `json:"name"`
Number int `json:"number"`
Conclusion string `json:"conclusion"`
}

// benchJobMarker appears in a reusable-workflow matrix job's name, which looks
// like "<caller-job-name> / bench (<label>)". The text after the marker (minus
// the trailing ")") is the artifact label the report keys on.
const benchJobMarker = " / bench ("

func cmdJobURLs(args []string) {
fs := flag.NewFlagSet("job-urls", flag.ExitOnError)
repo := fs.String("repo", "", "GitHub repository (OWNER/REPO) whose run jobs to fetch via gh")
runID := fs.String("id", "", "workflow run ID whose jobs to fetch via gh")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `usage: benchcheck job-urls -repo OWNER/REPO -id RUN_ID
benchcheck job-urls [jobs.json]

Write a TSV mapping each benchmark artifact label to its job URL, deep-linked
to the compare step when available. The output is consumed by
"benchcheck report -job-urls".

With -repo and -id, the GitHub "list jobs for a workflow run" API is fetched
via gh (which handles authentication and pagination). Otherwise the API
response is read from the given file, or stdin if omitted.
`)
}
if err := fs.Parse(args); err != nil {
os.Exit(2)
}

if (*repo == "") != (*runID == "") {
fmt.Fprintln(os.Stderr, "benchcheck job-urls: -repo and -id must be used together")
os.Exit(2)
}
if (*repo != "") && fs.NArg() > 0 {
fmt.Fprintln(os.Stderr, "benchcheck job-urls: cannot use -repo/-id together with a file argument")
os.Exit(2)
}
if *repo == "" && fs.NArg() > 1 {
fs.Usage()
os.Exit(2)
}

var in io.Reader
switch {
case *repo != "":
data, err := fetchJobs(*repo, *runID)
if err != nil {
fmt.Fprintf(os.Stderr, "benchcheck job-urls: %v\n", err)
os.Exit(1)
}
in = bytes.NewReader(data)
case fs.NArg() == 1:
f, err := os.Open(fs.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "benchcheck job-urls: %v\n", err)
os.Exit(1)
}
defer f.Close()
in = f
default:
in = os.Stdin
}

lines, err := jobURLs(in)
if err != nil {
fmt.Fprintf(os.Stderr, "benchcheck job-urls: %v\n", err)
os.Exit(1)
}

out := bufio.NewWriter(os.Stdout)
for _, line := range lines {
fmt.Fprintln(out, line)
}
if err := out.Flush(); err != nil {
fmt.Fprintf(os.Stderr, "benchcheck job-urls: %v\n", err)
os.Exit(1)
}
}

// fetchJobs runs `gh api` to retrieve the "list jobs for a workflow run" API
// response for repo and runID, following pagination. gh supplies auth from the
// GH_TOKEN/GITHUB_TOKEN environment. repo and runID come from trusted workflow
// context and are passed as single arguments (no shell), so they cannot inject.
func fetchJobs(repo, runID string) ([]byte, error) {
endpoint := fmt.Sprintf("repos/%s/actions/runs/%s/jobs", repo, runID)
cmd := exec.Command("gh", "api", endpoint, "--paginate")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, fmt.Errorf("gh api %s: %w: %s", endpoint, err, msg)
}
return nil, fmt.Errorf("gh api %s: %w", endpoint, err)
}
return out, nil
}

// jobURLs parses one or more concatenated pages of the jobs API response (as
// produced by `gh api --paginate`) and returns "<label>\t<url>" lines for the
// benchmark matrix jobs, deep-linking each URL to its compare step when found.
func jobURLs(r io.Reader) ([]string, error) {
dec := json.NewDecoder(r)
var lines []string
for {
var page jobsResponse
if err := dec.Decode(&page); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
for _, j := range page.Jobs {
i := strings.Index(j.Name, benchJobMarker)
if i < 0 {
continue // not a benchmark matrix job
}
label := strings.TrimSuffix(j.Name[i+len(benchJobMarker):], ")")
url := j.HTMLURL
if n, ok := compareStepNumber(j.Steps); ok {
url = fmt.Sprintf("%s#step:%d:1", url, n)
}
lines = append(lines, label+"\t"+url)
}
}
return lines, nil
}

// compareStepNumber returns the number of the first non-skipped step whose name
// mentions "compare" (case-insensitive), so the report can deep-link to the
// step that produced the comparison.
func compareStepNumber(steps []jobStep) (int, bool) {
for _, s := range steps {
if s.Conclusion == "skipped" {
continue
}
if strings.Contains(strings.ToLower(s.Name), "compare") {
return s.Number, true
}
}
return 0, false
}
84 changes: 84 additions & 0 deletions cmd/benchcheck/jobs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package main

import (
"slices"
"strings"
"testing"
)

func TestJobURLs(t *testing.T) {
// A single page with: a benchmark job whose compare step ran, a benchmark
// job whose compare step was skipped (no deep link), and a non-benchmark
// job that must be ignored.
input := `{
"total_count": 3,
"jobs": [
{
"name": "benchmark / bench (windows-2022-go1.25)",
"html_url": "https://github.com/o/r/actions/runs/1/job/11",
"steps": [
{"name": "Set up job", "number": 1, "conclusion": "success"},
{"name": "📊 Compare and check", "number": 7, "conclusion": "success"}
]
},
{
"name": "benchmark / bench (windows-2025-go1.26)",
"html_url": "https://github.com/o/r/actions/runs/1/job/12",
"steps": [
{"name": "Compare and check", "number": 7, "conclusion": "skipped"}
]
},
{
"name": "conclude",
"html_url": "https://github.com/o/r/actions/runs/1/job/13",
"steps": [
{"name": "Compare and check", "number": 7, "conclusion": "success"}
]
}
]
}`

got, err := jobURLs(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
want := []string{
"windows-2022-go1.25\thttps://github.com/o/r/actions/runs/1/job/11#step:7:1",
"windows-2025-go1.26\thttps://github.com/o/r/actions/runs/1/job/12",
}
if !slices.Equal(got, want) {
t.Errorf("jobURLs = %#v, want %#v", got, want)
}
}

func TestJobURLs_MultiplePages(t *testing.T) {
// `gh api --paginate` concatenates one JSON object per page; jobURLs must
// read them all.
page1 := `{"jobs":[{"name":"b / bench (a)","html_url":"https://x/1","steps":[{"name":"Compare","number":3,"conclusion":"success"}]}]}`
page2 := `{"jobs":[{"name":"b / bench (c)","html_url":"https://x/2","steps":[]}]}`

got, err := jobURLs(strings.NewReader(page1 + "\n" + page2 + "\n"))
if err != nil {
t.Fatal(err)
}
want := []string{
"a\thttps://x/1#step:3:1",
"c\thttps://x/2",
}
if !slices.Equal(got, want) {
t.Errorf("jobURLs = %#v, want %#v", got, want)
}
}

func TestJobURLs_Empty(t *testing.T) {
got, err := jobURLs(strings.NewReader(`{"jobs":[]}`))
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("expected no lines, got %#v", got)
}
}
8 changes: 6 additions & 2 deletions cmd/benchcheck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//
// benchcheck check [flags] base.json head.json
// benchcheck report [flags] results-dir
// benchcheck job-urls -repo OWNER/REPO -id RUN_ID
package main

import (
Expand All @@ -17,8 +18,9 @@ import (
func usage() {
fmt.Fprintf(os.Stderr, "usage: benchcheck <command> [flags] [args]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " check Compare benchmarks, detect regressions and test failures\n")
fmt.Fprintf(os.Stderr, " report Build a markdown report from benchmark artifacts\n")
fmt.Fprintf(os.Stderr, " check Compare benchmarks, detect regressions and test failures\n")
fmt.Fprintf(os.Stderr, " report Build a markdown report from benchmark artifacts\n")
fmt.Fprintf(os.Stderr, " job-urls Map benchmark job labels to job URLs from the jobs API\n")
os.Exit(2)
}

Expand All @@ -31,6 +33,8 @@ func main() {
cmdCheck(os.Args[2:])
case "report":
cmdReport(os.Args[2:])
case "job-urls":
cmdJobURLs(os.Args[2:])
case "-h", "-help", "--help", "help":
usage()
default:
Expand Down
Loading