From cc8888279d914a382dd8f8ac47dd59aa28e7e040 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 00:25:14 +0000 Subject: [PATCH 1/2] ci: stop repeating work the hosted runner has already done shellcheck and yamllint come with the ubuntu-latest image, so their jobs no longer install them. The collection is built once, in the build job, which also checks the tarball ships what the roles need. That job uses the image's ansible-core instead of installing the Ansible package. The commit and pull request title lints share one job, one checkout and one npm install. actionlint is fetched at a pinned version. --- .github/workflows/ci.yml | 47 +++++++++++----------------------------- README.md | 2 +- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46d4d05a..bab7b31e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,6 @@ jobs: - name: Lint every shell script run: | set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq shellcheck mapfile -t scripts < <(find . -type f -name '*.sh' -not -path './.git/*') echo "Found ${#scripts[@]} shell script(s)." if [ "${#scripts[@]}" -gt 0 ]; then @@ -43,7 +41,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install actionlint - run: bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + run: bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 - name: Lint every workflow file run: ./actionlint -color @@ -56,8 +54,6 @@ jobs: - name: Lint every YAML file run: | set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq yamllint yamllint . hadolint: @@ -104,16 +100,6 @@ jobs: echo "Syntax-checking ${playbook}" ansible-playbook -i ../examples/three-server-tailscale/inventory.yml "${playbook}" --syntax-check done - - name: Build the collection - # Proves galaxy.yml's build_ignore still ships what the roles need at runtime, such as the k3s node's Compose file and build context under roles/github_runner_cluster/files. - run: | - set -euo pipefail - ansible-galaxy collection build --output-path "${RUNNER_TEMP}/collection" - tar -tzf "${RUNNER_TEMP}"/collection/*.tar.gz | grep -Fx roles/github_runner_cluster/files/docker-compose.yml - tar -tzf "${RUNNER_TEMP}"/collection/*.tar.gz | grep -Fx roles/github_runner_cluster/files/k3s/node-entrypoint.sh - for file in Dockerfile fleet-regrade.sh fleet-regrade-core.sh fleet-regrade-docker.sh; do - tar -tzf "${RUNNER_TEMP}"/collection/*.tar.gz | grep -Fx "roles/github_runner_cluster/files/regrade/${file}" - done unit-tests: name: Unit tests (shard ${{ matrix.shard }}) @@ -171,13 +157,19 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - name: Install Ansible - run: | - set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq ansible - name: Build the collection tarball + # ansible-galaxy and ansible-doc come with the ansible-core the ubuntu-latest image already has: building, installing and reading a collection's own plugins needs no other collection. run: ansible-galaxy collection build --force + - name: Check the tarball ships what the roles need at runtime + # Proves galaxy.yml's build_ignore still ships what the roles need at runtime, such as the k3s node's Compose file and build context under roles/github_runner_cluster/files. + run: | + set -euo pipefail + tarball=$(ls exadev-github_runner-*.tar.gz) + tar -tzf "$tarball" | grep -Fx roles/github_runner_cluster/files/docker-compose.yml + tar -tzf "$tarball" | grep -Fx roles/github_runner_cluster/files/k3s/node-entrypoint.sh + for file in Dockerfile fleet-regrade.sh fleet-regrade-core.sh fleet-regrade-docker.sh; do + tar -tzf "$tarball" | grep -Fx "roles/github_runner_cluster/files/regrade/${file}" + done - name: Install the built tarball and verify its content run: | set -euo pipefail @@ -210,19 +202,6 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: npx --no-install commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose - - commitlint-pr-title: - name: Lint PR title - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: '22' - cache: npm - - run: npm ci # Rebase-merges keep each commit's own message, so the title isn't what lands on main; it's still checked so the PR list reads consistently. Passed through env, never interpolated into the script. - name: Lint the pull request title env: @@ -231,7 +210,7 @@ jobs: required-checks: name: Required Checks - needs: [shellcheck, actionlint, yamllint, hadolint, ansible-syntax-check, unit-tests, ansible-lint, collection-build, commitlint, commitlint-pr-title] + needs: [shellcheck, actionlint, yamllint, hadolint, ansible-syntax-check, unit-tests, ansible-lint, collection-build, commitlint] # Always runs, even if a dependency failed or was skipped, so it can report that failure itself rather than being skipped - a required check that's silently absent blocks a PR indefinitely instead of failing it visibly. if: always() runs-on: ubuntu-latest diff --git a/README.md b/README.md index e7faa3b2..27cd2244 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ There is no application test suite in this repo; the checks above are the projec ## Releases -Every push to `main` runs `.github/workflows/release.yml`, driven by [semantic-release](https://semantic-release.gitbook.io/) (`.releaserc.json`) against [Conventional Commits](https://www.conventionalcommits.org/) (`commitlint.config.js`, enforced by the `commitlint`/`commitlint-pr-title` CI jobs above). Almost every conventional-commit type triggers at least a patch release (see `.releaserc.json`'s `commit-analyzer` `releaseRules`: `feat` is minor, a breaking change (`!` or a `BREAKING CHANGE:` footer) is major, everything else conventional is patch), so in practice nearly every push to `main` produces a release. +Every push to `main` runs `.github/workflows/release.yml`, driven by [semantic-release](https://semantic-release.gitbook.io/) (`.releaserc.json`) against [Conventional Commits](https://www.conventionalcommits.org/) (`commitlint.config.js`, enforced by the `commitlint` CI job above, which checks every commit and the pull request title). Almost every conventional-commit type triggers at least a patch release (see `.releaserc.json`'s `commit-analyzer` `releaseRules`: `feat` is minor, a breaking change (`!` or a `BREAKING CHANGE:` footer) is major, everything else conventional is patch), so in practice nearly every push to `main` produces a release. A release: From 175d1b6b75caeeb0c5d4efc8bd4edbfdc9928d0a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 00:25:15 +0000 Subject: [PATCH 2/2] ci: skip the run on main for a tree its pull request already passed A push to main whose tree equals the tree of the pull request it merged, and whose pull request passed Required Checks on that head, would run the same files again. The run is skipped only on that explicit match, and any failure to decide runs the full set. --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bab7b31e..43b1c870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,43 @@ permissions: contents: read jobs: + verified: + name: Already verified + # A push to main whose tree is the tree its pull request was merged with, and whose pull request passed Required Checks on that head, has nothing new to check: the same files give the same result. Every other push, and every pull request, runs the full set. A failure here runs the full set too (the other jobs only skip on an explicit "true"). + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + checks: read + outputs: + skip: ${{ steps.compare.outputs.skip }} + steps: + - id: compare + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + skip=false + head=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq '.[0].head.sha // empty') + if [ -n "$head" ]; then + pushed_tree=$(gh api "repos/${REPO}/git/commits/${SHA}" --jq .tree.sha) + merged_tree=$(gh api "repos/${REPO}/git/commits/${head}" --jq .tree.sha) + verdict=$(gh api "repos/${REPO}/commits/${head}/check-runs?filter=latest&check_name=Required%20Checks" --jq '.check_runs[0].conclusion // empty') + echo "pushed tree ${pushed_tree}, pull request head ${head} tree ${merged_tree}, its Required Checks: ${verdict:-none}" + if [ "$pushed_tree" = "$merged_tree" ] && [ "$verdict" = success ]; then + skip=true + fi + fi + echo "skip=${skip}" >> "$GITHUB_OUTPUT" + shellcheck: name: Shellcheck + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -36,6 +71,8 @@ jobs: actionlint: name: actionlint + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -47,6 +84,8 @@ jobs: yamllint: name: YAML lint + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -58,6 +97,8 @@ jobs: hadolint: name: hadolint + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -85,6 +126,8 @@ jobs: ansible-syntax-check: name: Ansible syntax check + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -103,6 +146,8 @@ jobs: unit-tests: name: Unit tests (shard ${{ matrix.shard }}) + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest # Each module is run on its own by discovery, dealt out by size so that the shards take about the same amount of work: the tests that run Ansible playbooks cost a process each, and the suite no longer fits one job. timeout-minutes: 20 @@ -135,6 +180,8 @@ jobs: ansible-lint: name: ansible-lint + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -153,6 +200,8 @@ jobs: collection-build: name: Collection build check + needs: verified + if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: