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
33 changes: 33 additions & 0 deletions .github/workflows/csharp-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,39 @@ jobs:
status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)"
echo "matrix-status=$status" >> "$GITHUB_OUTPUT"

matrix-comment:
name: matrix-comment (${{ inputs.artifact-prefix }})
needs: build-and-test
if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout CI helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers
- name: Render build-matrix table
env:
GH_TOKEN: ${{ github.token }}
SELF_JOB_NAME: matrix-comment (${{ inputs.artifact-prefix }})
MATRIX_TITLE: 'C# build matrix'
run: |
set -euo pipefail
gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \
| .github-actions-helpers/scripts/render_matrix_comment.py "$SELF_JOB_NAME" "$MATRIX_TITLE" \
> build-matrix-results.md
- name: Add build-matrix PR comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
recreate: true
header: ${{ inputs.artifact-prefix }}-build-matrix
path: build-matrix-results.md

pack:
name: pack
if: ${{ inputs.pack }}
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/scala-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,36 @@ jobs:
set -euo pipefail
status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)"
echo "matrix-status=$status" >> "$GITHUB_OUTPUT"

matrix-comment:
name: matrix-comment (${{ inputs.artifact-prefix }})
needs: build-and-test
if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout CI helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers
- name: Render build-matrix table
env:
GH_TOKEN: ${{ github.token }}
SELF_JOB_NAME: matrix-comment (${{ inputs.artifact-prefix }})
MATRIX_TITLE: 'Scala build matrix'
run: |
set -euo pipefail
gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \
| .github-actions-helpers/scripts/render_matrix_comment.py "$SELF_JOB_NAME" "$MATRIX_TITLE" \
> build-matrix-results.md
- name: Add build-matrix PR comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
recreate: true
header: ${{ inputs.artifact-prefix }}-build-matrix
path: build-matrix-results.md
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Publish the `build-matrix` shard results of `csharp-ci.yaml` and `scala-ci.yaml` as a sticky PR comment (`shard | result | duration` table, each shard linking to its job). A new `matrix-comment` job (`needs: build-and-test`, `if: !cancelled()`) queries the run's per-shard job outcomes via `gh api .../actions/runs/<run-id>/jobs` — so the table renders even when a shard fails — and upserts it via `marocchino/sticky-pull-request-comment` under a `<artifact-prefix>-build-matrix` header, distinct from the coverage sticky comment. It no-ops when the caller passes no `build-matrix`, on non-`pull_request` events, and for dependabot. Coverage comments are unaffected. (#21)
### Fixed

- Harden `scripts/write-badges.sh` (the `update-badges.yaml` helper) against silent failures. `git fetch origin "$badge_branch" || true` previously swallowed every failure — auth, network, or a genuinely absent branch alike — then fell through to the orphan-rebuild path; a real fetch failure is now aborted loud, and only the benign "branch does not exist yet" case (detected up front with `git ls-remote --exit-code`) rebuilds the orphan branch. Malformed input now fails the badge job instead of writing a bogus badge file: a non-array `coverage-data`/`matrix-data`, a coverage entry missing `slug`/`label`, or a matrix entry missing `lang`/`os`/`arch`/`passed` (which would have written `ci-null-null-null.json`) is rejected with a `::error::` annotation. Added `test/write-badges_test.sh` pinning the `coverage-<slug>.json` / `ci-<lang>-<os>-<arch>.json` filename contract, the null-percent skip, and the new fail-loud paths, wired into the `build-and-test` self-test job. Healthy consumers on the built-in `matrix-status` → `update-badges.yaml` flow see no change. (#24)
Expand All @@ -18,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fix the badge helper scripts silently coercing unrecognized status input into a plausible-but-wrong badge instead of erroring. `matrix_badge_json.py` treated anything outside its passing allowlist as failing, so a `cancelled` or typo'd status rendered a misleading red badge, and `aggregate_matrix_status.py` reported a malformed marker (missing status) as a failed shard. Both now reject input outside the known `success`/`failure` token sets and fail the badge job loud, with regression tests for the rejected cases. Workflows using the built-in `matrix-status` → `update-badges.yaml` flow only ever produce known tokens, so healthy consumers see no change. First outside contribution to this repo — thank you @maxi-maxima! (#30)

- Fix `csharp-ci.yaml`'s `Merge per-project cobertura reports` step finding no coverage files for consumers that bump `Microsoft.Testing.Extensions.CodeCoverage` to 18.9.0 on .NET SDK 10.0.3xx. That combination makes solution-level `dotnet test` resolve the relative `--coverage-output` against the run-level `<working-directory>/TestResults/` instead of each project's `bin/Release/<tfm>/TestResults/`, so the default `tests-glob` matched nothing and failed the coverage shard. The default now carries a second glob (`TestResults/**/*.cobertura.xml`) — the merge step word-splits the input under `globstar nullglob`, so old-layout consumers see no change and callers passing an explicit `tests-glob` are unaffected. The README caller prerequisites document both layouts and lift the stale "do not bump `CodeCoverage` past 18.0.x" ceiling: with `xunit.v3.mtp-v2` shipped, 18.x lines through 18.9.x are supported, while the 19.x empty-output warning stays. First hit by `peacefulstudio/daml-codegen-csharp-internal`. (#32)

## [2.3.3] - 2026-07-09
Expand Down
131 changes: 131 additions & 0 deletions scripts/render_matrix_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0
"""Render a workflow run's build-matrix shard results as a markdown table.

Reads the concatenated JSON pages emitted by
``gh api --paginate repos/<repo>/actions/runs/<run-id>/jobs`` on stdin,
anchors on the calling aggregator job's own display name to resolve the
reusable-workflow name prefix, and prints a ``shard | result | duration``
table covering the sibling ``build-and-test (<shard>)`` jobs of the same
workflow invocation.
"""
import json
import sys
from datetime import datetime

SHARD_JOB_MARKER = 'build-and-test ('
TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
UNKNOWN_DURATION = '—'
RESULT_LABELS = {
'success': '✅ success',
'failure': '❌ failure',
'cancelled': '🚫 cancelled',
'skipped': '⏭️ skipped',
'timed_out': '⏱️ timed out',
'neutral': '⚪ neutral',
'action_required': '⚠️ action required',
'stale': '⚠️ stale',
}


def parse_job_pages(text):
decoder = json.JSONDecoder()
jobs = []
index = 0
while index < len(text):
if text[index].isspace():
index += 1
continue
page, index = decoder.raw_decode(text, index)
jobs.extend(page['jobs'])
return jobs


def workflow_prefix(jobs, self_job_name):
matches = [
job['name']
for job in jobs
if job['name'] == self_job_name or job['name'].endswith(f' / {self_job_name}')
]
if not matches:
raise ValueError(f'no job named {self_job_name!r} found in this run')
if len(matches) > 1:
raise ValueError(
f'multiple jobs named {self_job_name!r} found in this run; '
'set a distinct artifact-prefix per workflow invocation'
)
return matches[0][: -len(self_job_name)]


def shard_jobs(jobs, prefix):
marker = f'{prefix}{SHARD_JOB_MARKER}'
shards = []
for job in jobs:
name = job['name']
if not (name.startswith(marker) and name.endswith(')')):
continue
shards.append((name[len(marker):-1], job))
return sorted(shards, key=lambda shard: shard[0])


def result_label(job):
conclusion = job['conclusion']
if conclusion not in RESULT_LABELS:
raise ValueError(f'unknown conclusion for job {job["name"]!r}: {conclusion!r}')
return RESULT_LABELS[conclusion]


def format_duration(job):
started = job.get('started_at')
completed = job.get('completed_at')
if not started or not completed:
return UNKNOWN_DURATION
elapsed = (
datetime.strptime(completed, TIMESTAMP_FORMAT)
- datetime.strptime(started, TIMESTAMP_FORMAT)
)
seconds = int(elapsed.total_seconds())
if seconds < 0:
return UNKNOWN_DURATION
minutes, remainder = divmod(seconds, 60)
if minutes == 0:
return f'{remainder}s'
return f'{minutes}m {remainder:02d}s'


def render(jobs, self_job_name, title):
prefix = workflow_prefix(jobs, self_job_name)
shards = shard_jobs(jobs, prefix)
if not shards:
raise ValueError(
f'no {SHARD_JOB_MARKER}<shard>) jobs found under prefix {prefix!r}'
)
lines = [
f'## {title}',
'',
'| shard | result | duration |',
'| --- | --- | --- |',
]
for shard, job in shards:
lines.append(
f'| [{shard}]({job["html_url"]}) '
f'| {result_label(job)} '
f'| {format_duration(job)} |'
)
return '\n'.join(lines) + '\n'


def main(argv):
if len(argv) != 3:
print(
'error: usage: render_matrix_comment.py <self-job-name> <title>',
file=sys.stderr,
)
return 2
print(render(parse_job_pages(sys.stdin.read()), argv[1], argv[2]), end='')
return 0


if __name__ == '__main__':
sys.exit(main(sys.argv))
131 changes: 131 additions & 0 deletions test/render_matrix_comment_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0
import json
import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))

import render_matrix_comment

SELF_JOB_NAME = 'matrix-comment (csharp)'
TITLE = 'C# build matrix'


def job(
name,
conclusion='success',
started='2026-07-10T12:00:00Z',
completed='2026-07-10T12:03:42Z',
url='https://github.test/run/1/job/1',
):
return {
'name': name,
'conclusion': conclusion,
'started_at': started,
'completed_at': completed,
'html_url': url,
}


def page(*jobs):
return json.dumps({'total_count': len(jobs), 'jobs': list(jobs)})


class RenderMatrixCommentTest(unittest.TestCase):
def test_renders_shards_sorted_by_name_with_links_and_durations(self):
jobs = [
job('ci / build-and-test (windows-x64)', conclusion='failure', url='https://github.test/j/2'),
job('ci / build-and-test (linux-x64)', completed='2026-07-10T12:00:59Z', url='https://github.test/j/1'),
job(f'ci / {SELF_JOB_NAME}', conclusion=None),
]
self.assertEqual(
render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE),
'## C# build matrix\n'
'\n'
'| shard | result | duration |\n'
'| --- | --- | --- |\n'
'| [linux-x64](https://github.test/j/1) | ✅ success | 59s |\n'
'| [windows-x64](https://github.test/j/2) | ❌ failure | 3m 42s |\n',
)

def test_excludes_sibling_workflow_shards_sharing_shard_names(self):
jobs = [
job('csharp / build-and-test (linux-x64)', url='https://github.test/j/csharp'),
job('scala / build-and-test (linux-x64)', conclusion='failure', url='https://github.test/j/scala'),
job(f'csharp / {SELF_JOB_NAME}', conclusion=None),
job('scala / matrix-comment (scala)', conclusion=None),
]
rendered = render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE)
self.assertIn('https://github.test/j/csharp', rendered)
self.assertNotIn('https://github.test/j/scala', rendered)
self.assertNotIn('failure', rendered)

def test_parses_concatenated_paginated_pages(self):
text = page(job('ci / build-and-test (a)')) + '\n' + page(job('ci / build-and-test (b)'))
self.assertEqual(
[entry['name'] for entry in render_matrix_comment.parse_job_pages(text)],
['ci / build-and-test (a)', 'ci / build-and-test (b)'],
)

def test_renders_cancelled_and_skipped_labels(self):
jobs = [
job('ci / build-and-test (a)', conclusion='cancelled'),
job('ci / build-and-test (b)', conclusion='skipped', started=None, completed=None),
job(f'ci / {SELF_JOB_NAME}', conclusion=None),
]
rendered = render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE)
self.assertIn('| 🚫 cancelled |', rendered)
self.assertIn('| ⏭️ skipped | — |', rendered)

def test_missing_self_job_fails_loudly(self):
with self.assertRaises(ValueError):
render_matrix_comment.render(
[job('ci / build-and-test (a)')], SELF_JOB_NAME, TITLE
)

def test_duplicate_self_job_fails_loudly(self):
jobs = [
job(f'one / {SELF_JOB_NAME}', conclusion=None),
job(f'two / {SELF_JOB_NAME}', conclusion=None),
]
with self.assertRaises(ValueError):
render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE)

def test_no_shard_jobs_fails_loudly(self):
with self.assertRaises(ValueError):
render_matrix_comment.render(
[job(f'ci / {SELF_JOB_NAME}', conclusion=None)], SELF_JOB_NAME, TITLE
)

def test_unknown_conclusion_fails_loudly(self):
jobs = [
job('ci / build-and-test (a)', conclusion='mystery'),
job(f'ci / {SELF_JOB_NAME}', conclusion=None),
]
with self.assertRaises(ValueError):
render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE)

def test_in_progress_shard_conclusion_fails_loudly(self):
jobs = [
job('ci / build-and-test (a)', conclusion=None),
job(f'ci / {SELF_JOB_NAME}', conclusion=None),
]
with self.assertRaises(ValueError):
render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE)

def test_negative_or_missing_duration_renders_unknown(self):
self.assertEqual(
render_matrix_comment.format_duration(
job('x', started='2026-07-10T12:05:00Z', completed='2026-07-10T12:00:00Z')
),
'—',
)
self.assertEqual(
render_matrix_comment.format_duration(job('x', started=None)), '—'
)


if __name__ == '__main__':
unittest.main()