From 8b1a685789ab55f0a21e285b40b89a283d42d80c Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Sat, 11 Jul 2026 23:32:46 +0200 Subject: [PATCH 1/2] feat(ci): publish build-matrix results as a sticky PR comment (#21) Add a `matrix-comment` job to `csharp-ci.yaml` and `scala-ci.yaml` (`needs: build-and-test`, `if: always()`) that queries the run's per-shard job outcomes via the GitHub API and upserts a `shard | result | duration` table via marocchino/sticky-pull-request-comment under a `-build-matrix` header, distinct from the coverage comment. No-ops without `build-matrix`, on non-PR events, and for dependabot. Rendering is done by a new, unit-tested `scripts/render_matrix_comment.py`. --- .github/workflows/csharp-ci.yaml | 33 ++++++++ .github/workflows/scala-ci.yaml | 33 ++++++++ CHANGELOG.md | 4 +- scripts/render_matrix_comment.py | 131 +++++++++++++++++++++++++++++ test/render_matrix_comment_test.py | 131 +++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100755 scripts/render_matrix_comment.py create mode 100644 test/render_matrix_comment_test.py diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index 2b17680..8d2a262 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -477,6 +477,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: ${{ always() && 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 }} diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 3bee5e4..40eb279 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -406,3 +406,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: ${{ always() && 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e87d2..e0a7dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.3.4] - 2026-07-10 +### 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: always()`) queries the run's per-shard job outcomes via `gh api .../actions/runs//jobs` — so the table renders even when a shard fails — and upserts it via `marocchino/sticky-pull-request-comment` under a `-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 diff --git a/scripts/render_matrix_comment.py b/scripts/render_matrix_comment.py new file mode 100755 index 0000000..afcf944 --- /dev/null +++ b/scripts/render_matrix_comment.py @@ -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//actions/runs//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 ()`` 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}) 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 ', + 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)) diff --git a/test/render_matrix_comment_test.py b/test/render_matrix_comment_test.py new file mode 100644 index 0000000..c371c35 --- /dev/null +++ b/test/render_matrix_comment_test.py @@ -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() From f591cedec56b7232abb210f6bd9a8fb559c60e0a Mon Sep 17 00:00:00 2001 From: monsieurleberre <monsieurleberre@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:41:05 +0200 Subject: [PATCH 2/2] fix(changelog): restore 2.3.4 release heading; matrix-comment if !cancelled() Restore the `## [2.3.4] - 2026-07-10` release heading and its Fixed block (#30, #32) that the previous commit accidentally folded under [Unreleased]; keep only the #21 Added bullet in [Unreleased]. Change the matrix-comment job's `if` from `always()` to `!cancelled()` in csharp-ci.yaml and scala-ci.yaml so it still runs on shard failure (fail-fast is false) but does not post a wall of cancelled shards on a manual run cancellation. --- .github/workflows/csharp-ci.yaml | 2 +- .github/workflows/scala-ci.yaml | 2 +- CHANGELOG.md | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index 8d2a262..b76a82c 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -480,7 +480,7 @@ jobs: matrix-comment: name: matrix-comment (${{ inputs.artifact-prefix }}) needs: build-and-test - if: ${{ always() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} runs-on: ubuntu-latest permissions: actions: read diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 40eb279..89ed0d5 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -410,7 +410,7 @@ jobs: matrix-comment: name: matrix-comment (${{ inputs.artifact-prefix }}) needs: build-and-test - if: ${{ always() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} runs-on: ubuntu-latest permissions: actions: read diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a7dc9..99b6c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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: always()`) 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) +- 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) + +## [2.3.4] - 2026-07-10 ### Fixed