diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46d4d05a..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: @@ -28,8 +63,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 @@ -38,17 +71,21 @@ 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: @@ -56,12 +93,12 @@ jobs: - 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: @@ -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: @@ -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 @@ -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: @@ -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 @@ -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: @@ -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 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: