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
96 changes: 62 additions & 34 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,50 @@ 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:
- uses: actions/checkout@v7
- 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
Expand All @@ -38,30 +71,34 @@ jobs:

actionlint:
name: actionlint
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
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

yamllint:
name: YAML lint
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Lint every YAML file
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq yamllint
yamllint .

hadolint:
name: hadolint
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
Expand Down Expand Up @@ -89,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:
Expand All @@ -104,19 +143,11 @@ 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 }})
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
Expand Down Expand Up @@ -149,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:
Expand All @@ -167,17 +200,25 @@ jobs:

collection-build:
name: Collection build check
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
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
Expand Down Expand Up @@ -210,19 +251,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:
Expand All @@ -231,7 +259,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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading