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
46 changes: 46 additions & 0 deletions .github/workflows/ci_orchestrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand All @@ -95,9 +104,46 @@ 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:
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
38 changes: 37 additions & 1 deletion .github/workflows/reusable_ici.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -65,6 +73,7 @@ jobs:
else
echo "pip_install=" >> "$GITHUB_OUTPUT"
fi

- name: Cache ccache
uses: actions/cache@v6
with:
Expand All @@ -73,11 +82,38 @@ 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 }}
ROS_REPO: ${{ inputs.ros_repo }}
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: 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" <<EOF
{"schemaVersion":1,"label":"${{ inputs.ros_distro }}","message":"${status}","color":"${color}"}
EOF

- name: Upload badge artifact
if: ${{ always() && inputs.badge_gist_id != '' && github.ref == 'refs/heads/main' }}
uses: actions/upload-artifact@v4
with:
name: badge-${{ inputs.ros_distro }}
path: badge/*.json
retention-days: 1
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -86,10 +88,44 @@ 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: <your-gist-id>
secrets: inherit
```
4. Point README badges at the gist file, namespaced `<repo-name>-<distro>.json`:
```markdown
[![Jazzy](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/<user>/<gist-id>/raw/<repo-name>-jazzy.json)](https://github.com/<org>/<repo-name>/actions/workflows/ci.yml)
```

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 (`<repo-name>-<distro>.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)

- **`reusable_ici.yml`** - the upstream `ros-industrial` industrial_ci template, builds one distro/channel combination. Auto-detects `repos.list`, `Aptfile`, and `requirements.txt`.
- **`pre-commit.yml`** - runs `pre-commit` across all files.

## 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`.
Loading