diff --git a/.github/actions/setup-vagrant-libvirt/README.md b/.github/actions/setup-vagrant-libvirt/README.md new file mode 100644 index 0000000..6209013 --- /dev/null +++ b/.github/actions/setup-vagrant-libvirt/README.md @@ -0,0 +1,104 @@ +# setup-vagrant-libvirt + +Install Vagrant, the vagrant-libvirt provider and libvirt on an Ubuntu +GitHub Actions runner, for Beaker acceptance tests with +`BEAKER_HYPERVISOR=vagrant_libvirt`. + +Ubuntu stopped shipping `vagrant` and `vagrant-libvirt` after 23.10. +[`ppa:evgeni/vagrant`](https://launchpad.net/~evgeni/+archive/ubuntu/vagrant) +packages Vagrant 2.3.7 (the last MPL-licensed release) and a matching +vagrant-libvirt. Installing from the PPA alone fails the job whenever +Launchpad is down, so this action tries each source in turn: + +1. **The Actions cache**: the PPA's packages (including any dependencies + the PPA supplies), saved by a `cache-only` run on the default branch. + This path makes no requests to Launchpad; everything else comes from + the Ubuntu archive. +2. **The PPA**: add it, refresh only its index (so an error from that + source fails the attempt rather than being a warning), and install. + Each step is tried three times, 30s and then 60s apart. +3. **HashiCorp**: `vagrant` (pinned to `vagrant-version`) from + , plus `vagrant plugin install + vagrant-libvirt` (pinned to `vagrant-libvirt-version`), built against + `libvirt-dev`. HashiCorp's Vagrant is BSL-licensed, so the action emits + a warning when it falls back to it. + +### Which runner + +The cache and the PPA only apply on the Ubuntu series in `ppa-series`. The +PPA publishes for `noble` alone, so they need `runs-on: ubuntu-24.04`. On +any other series the action goes straight to HashiCorp, with a notice +rather than a warning. `ubuntu-latest` now resolves to `ubuntu-26.04` +(`resolute`), so a job on `ubuntu-latest` always installs HashiCorp's +Vagrant and never contacts Launchpad, and the cache below is only a +hedge for jobs on `ubuntu-24.04`. + +## Usage + +In the acceptance job: + +```yaml + - name: Setup libvirt for Vagrant + uses: simp/gha-workflows/.github/actions/setup-vagrant-libvirt@v1.1.0 + - name: beaker + env: + BEAKER_HYPERVISOR: 'vagrant_libvirt' + VAGRANT_DEFAULT_PROVIDER: 'libvirt' + run: bundle exec rake beaker:suites[default,almalinux9] +``` + +### Warming the cache + +**The cache only helps pull requests if the default branch fills it.** +A pull request can restore caches saved from its own ref, its base branch +or the default branch, but not caches saved by other pull requests. Add a +separate workflow that runs this action with `cache-only: 'true'` on the +default branch, on a runner whose Ubuntu series the PPA publishes for: + +```yaml +name: Vagrant package cache + +'on': + push: + branches: [main] + schedule: + - cron: '17 6 * * 1,4' + workflow_dispatch: {} + +jobs: + cache: + runs-on: ubuntu-24.04 + steps: + - uses: simp/gha-workflows/.github/actions/setup-vagrant-libvirt@v1.1.0 + with: + cache-only: 'true' +``` + +A `cache-only` run asks apt which packages the PPA would currently supply +and derives the cache key from their file names. If an entry with that key +already exists, the run restores it, which resets GitHub's seven-day +eviction clock. Otherwise it downloads the packages and saves a new entry. +A new PPA build therefore gets a new key, and installs restore the newest +key for their Ubuntu series and architecture. The run fails if the PPA is +still unreachable after the retries, so an outage shows up in the +scheduled run. + +GitHub disables scheduled workflows in public repos after 60 days without +activity, so on a quiet repo the cache can expire and the next pull +request starts cold. + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `cache-only` | `'false'` | `'true'` saves the PPA's packages to the Actions cache instead of installing anything | +| `cache` | `'true'` | `'false'` ignores the Actions cache when installing | +| `ppa-series` | `'noble'` | Space-separated Ubuntu codenames the PPA publishes for | +| `vagrant-version` | `'2.4.9-1'` | HashiCorp's apt version of `vagrant`, for the fallback | +| `vagrant-libvirt-version` | `'0.12.2'` | The `vagrant-libvirt` plugin version for the fallback | + +## Outputs + +| Output | Description | +|---|---| +| `source` | Install: `cache`, `ppa` or `hashicorp`. Cache-only: `cached` (already cached), `saved`, or `none` (the PPA doesn't publish for this series) | diff --git a/.github/actions/setup-vagrant-libvirt/action.yml b/.github/actions/setup-vagrant-libvirt/action.yml new file mode 100644 index 0000000..48d96e7 --- /dev/null +++ b/.github/actions/setup-vagrant-libvirt/action.yml @@ -0,0 +1,123 @@ +--- +name: 'Set up Vagrant with libvirt' +description: >- + Install Vagrant, vagrant-libvirt and libvirt on an Ubuntu runner from + ppa:evgeni/vagrant, the Actions cache, or HashiCorp's apt repository, + so a Launchpad outage doesn't fail the job. + +inputs: + cache-only: + description: >- + 'true' to save the PPA's packages to the Actions cache instead of + installing anything. Run it from the default branch (push, schedule) + so pull requests can restore the cache. + required: false + default: 'false' + cache: + description: "'false' to ignore the Actions cache when installing" + required: false + default: 'true' + ppa-series: + description: >- + Space-separated Ubuntu codenames that ppa:evgeni/vagrant publishes + for. On any other series the action goes straight to HashiCorp. + required: false + default: 'noble' + vagrant-version: + description: "HashiCorp's apt version of vagrant, for the fallback when the PPA can't be used" + required: false + default: '2.4.9-1' + vagrant-libvirt-version: + description: 'The vagrant-libvirt plugin version installed with HashiCorp vagrant' + required: false + default: '0.12.2' + +outputs: + source: + description: >- + Where Vagrant came from: 'cache', 'ppa' or 'hashicorp' (install), or + 'cached', 'saved' or 'none' (cache-only) + value: ${{ steps.install.outputs.source || steps.result.outputs.source }} + +runs: + using: composite + steps: + - name: Check the Ubuntu series + id: series + shell: bash + env: + PPA_SERIES: ${{ inputs.ppa-series }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" series' + + # Install: restore the newest cached package set, if any + - name: Restore the cached PPA packages + if: >- + inputs.cache-only != 'true' && inputs.cache != 'false' && + steps.series.outputs.ppa == 'true' + uses: actions/cache/restore@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.series.outputs.prefix }} + restore-keys: ${{ steps.series.outputs.prefix }} + + - name: Install Vagrant and libvirt + id: install + if: inputs.cache-only != 'true' + shell: bash + env: + DEBS: ${{ steps.series.outputs.dir }} + PPA: ${{ steps.series.outputs.ppa }} + CODENAME: ${{ steps.series.outputs.codename }} + VAGRANT_VERSION: ${{ inputs.vagrant-version }} + VAGRANT_LIBVIRT_VERSION: ${{ inputs.vagrant-libvirt-version }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" install' + + # Cache-only: name the PPA's current packages, then save them once + - name: Resolve the PPA packages + id: resolve + if: inputs.cache-only == 'true' && steps.series.outputs.ppa == 'true' + shell: bash + env: + PREFIX: ${{ steps.series.outputs.prefix }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" resolve' + + # A restore of an existing key also keeps it from being evicted + - name: Check the cache + id: cached + if: steps.resolve.outcome == 'success' + uses: actions/cache/restore@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.resolve.outputs.key }} + + - name: Download the PPA packages + if: steps.resolve.outcome == 'success' && steps.cached.outputs.cache-hit != 'true' + shell: bash + env: + DEBS: ${{ steps.series.outputs.dir }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" download' + + - name: Save the PPA packages + if: steps.resolve.outcome == 'success' && steps.cached.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.resolve.outputs.key }} + + - name: Report the cache result + id: result + if: inputs.cache-only == 'true' + shell: bash + env: + PPA: ${{ steps.series.outputs.ppa }} + CODENAME: ${{ steps.series.outputs.codename }} + HIT: ${{ steps.cached.outputs.cache-hit }} + run: | + if [ "$PPA" != 'true' ]; then + echo "::notice::ppa:evgeni/vagrant has no packages for Ubuntu ${CODENAME}; nothing to cache" + echo 'source=none' >> "$GITHUB_OUTPUT" + elif [ "$HIT" = 'true' ]; then + echo 'source=cached' >> "$GITHUB_OUTPUT" + else + echo 'source=saved' >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/setup-vagrant-libvirt/setup-vagrant-libvirt.bash b/.github/actions/setup-vagrant-libvirt/setup-vagrant-libvirt.bash new file mode 100755 index 0000000..13a8808 --- /dev/null +++ b/.github/actions/setup-vagrant-libvirt/setup-vagrant-libvirt.bash @@ -0,0 +1,169 @@ +#!/bin/bash +# +# The steps of action.yml. Usage: setup-vagrant-libvirt.bash COMMAND +# +# series report the Ubuntu codename, whether the PPA publishes for it, +# and the cache directory and key prefix +# install install Vagrant, vagrant-libvirt and libvirt from the cached +# PPA packages, else the PPA, else HashiCorp and rubygems.org +# resolve add the PPA and name the packages it would install, with a +# cache key derived from their file names +# download download the resolved packages into the cache directory +# (run in the same job as resolve, which adds the PPA) +# +# ppa:evgeni/vagrant has vagrant 2.3.7, the last MPL-licensed release, and +# a matching vagrant-libvirt. Ubuntu dropped both after 23.10. + +set -euo pipefail + +PPA_NAME='ppa:evgeni/vagrant' +PPA_URL='https://ppa.launchpadcontent.net/evgeni/vagrant/' +PPA_LIST="${RUNNER_TEMP:-/tmp}/vagrant-ppa-debs.list" +LIBVIRT_PACKAGES=(libvirt-daemon-system libvirt-daemon qemu-system-x86 qemu-utils dnsmasq) + +# Up to three attempts, 30s and then 60s apart +retry() { + local delay + for delay in 30 60 0; do + "$@" && return 0 + if [ "$delay" -eq 0 ]; then + return 1 + fi + echo "::notice::Retrying in ${delay}s: $*" + sleep "$delay" + done +} + +ppa_sources() { + compgen -G '/etc/apt/sources.list.d/evgeni-ubuntu-vagrant-*' || true +} + +# Refresh the PPA's index alone, failing on any error. A plain +# `apt-get update` only warns when a source is unreachable. +update_ppa() { + local sources + sources=$(ppa_sources) + [ -n "$sources" ] || return 1 + sudo apt-get update --error-on=any \ + -o Dir::Etc::sourcelist="$sources" \ + -o Dir::Etc::sourceparts=- \ + -o APT::Get::List-Cleanup=0 +} + +add_ppa() { + retry sudo add-apt-repository -y -n "$PPA_NAME" && retry update_ppa +} + +# Removing the PPA with add-apt-repository can itself need Launchpad +remove_ppa() { + sudo rm -f /etc/apt/sources.list.d/evgeni-ubuntu-vagrant-* +} + +series() { + local ppa=false arch s + # shellcheck source=/dev/null + . /etc/os-release + arch=$(dpkg --print-architecture) + for s in ${PPA_SERIES:-}; do + if [ "$s" = "$VERSION_CODENAME" ]; then + ppa=true + fi + done + { + echo "codename=${VERSION_CODENAME}" + echo "ppa=${ppa}" + echo "dir=${RUNNER_TEMP}/vagrant-ppa-debs" + echo "prefix=vagrant-ppa-debs-${VERSION_CODENAME}-${arch}-" + } >> "$GITHUB_OUTPUT" +} + +from_cache() { + if ! compgen -G "${DEBS}/*.deb" > /dev/null; then + return 1 + fi + echo "Installing the cached PPA packages: $(cd "$DEBS" && echo *.deb)" + if retry sudo apt-get update && + retry sudo apt-get install -y --no-install-recommends "$DEBS"/*.deb "${LIBVIRT_PACKAGES[@]}"; then + return 0 + fi + echo '::warning::Installing the cached PPA packages failed; trying the PPA' + return 1 +} + +from_ppa() { + if [ "${PPA:-}" != 'true' ]; then + return 1 + fi + retry sudo apt-get update || true + if add_ppa && retry sudo apt-get install -y --no-install-recommends vagrant vagrant-libvirt "${LIBVIRT_PACKAGES[@]}"; then + return 0 + fi + remove_ppa + return 1 +} + +from_hashicorp() { + local fallback="BSL-licensed Vagrant ${VAGRANT_VERSION} from HashiCorp and vagrant-libvirt ${VAGRANT_LIBVIRT_VERSION} from rubygems.org" + if [ "${PPA:-}" = 'true' ]; then + echo "::warning::ppa:evgeni/vagrant is unavailable, so this job uses ${fallback}" + else + echo "::notice::ppa:evgeni/vagrant has no packages for Ubuntu ${CODENAME}, so this job uses ${fallback}" + fi + retry curl -fsSL -o "${RUNNER_TEMP}/hashicorp.asc" https://apt.releases.hashicorp.com/gpg + sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg "${RUNNER_TEMP}/hashicorp.asc" + echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com ${CODENAME} main" | + sudo tee /etc/apt/sources.list.d/hashicorp.list + retry sudo apt-get update + retry sudo apt-get install -y --no-install-recommends "vagrant=${VAGRANT_VERSION}" \ + libvirt-dev build-essential pkg-config "${LIBVIRT_PACKAGES[@]}" + retry vagrant plugin install vagrant-libvirt --plugin-version "$VAGRANT_LIBVIRT_VERSION" +} + +install() { + local source + if from_cache; then + source=cache + elif from_ppa; then + source=ppa + else + from_hashicorp + source=hashicorp + fi + sudo chmod 666 /var/run/libvirt/libvirt-sock + echo "Installed $(vagrant --version) (source: ${source})" + echo "source=${source}" >> "$GITHUB_OUTPUT" +} + +# The PPA's packages that `apt-get install vagrant vagrant-libvirt` would +# fetch, as apt's "'URL' FILE SIZE HASH" lines. Archive packages are left +# out: the install step gets those from the archive either way. +resolve() { + local key + retry sudo apt-get update || true + add_ppa + apt-get install --print-uris -qq vagrant vagrant-libvirt | grep -F "'${PPA_URL}" > "$PPA_LIST" || true + if [ ! -s "$PPA_LIST" ]; then + echo '::error::ppa:evgeni/vagrant provides none of the packages for vagrant and vagrant-libvirt' + return 1 + fi + cat "$PPA_LIST" + key="${PREFIX}$(awk '{ print $2 }' "$PPA_LIST" | sort | sha256sum | cut -c1-16)" + echo "key=${key}" >> "$GITHUB_OUTPUT" +} + +# apt-get download checks each file against the PPA's signed index +download() { + local packages + mapfile -t packages < <(awk '{ split($2, file, "_"); print file[1] }' "$PPA_LIST") + mkdir -p "$DEBS" + (cd "$DEBS" && retry apt-get download "${packages[@]}") + ls -l "$DEBS" +} + +case "${1:-}" in + series | install | resolve | download) "$1" ;; + *) + echo "Usage: $0 series|install|resolve|download" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feeab3e..fb1c498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ -# Lint this repo's workflows (including the reusable ones) on every PR +# Lint this repo's workflows (including the reusable ones) and test its +# actions on every PR --- name: CI @@ -16,15 +17,121 @@ jobs: run: | bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -color + - name: Run shellcheck on the actions' scripts + run: shellcheck .github/actions/*/*.bash + + # Which of the jobs below this PR needs. The action's tests depend on + # Launchpad, so they only run when the action or this workflow changes: + # an outage then can't block an unrelated fix. + changes: + name: 'Changed paths' + runs-on: ubuntu-latest + outputs: + vagrant: ${{ steps.diff.outputs.vagrant }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: diff + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + git diff --name-only "$BASE" HEAD | tee changed + if grep -qE '^\.github/(actions/setup-vagrant-libvirt/|workflows/ci\.yml$)' changed; then + echo 'vagrant=true' >> "$GITHUB_OUTPUT" + else + echo 'vagrant=false' >> "$GITHUB_OUTPUT" + fi + + # The setup-vagrant-libvirt action, run on real runners in each mode: + # cache-only first, so the install jobs that follow can restore its packages + vagrant-cache: + name: 'setup-vagrant-libvirt: cache-only (${{ matrix.runner }})' + needs: changes + if: needs.changes.outputs.vagrant == 'true' + strategy: + matrix: + runner: [ubuntu-24.04, ubuntu-latest] + fail-fast: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - id: vagrant + uses: ./.github/actions/setup-vagrant-libvirt + with: + cache-only: 'true' + - name: Check the result + env: + SOURCE: ${{ steps.vagrant.outputs.source }} + run: | + echo "source: ${SOURCE}" + . /etc/os-release + if [ "$VERSION_CODENAME" = 'noble' ]; then + [[ "$SOURCE" =~ ^(cached|saved)$ ]] + else + [ "$SOURCE" = 'none' ] + fi + ! command -v vagrant + + # `noble` is the source expected where the runner is on noble, which the + # PPA publishes for; on any other series it is always hashicorp. The + # ubuntu-latest row is a canary for whatever series that label resolves to + # (currently 26.04, so HashiCorp), the way consuming repos run. + vagrant-install: + name: 'setup-vagrant-libvirt: install (${{ matrix.runner }}, ${{ matrix.case }})' + needs: [changes, vagrant-cache] + if: needs.changes.outputs.vagrant == 'true' + strategy: + matrix: + include: + - {runner: ubuntu-24.04, case: cache hit, cache: 'true', launchpad: reachable, noble: cache} + - {runner: ubuntu-24.04, case: 'cache hit, Launchpad blocked', cache: 'true', launchpad: blocked, noble: cache} + - {runner: ubuntu-24.04, case: PPA, cache: 'false', launchpad: reachable, noble: ppa} + - {runner: ubuntu-24.04, case: 'PPA down, HashiCorp fallback', cache: 'false', launchpad: blocked, noble: hashicorp} + - {runner: ubuntu-latest, case: as consuming repos run, cache: 'true', launchpad: reachable, noble: cache} + fail-fast: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - name: Block Launchpad + if: matrix.launchpad == 'blocked' + run: | + for host in ppa.launchpadcontent.net ppa.launchpad.net api.launchpad.net launchpad.net keyserver.ubuntu.com; do + echo "0.0.0.0 ${host}" | sudo tee -a /etc/hosts + done + - id: vagrant + uses: ./.github/actions/setup-vagrant-libvirt + with: + cache: ${{ matrix.cache }} + - name: Check the result + env: + SOURCE: ${{ steps.vagrant.outputs.source }} + NOBLE: ${{ matrix.noble }} + run: | + . /etc/os-release + expected=hashicorp + if [ "$VERSION_CODENAME" = 'noble' ]; then + expected=$NOBLE + fi + echo "source: ${SOURCE} (expected ${expected})" + [ "$SOURCE" = "$expected" ] + vagrant --version + vagrant plugin list | grep vagrant-libvirt + virsh --connect qemu:///system list --all # The one check for branch protection to require: GitHub only blocks a # merge on checks listed by name, and this job fails unless every job it - # needs succeeded. Add any new job in this workflow to `needs:`. + # needs succeeded or was skipped (the action tests, when `changes` says + # this PR doesn't touch the action). Add any new job in this workflow to + # `needs:`. all-checks: name: 'All checks' if: always() needs: - actionlint + - changes + - vagrant-cache + - vagrant-install runs-on: ubuntu-latest steps: - name: 'Fail unless every needed job succeeded' @@ -32,4 +139,4 @@ jobs: NEEDS: ${{ toJSON(needs) }} run: | jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS" - jq -e 'all(.[]; .result == "success")' <<< "$NEEDS" > /dev/null + jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<< "$NEEDS" > /dev/null diff --git a/README.md b/README.md index b77c400..6798bdd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ a fix is a tag bump here instead of a fleet-wide sync — and half-rolled-out changes (like [simp/puppetsync#84][84]) become structurally impossible. See [simp/puppetsync#85][85] for the background and migration plan. -This repo holds two kinds of workflow: +This repo holds two kinds of workflow, plus shared composite actions: - **Reusable workflows** (`on: workflow_call`) — called by a thin shim in each consuming repo. The original purpose of this repo. @@ -19,6 +19,9 @@ This repo holds two kinds of workflow: nothing in the other repos to trigger them. They are the right shape whenever a task is fleet-wide and needs no per-repo configuration: one file to change, idempotent, and it covers repos outside puppetsync's sync set for free. +- **Composite actions** (`.github/actions//action.yml`) — steps shared + across repos that must run inside a job the consuming repo owns, such as + setting up an acceptance job that has a repo-specific matrix. [reusing]: https://docs.github.com/en/actions/using-workflows/reusing-workflows [84]: https://github.com/simp/puppetsync/issues/84 @@ -42,6 +45,19 @@ namespaced by the kind of repo they serve: | [`org_sync_forks.yml`](.github/workflows/org_sync_forks.yml) | Weekly: fast-forward the org's pure-mirror forks from their upstreams. Skips any fork carrying SIMP commits | | [`org_reconcile_board_status.yml`](.github/workflows/org_reconcile_board_status.yml) | Hourly: reconcile the [Org Triage board](https://github.com/orgs/simp/projects/11) with live state — Status from member PRs (draft → In Progress, ready → In Review, …), `community` on outside contributions, `needs-attention` on items idle past their column's threshold. Never moves items out of Blocked/Parked/Done, never closes anything | +## Actions + +| Action | Purpose | +|---|---| +| [`setup-vagrant-libvirt`](.github/actions/setup-vagrant-libvirt) | Install Vagrant, vagrant-libvirt and libvirt for Beaker acceptance tests from the Actions cache, `ppa:evgeni/vagrant`, or HashiCorp, so a Launchpad outage doesn't fail the job | + +Call one as a step, pinned to a release tag like the workflows: + +```yaml + - name: Setup libvirt for Vagrant + uses: simp/gha-workflows/.github/actions/setup-vagrant-libvirt@v1.1.0 +``` + ## Calling a workflow Each consuming repo carries a thin shim (maintained by puppetsync) that @@ -77,15 +93,17 @@ secrets, permissions, and observable behavior). Callers pin an exact tag and keeping rollouts deliberate and reviewable. - **Major**: a caller must change (input/secret/permission contract) -- **Minor**: new workflows or new optional inputs +- **Minor**: new workflows or actions, or new optional inputs - **Patch**: behavior fixes within the existing contract ## What belongs here (and what doesn't) -- **Here**: workflow *logic* shared across repos (`on: workflow_call`), and - org-level scheduled jobs that operate on the fleet through the API (`org_*`). -- **`simp/github-action-*` repos**: single composite/Docker **actions** - (things with an `action.yml`), e.g. +- **Here**: workflow *logic* shared across repos (`on: workflow_call`), + org-level scheduled jobs that operate on the fleet through the API + (`org_*`), and composite actions shared across repos + (`.github/actions//`). New actions go here rather than in a repo of + their own, so there is one repo to version, review and protect. Older + actions still live in their own `simp/github-action-*` repos, e.g. [github-action-build-and-sign-pkg-single-rpm](https://github.com/simp/github-action-build-and-sign-pkg-single-rpm). - **The consuming repo**: triggers, per-repo configuration (matrices, nodesets), and anything repo-specific. If a workflow needs per-repo