From 89f783d5137bad5ccc37a7b6746545eabdbdceaf Mon Sep 17 00:00:00 2001 From: mbloechli Date: Sat, 4 Jul 2026 22:50:28 +0200 Subject: [PATCH 1/2] feat(ci): add opt-in gist-backed status badges GitHub's native badge.svg reports per workflow file, not per matrix entry, and doesn't render for anonymous viewers of a private repo's README. Add an opt-in badge_gist_id input (plus GIST_TOKEN secret) that publishes each distro's pass/fail to a gist via schneegans/dynamic-badges-action, so consumers can render per-distro badges via shields.io's endpoint renderer regardless of repo visibility. Badges are only written on pushes to main, matching the old ?branch=main semantics; disabled by default (empty gist ID). --- .github/workflows/ci_orchestrator.yml | 10 +++++++++ .github/workflows/reusable_ici.yml | 18 +++++++++++++++ README.md | 32 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/.github/workflows/ci_orchestrator.yml b/.github/workflows/ci_orchestrator.yml index 38da458..f111996 100644 --- a/.github/workflows/ci_orchestrator.yml +++ b/.github/workflows/ci_orchestrator.yml @@ -24,10 +24,18 @@ on: default: 'ubuntu-latest' required: false type: string + badge_gist_id: + description: 'Optional gist ID to publish per-distro pass/fail status badges to. Empty (default) disables badge publishing. Only written on pushes to main.' + default: '' + required: false + type: string secrets: CI_PAT: description: 'Optional PAT used to clone private upstream dependencies listed in a repos.list workspace file.' required: false + GIST_TOKEN: + description: 'PAT with "gist" scope, required only when badge_gist_id is set.' + required: false concurrency: group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} @@ -80,6 +88,7 @@ jobs: ros_distro: ${{ matrix.ros_distro }} ros_repo: ${{ matrix.ros_repo }} runner: ${{ inputs.runner }} + badge_gist_id: ${{ inputs.badge_gist_id }} secrets: inherit secondary: @@ -95,6 +104,7 @@ jobs: ros_distro: ${{ matrix.ros_distro }} ros_repo: ${{ matrix.ros_repo }} runner: ${{ inputs.runner }} + badge_gist_id: ${{ inputs.badge_gist_id }} secrets: inherit pre-commit: diff --git a/.github/workflows/reusable_ici.yml b/.github/workflows/reusable_ici.yml index 46fddc9..28a90f2 100644 --- a/.github/workflows/reusable_ici.yml +++ b/.github/workflows/reusable_ici.yml @@ -31,10 +31,18 @@ on: default: 'ubuntu-latest' required: false type: string + badge_gist_id: + description: 'Optional gist ID to publish a pass/fail status badge to. Empty (default) disables badge publishing. Only written on pushes to main.' + default: '' + required: false + type: string secrets: CI_PAT: description: 'Optional PAT used to clone private upstream dependencies listed in the repos.list workspace file.' required: false + GIST_TOKEN: + description: 'PAT with "gist" scope, required only when badge_gist_id is set.' + required: false jobs: ici: @@ -81,3 +89,13 @@ jobs: BEFORE_INSTALL_UPSTREAM_DEPENDENCIES: ${{ steps.detect.outputs.before_install }} AFTER_INSTALL_TARGET_DEPENDENCIES: ${{ steps.detect.outputs.pip_install }} BEFORE_INIT: ${{ secrets.CI_PAT != '' && format('apt-get update -qq && apt-get install -y -qq git && git config --global url."https://{0}@github.com/".insteadOf "https://github.com/"', secrets.CI_PAT) || '' }} + - name: Publish status badge + if: ${{ always() && inputs.badge_gist_id != '' && github.ref == 'refs/heads/main' }} + uses: schneegans/dynamic-badges-action@v1.8.0 + with: + auth: ${{ secrets.GIST_TOKEN }} + gistID: ${{ inputs.badge_gist_id }} + filename: ${{ github.event.repository.name }}-${{ inputs.ros_distro }}.json + label: ${{ inputs.ros_distro }} + message: ${{ job.status == 'success' && 'passing' || 'failing' }} + color: ${{ job.status == 'success' && 'brightgreen' || 'red' }} diff --git a/README.md b/README.md index 4917346..2cbeaa8 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,12 @@ workflows (`reusable_ici.yml`, `pre-commit.yml`) that most repos never reference |---|---|---|---| | `ros_distro` | no | `all` | `all` runs the full gated matrix (jazzy, then kilted/lyrical/rolling once jazzy succeeds); a single distro name (e.g. `kilted`) builds only that one. | | `runner` | no | `ubuntu-latest` | Runner label(s) for the build jobs, e.g. `self-hosted`. | +| `badge_gist_id` | no | `''` | Opt-in: gist ID to publish per-distro pass/fail status badges to. Empty disables badge publishing entirely. | | Secret | Required | Description | |---|---|---| | `CI_PAT` | no | PAT used to clone private upstream dependencies from a `repos.list` file at the repo root. Pass via `secrets: inherit`; omit entirely for public-only repos. | +| `GIST_TOKEN` | no | PAT with `gist` scope. Required only if `badge_gist_id` is set. Pass via `secrets: inherit`. | What's **not** an input, because it's derived or auto-detected rather than repo-specific config: - **`upstream_workspace`** - auto-detected: `reusable_ici.yml` uses `repos.list` if it exists at the repo root, otherwise nothing. @@ -86,6 +88,35 @@ jobs: secrets: inherit ``` +## Status badges for private repos (opt-in, gist-backed) + +GitHub's native workflow `badge.svg` reports at the workflow-**file** level, so it can't show +one badge per distro, and it won't render for anonymous viewers of a **private** repo's README. +To get per-distro badges that work regardless of repo visibility, the orchestrator can publish +each distro's pass/fail to a gist, which [shields.io's `endpoint` +renderer](https://shields.io/badges/endpoint-badge) then turns into a badge: + +1. Create a public gist (any placeholder file) and a PAT with only the `gist` scope. +2. Add the PAT as a repo (or org) secret named `GIST_TOKEN`. +3. Pass `badge_gist_id` and `secrets: inherit` in your `ci.yml`: + ```yaml + jobs: + ci: + uses: Duatic/ci-workflows/.github/workflows/ci_orchestrator.yml@v1 + with: + ros_distro: ${{ inputs.ros_distro }} + badge_gist_id: + secrets: inherit + ``` +4. Point README badges at the gist file, namespaced `-.json`: + ```markdown + [![Jazzy](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com///raw/-jazzy.json)](https://github.com///actions/workflows/ci.yml) + ``` + +Badges are only written on pushes to `main` (matching the old `?branch=main` badge semantics) — +pull request runs never touch them. A single gist can hold badges for multiple repos since the +filename is namespaced per repo. + ## Leaf workflows (internal, not called directly by product repos) - **`reusable_ici.yml`** - the upstream `ros-industrial` industrial_ci template, builds one distro/channel combination. Auto-detects `repos.list`, `Aptfile`, and `requirements.txt`. @@ -93,3 +124,4 @@ jobs: ## Requirements on consumer repos - Repos using a private-dependency PAT must have a secret available (repo or org level) and pass `secrets: inherit`. +- Repos opting into gist-backed badges must have a `GIST_TOKEN` secret available (repo or org level) and pass `secrets: inherit`. From bfcee796ca07a8f3945dd92b2ec5ccf622088312 Mon Sep 17 00:00:00 2001 From: mbloechli Date: Tue, 14 Jul 2026 11:51:31 +0200 Subject: [PATCH 2/2] fix: coalesce git gists writes to mitigate hitting api limit --- .github/workflows/ci_orchestrator.yml | 36 +++++++++++++++++++++++++++ .github/workflows/reusable_ici.yml | 36 ++++++++++++++++++++------- README.md | 10 +++++--- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci_orchestrator.yml b/.github/workflows/ci_orchestrator.yml index f111996..acdaa0a 100644 --- a/.github/workflows/ci_orchestrator.yml +++ b/.github/workflows/ci_orchestrator.yml @@ -111,3 +111,39 @@ jobs: name: pre-commit if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.draft) }} uses: ./.github/workflows/pre-commit.yml + + # Coalesces every leg's badge artifact into a single gist patch. + badges: + name: Publish status badges + needs: [prepare, primary, secondary] + if: ${{ always() && inputs.badge_gist_id != '' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + steps: + - name: Download badge artifacts + uses: actions/download-artifact@v4 + with: + pattern: badge-* + path: badges + merge-multiple: true + + - name: Publish badges to gist + env: + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + GIST_ID: ${{ inputs.badge_gist_id }} + run: | + set -euo pipefail + shopt -s nullglob + files=(badges/*.json) + if [ "${#files[@]}" -eq 0 ]; then + echo "No badge artifacts found, nothing to publish." + exit 0 + fi + payload=$(jq -n '{files:{}}') + for f in "${files[@]}"; do + payload=$(jq --arg n "$(basename "$f")" --rawfile c "$f" '.files[$n]={content:$c}' <<<"$payload") + done + curl -sSf -X PATCH \ + -H "Authorization: Bearer ${GIST_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/gists/${GIST_ID}" \ + -d "$payload" > /dev/null diff --git a/.github/workflows/reusable_ici.yml b/.github/workflows/reusable_ici.yml index 28a90f2..8b13474 100644 --- a/.github/workflows/reusable_ici.yml +++ b/.github/workflows/reusable_ici.yml @@ -73,6 +73,7 @@ jobs: else echo "pip_install=" >> "$GITHUB_OUTPUT" fi + - name: Cache ccache uses: actions/cache@v6 with: @@ -81,7 +82,10 @@ jobs: restore-keys: | ccache-${{ inputs.ros_distro }}-${{ steps.detect.outputs.upstream_workspace }}-${{ inputs.ros_repo }}-${{ github.job }}-${{ github.sha }} ccache-${{ inputs.ros_distro }}-${{ steps.detect.outputs.upstream_workspace }}-${{ inputs.ros_repo }}-${{ github.job }} - - uses: 'ros-industrial/industrial_ci@master' + + - name: industrial_ci + id: ici + uses: 'ros-industrial/industrial_ci@master' env: UPSTREAM_WORKSPACE: ${{ steps.detect.outputs.upstream_workspace }} ROS_DISTRO: ${{ inputs.ros_distro }} @@ -89,13 +93,27 @@ jobs: BEFORE_INSTALL_UPSTREAM_DEPENDENCIES: ${{ steps.detect.outputs.before_install }} AFTER_INSTALL_TARGET_DEPENDENCIES: ${{ steps.detect.outputs.pip_install }} BEFORE_INIT: ${{ secrets.CI_PAT != '' && format('apt-get update -qq && apt-get install -y -qq git && git config --global url."https://{0}@github.com/".insteadOf "https://github.com/"', secrets.CI_PAT) || '' }} - - name: Publish status badge + + - name: Write badge artifact + if: ${{ always() && inputs.badge_gist_id != '' && github.ref == 'refs/heads/main' }} + shell: bash + run: | + set -euo pipefail + mkdir -p badge + status="failing" + color="red" + if [ "${{ steps.ici.outcome }}" = "success" ]; then + status="passing" + color="brightgreen" + fi + cat > "badge/${{ github.event.repository.name }}-${{ inputs.ros_distro }}.json" <//raw/-jazzy.json)](https://github.com///actions/workflows/ci.yml) ``` -Badges are only written on pushes to `main` (matching the old `?branch=main` badge semantics) — -pull request runs never touch them. A single gist can hold badges for multiple repos since the -filename is namespaced per repo. +Badges are only written on pushes to `main`. Each distro leg renders its own badge JSON and uploads it as an artifact; a `badges` job in the orchestrator downloads every leg's artifact and does a single multi-file gist PATCH per run. + +**Use a separate gist per repo**, even though the filename is namespaced (`-.json`) +and would technically allow sharing one gist across repos. With ~20 repos on the same nightly +cron, concurrent writers hitting the *same* gist trip GitHub's secondary (abuse) rate limit even +after coalescing each repo down to one write. The abuse limit is sensitive to concurrent writes against +one resource, not just total call volume. ## Leaf workflows (internal, not called directly by product repos)