diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index 42e2bd9..f83d90e 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -5,6 +5,21 @@ name: CSharp CI on: workflow_call: + outputs: + coverage: + description: >- + Integer line-coverage percentage (e.g. '85') produced by the + coverage matrix shard, or an empty string when no shard set + `coverage: true`. Feed it as the `percent` field of an entry in the + `update-badges` reusable workflow's `coverage-data` input. + value: ${{ jobs.coverage-output.outputs.coverage }} + matrix-status: + description: >- + JSON array of per-shard build results + `[{ "name", "os", "arch", "passed" }]`, one entry per matrix shard, + suitable to feed (after tagging each entry with a `lang`) into the + `update-badges` reusable workflow's `matrix-data` input. + value: ${{ jobs.matrix-output.outputs.matrix-status }} secrets: BOT_GITHUB_TOKEN: description: >- @@ -154,6 +169,16 @@ on: required: false type: string default: 'nupkg' + artifact-prefix: + description: >- + Prefix applied to per-run artifact names (coverage-percent, + ci-result-) to namespace them by language. Set a distinct + value per language when multiple CI workflows (e.g. csharp-ci and + scala-ci) run as sibling jobs in the same caller run, so their + run-level artifacts don't collide. + required: false + type: string + default: 'csharp' concurrency: group: ${{ inputs.concurrency-group != '' && inputs.concurrency-group || format('{0}-{1}', github.workflow, github.ref) }} @@ -294,6 +319,23 @@ jobs: -f cobertura \ "${cobertura_files[@]}" + - name: Extract coverage percentage + if: ${{ matrix.coverage }} + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + mkdir -p coverage + .github-actions-helpers/scripts/extract_coverage.py "$GITHUB_WORKSPACE/coverage/merged.cobertura.xml" > coverage/coverage-percent.txt + + - name: Upload coverage value + if: ${{ matrix.coverage }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: coverage/coverage-percent.txt + if-no-files-found: error + retention-days: 1 + - name: Code Coverage Summary Report if: ${{ matrix.coverage }} uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0 @@ -340,6 +382,83 @@ jobs: fi cat code-coverage-results.md >> "$GITHUB_STEP_SUMMARY" + - name: Record shard result + if: ${{ !cancelled() }} + env: + SHARD_NAME: ${{ matrix.name }} + SHARD_STATUS: ${{ job.status }} + run: | + set -euo pipefail + mkdir -p ci-result + printf '%s %s\n' "$SHARD_NAME" "$SHARD_STATUS" > "ci-result/${SHARD_NAME}.txt" + + - name: Upload shard result + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-prefix }}-ci-result-${{ matrix.name }} + path: ci-result/${{ matrix.name }}.txt + if-no-files-found: error + retention-days: 1 + + coverage-output: + name: coverage-output + needs: build-and-test + if: ${{ success() }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + coverage: ${{ steps.read.outputs.coverage }} + steps: + - name: Download coverage value + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: coverage + + - name: Read coverage value + id: read + run: | + set -euo pipefail + coverage="" + if [ -f coverage/coverage-percent.txt ]; then + coverage=$(tr -d '[:space:]' < coverage/coverage-percent.txt) + fi + echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + + matrix-output: + name: matrix-output + needs: build-and-test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix-status: ${{ steps.aggregate.outputs.matrix-status }} + 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: Download shard results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ inputs.artifact-prefix }}-ci-result-* + path: ci-results + merge-multiple: true + + - name: Aggregate shard results + id: aggregate + run: | + set -euo pipefail + status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)" + echo "matrix-status=$status" >> "$GITHUB_OUTPUT" + pack: name: pack if: ${{ inputs.pack }} diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 1ccd628..5780b32 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -5,6 +5,18 @@ name: Scala CI on: workflow_call: + outputs: + coverage: + description: >- + Integer line-coverage percentage produced by the coverage shard, + or an empty string when no shard set `coverage: true`. Suitable to + feed into the `update-badges` reusable workflow's `coverage-data`. + value: ${{ jobs.coverage-output.outputs.coverage }} + matrix-status: + description: >- + JSON array of per-shard build results + `[{ "name", "os", "arch", "passed" }]`, one entry per matrix shard. + value: ${{ jobs.matrix-output.outputs.matrix-status }} inputs: working-directory: description: >- @@ -94,6 +106,16 @@ on: required: false type: string default: 'scala-coverage' + artifact-prefix: + description: >- + Prefix applied to per-run artifact names (coverage-percent, + ci-result-) to namespace them by language. Set a distinct + value per language when multiple CI workflows (e.g. csharp-ci and + scala-ci) run as sibling jobs in the same caller run, so their + run-level artifacts don't collide. + required: false + type: string + default: 'scala' coverage-title: description: >- Heading prepended to the rendered coverage report so a reader @@ -210,6 +232,33 @@ jobs: mkdir -p coverage/merged cp "$COBERTURA_PATH" coverage/merged/Cobertura.xml + - name: Checkout CI helpers + if: ${{ matrix.coverage }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: peacefulstudio/github-actions + ref: ${{ job.workflow_sha }} + path: .github-actions-helpers + + - name: Extract coverage percentage + if: ${{ matrix.coverage }} + working-directory: ${{ github.workspace }} + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + mkdir -p coverage + .github-actions-helpers/scripts/extract_coverage.py "$WORKING_DIRECTORY/coverage/merged/Cobertura.xml" > coverage/coverage-percent.txt + + - name: Upload coverage value + if: ${{ matrix.coverage }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: coverage/coverage-percent.txt + if-no-files-found: error + retention-days: 1 + - name: Code Coverage Summary Report if: ${{ matrix.coverage }} uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0 @@ -220,14 +269,6 @@ jobs: hide_complexity: true output: 'both' - - name: Checkout CI helpers - if: ${{ matrix.coverage }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: peacefulstudio/github-actions - ref: ${{ job.workflow_sha }} - path: .github-actions-helpers - - name: Sort coverage table alphabetically if: ${{ matrix.coverage }} uses: ./.github-actions-helpers/.github/actions/sort-coverage-table @@ -272,3 +313,81 @@ jobs: name: ${{ inputs.coverage-artifact-name }} path: ${{ inputs.working-directory }}/${{ inputs.cobertura-path }} retention-days: 14 + + - name: Record shard result + if: ${{ !cancelled() }} + working-directory: ${{ github.workspace }} + env: + SHARD_NAME: ${{ matrix.name }} + SHARD_STATUS: ${{ job.status }} + run: | + set -euo pipefail + mkdir -p ci-result + printf '%s %s\n' "$SHARD_NAME" "$SHARD_STATUS" > "ci-result/${SHARD_NAME}.txt" + + - name: Upload shard result + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-prefix }}-ci-result-${{ matrix.name }} + path: ci-result/${{ matrix.name }}.txt + if-no-files-found: error + retention-days: 1 + + coverage-output: + name: coverage-output + needs: build-and-test + if: ${{ success() }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + coverage: ${{ steps.read.outputs.coverage }} + steps: + - name: Download coverage value + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: coverage + + - name: Read coverage value + id: read + run: | + set -euo pipefail + coverage="" + if [ -f coverage/coverage-percent.txt ]; then + coverage=$(tr -d '[:space:]' < coverage/coverage-percent.txt) + fi + echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + + matrix-output: + name: matrix-output + needs: build-and-test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix-status: ${{ steps.aggregate.outputs.matrix-status }} + 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: Download shard results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ inputs.artifact-prefix }}-ci-result-* + path: ci-results + merge-multiple: true + + - name: Aggregate shard results + id: aggregate + run: | + set -euo pipefail + status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)" + echo "matrix-status=$status" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/update-badges.yaml b/.github/workflows/update-badges.yaml new file mode 100644 index 0000000..876b836 --- /dev/null +++ b/.github/workflows/update-badges.yaml @@ -0,0 +1,64 @@ +# Copyright (c) 2026 Peaceful Studio OÜ +# SPDX-License-Identifier: Apache-2.0 + +name: Update Badges + +on: + workflow_call: + inputs: + coverage-data: + description: >- + JSON array of per-language coverage entries, each + `{ "slug": , "label": , "percent": }`. + Entries with an empty or null `percent` are skipped (no badge for a + language that produced no coverage). Writes `coverage-.json` + per entry to the badge branch. Default `'[]'` writes no coverage badge. + required: false + type: string + default: '[]' + matrix-data: + description: >- + JSON array of per-shard CI results, each + `{ "lang": , "os": , "arch": , "passed": }`. + Writes `ci---.json` per entry. Default `'[]'` writes + no matrix badges. + required: false + type: string + default: '[]' + badge-branch: + description: >- + Orphan branch of the caller repo where the shields.io endpoint + JSON is written. The caller must grant `permissions: contents: + write` so the built-in GITHUB_TOKEN can push to it. + required: false + type: string + default: 'badges' + +jobs: + update-badges: + name: update-badges + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - 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: Write badges + if: ${{ inputs.coverage-data != '[]' || inputs.matrix-data != '[]' }} + env: + COVERAGE_DATA: ${{ inputs.coverage-data }} + MATRIX_DATA: ${{ inputs.matrix-data }} + BADGE_BRANCH: ${{ inputs.badge-branch }} + run: | + set -euo pipefail + .github-actions-helpers/scripts/write-badges.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a3830..c5ccffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add live README status badges driven from CI: a new `update-badges.yaml` reusable workflow writes shields.io endpoint JSON to an orphan `badges` branch of the caller repo (built-in `GITHUB_TOKEN`, no gist or PAT), fed by new `coverage` and `matrix-status` outputs on `csharp-ci.yaml` and `scala-ci.yaml`, so a consumer README can show live coverage and per-platform (OS × arch) CI badges alongside the standard release/version badges. (#23) + ## [2.1.0] - 2026-06-12 ### Fixed diff --git a/README.md b/README.md index 9296bbd..2b4e6f2 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,54 @@ jobs: secrets: inherit ``` +### `update-badges.yaml` — live coverage + CI matrix badges via an orphan branch + +Writes [shields.io endpoint](https://shields.io/badges/endpoint-badge) JSON +to an orphan `badges` branch of the **caller** repo using the built-in +`GITHUB_TOKEN` — no gist and no PAT. A single post-CI writer renders every +badge file in one commit, so a README can show live coverage and per-platform +CI badges from that branch's raw URLs. Opt-in and public-repo-oriented. + +`csharp-ci.yaml` / `scala-ci.yaml` expose a `coverage` output (integer +percentage from the coverage shard, empty when no shard sets `coverage: true`) +and a `matrix-status` output (per-shard `{name, os, arch, passed}` results) to +feed it. Wire them together in the consumer, gating on `main` and granting +`contents: write`: + +```yaml +jobs: + csharp-ci: + uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2 + secrets: inherit + badges: + needs: csharp-ci + if: ${{ github.ref == 'refs/heads/main' }} + permissions: + contents: write + uses: peacefulstudio/github-actions/.github/workflows/update-badges.yaml@v2 + with: + coverage-data: >- + [{"slug":"csharp","label":"coverage","percent":"${{ needs.csharp-ci.outputs.coverage }}"}] + matrix-data: ${{ needs.csharp-ci.outputs.matrix-status }} +``` + +Inputs (all optional): + +- `coverage-data` — JSON array of `{slug, label, percent}`; writes one + `coverage-.json` per entry (entries with an empty/null `percent` are + skipped). Default `'[]'` writes no coverage badge. +- `matrix-data` — JSON array of `{lang, os, arch, passed}`; writes one + `ci---.json` per entry. Tag each `matrix-status` entry with a + `lang` first (so one badge branch can hold several languages). Default `'[]'`. +- `badge-branch` — orphan branch to write to (default `badges`). + +Then reference a badge in the README (substitute owner/repo and the file the +writer produced): + +```markdown +![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com///badges/coverage-csharp.json) +``` + ## Selecting runners By default the runner is chosen from the built repo's **visibility**: public diff --git a/scripts/aggregate_matrix_status.py b/scripts/aggregate_matrix_status.py new file mode 100755 index 0000000..f472162 --- /dev/null +++ b/scripts/aggregate_matrix_status.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Peaceful Studio OÜ +# SPDX-License-Identifier: Apache-2.0 +"""Aggregate per-shard CI result markers into a matrix-status JSON array.""" +import json +import os +import sys + +PASSING_STATUS = 'success' + + +def split_os_arch(name): + os_name, separator, arch = name.partition('-') + return os_name, arch if separator else '' + + +def read_marker(path): + with open(path) as handle: + text = handle.read().strip() + name, _, status = text.partition(' ') + return name, status + + +def aggregate(directory): + shards = [] + for entry in sorted(os.listdir(directory)): + path = os.path.join(directory, entry) + if not os.path.isfile(path): + continue + name, status = read_marker(path) + os_name, arch = split_os_arch(name) + shards.append( + { + 'name': name, + 'os': os_name, + 'arch': arch, + 'passed': status == PASSING_STATUS, + } + ) + return json.dumps(shards) + + +def main(argv): + if len(argv) != 2: + print('error: usage: aggregate_matrix_status.py ', file=sys.stderr) + return 2 + print(aggregate(argv[1])) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/scripts/coverage_badge_json.py b/scripts/coverage_badge_json.py new file mode 100755 index 0000000..5f2a692 --- /dev/null +++ b/scripts/coverage_badge_json.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Peaceful Studio OÜ +# SPDX-License-Identifier: Apache-2.0 +"""Build a shields.io endpoint JSON document for a coverage badge.""" +import json +import sys + +GITHUB_PASSING_GREEN = '#28a745' + +COLOR_THRESHOLDS = ( + (90, GITHUB_PASSING_GREEN), + (80, 'green'), + (70, 'yellowgreen'), + (60, 'yellow'), + (50, 'orange'), +) + + +def color_for(coverage): + for threshold, color in COLOR_THRESHOLDS: + if coverage >= threshold: + return color + return 'red' + + +def badge_json(coverage, label): + return json.dumps( + { + 'schemaVersion': 1, + 'label': label, + 'message': f'{coverage}%', + 'color': color_for(coverage), + } + ) + + +def main(argv): + if len(argv) != 3: + print('error: usage: coverage_badge_json.py