diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index 051a4ed..b6d5aad 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -483,39 +483,6 @@ 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 }} diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 5b94448..b6b457d 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -412,36 +412,3 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d07148..c29ed29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.1] - 2026-07-16 + +### Removed + +- Revert 2.4.0's sticky build-matrix PR comment from `csharp-ci.yaml` and `scala-ci.yaml`. The `matrix-comment` job requests `actions: read`, and a called workflow cannot request a permission its caller's `permissions:` block does not grant — GitHub rejects the caller run at startup (`startup_failure`, before any job runs, on every event type; the job-level `if` guards cannot help because the check is static). Every caller with a restricted top-level `permissions:` block was unable to run CI at all from the moment the floating `v2` tag moved to 2.4.0. Callers that had granted `actions: read` lose only the comment; coverage comments and all other outputs are unchanged. The feature will return reading the existing per-shard `ci-results` artifacts instead of the jobs API, which needs no extra caller permissions. (#37) + ## [2.4.0] - 2026-07-14 ### Added @@ -140,7 +146,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `csharp-ci.yaml` — .NET build / test / coverage / pack. - `terraform-ci.yaml` — Terraform fmt / validate / test. -[Unreleased]: https://github.com/peacefulstudio/github-actions/compare/v2.4.0...HEAD +[Unreleased]: https://github.com/peacefulstudio/github-actions/compare/v2.4.1...HEAD +[2.4.1]: https://github.com/peacefulstudio/github-actions/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/peacefulstudio/github-actions/compare/v2.3.4...v2.4.0 [2.3.4]: https://github.com/peacefulstudio/github-actions/compare/v2.3.3...v2.3.4 [2.3.3]: https://github.com/peacefulstudio/github-actions/compare/v2.3.2...v2.3.3 diff --git a/scripts/render_matrix_comment.py b/scripts/render_matrix_comment.py deleted file mode 100755 index afcf944..0000000 --- a/scripts/render_matrix_comment.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/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 deleted file mode 100644 index c371c35..0000000 --- a/test/render_matrix_comment_test.py +++ /dev/null @@ -1,131 +0,0 @@ -# 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()