Repository navigation
332 lines (316 loc) · 17.6 KB
/
Copy pathci.yml
File metadata and controls
332 lines (316 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
name: CI
# Lints every shell script, workflow file, YAML file, and Dockerfile in the repo. There is no application test suite here (this repo is fleet infrastructure config, not application code) - these checks are its actual verification surface, matching the README's Build/test/lint section.
#
# Runs on GitHub-hosted runners, which are free for a public repository, so the checks of the fleet's own configuration don't depend on the fleet being up. The fleet's own behaviour is tested by test-arc-runner.yml and test-autoscaler.yml, which target it directly.
#
# required-checks is the single required status check in this repo's ruleset (never the individual job names), so adding or renaming a lint job never needs a ruleset update - matching ExaDev/eslint-config and ExaDev/wire-mesh's own ci.yml.
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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
mapfile -t scripts < <(find . -type f -name '*.sh' -not -path './.git/*')
echo "Found ${#scripts[@]} shell script(s)."
if [ "${#scripts[@]}" -gt 0 ]; then
shellcheck "${scripts[@]}"
fi
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/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
yamllint .
hadolint:
name: hadolint
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Install hadolint
# Detect arch rather than assume amd64, so the step also works on an arm64 runner such as this project's own fleet. Downloads into the workspace (no sudo, no system path) - actions-runner pods run the workflow user without write access to /usr/local/bin.
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) hadolint_arch=x86_64 ;;
aarch64|arm64) hadolint_arch=arm64 ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL -o ./hadolint "https://github.com/hadolint/hadolint/releases/download/v2.15.1/hadolint-linux-${hadolint_arch}"
chmod +x ./hadolint
- name: Lint every Dockerfile
run: |
set -euo pipefail
mapfile -t dockerfiles < <(find . -type f -iname 'Dockerfile*' -not -path './.git/*')
echo "Found ${#dockerfiles[@]} Dockerfile(s)."
if [ "${#dockerfiles[@]}" -gt 0 ]; then
# error threshold, not the default (info): this repo deliberately floats several base images/packages on `latest` rather than pinning (see e.g. ghcr.io/exadev/github-runner:latest), so hadolint's warning-level pinning advice (DL3007/DL3008/DL3018) stays visible in the log without failing every run over an already-made, documented tradeoff.
./hadolint --failure-threshold error "${dockerfiles[@]}"
fi
ansible-syntax-check:
name: Ansible syntax check
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Syntax-check every playbook
# From ansible/ so its ansible.cfg applies, against an example inventory, since this repository carries no fleet's own. Every top-level playbook is checked, so a new one is covered without editing this list.
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq ansible pkg-config
cd ansible
for playbook in ../playbooks/*.yml; do
echo "Syntax-checking ${playbook}"
ansible-playbook -i ../examples/three-server-tailscale/inventory.yml "${playbook}" --syntax-check
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, and scripts/ci/shard_unit_tests.py deals the modules out by the work counted from their tests: the tests that run Ansible playbooks cost a process each, so the playbook runs a module's tests reach, not its bytes, decide its time, and the suite no longer fits one job. The timeout is a guard against a hung test, set above the heaviest shard's run, never a budget the shards are fitted to.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- name: Install Ansible, zsh and fio
# zsh is one of the two shells the host tools role writes startup lines for, and its tests read those lines back with a real zsh. fio is the disk check the role controller's probe takes its fdatasync reading with, which its tests run for real.
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq ansible pkg-config zsh fio
- name: Install mise
# The host tools tests read the mise configuration the role renders back with a real mise, to check that every value reaches the variable as given. A release more than a week old, pinned to its published checksum so the download is the file that was tested; the version is the one the tests were written against, raised by hand with its checksum from the release's SHASUMS256.txt.
env:
MISE_VERSION: v2026.10.20
MISE_SHA256: 5a9057180c3a9a795076f84be4f5f372e6e37179e1aeb49732928d6b395ce829
run: |
set -euo pipefail
directory="${RUNNER_TEMP}/mise"
mkdir -p "${directory}"
curl -fsSL -o "${directory}/mise.tar.gz" "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64.tar.gz"
echo "${MISE_SHA256} ${directory}/mise.tar.gz" | sha256sum --check --strict
tar -xzf "${directory}/mise.tar.gz" -C "${directory}"
echo "${directory}/mise/bin" >> "$GITHUB_PATH"
- name: Install etcd for the probe's live test
# The probe's tests start a real three-member etcd to read its metrics from. Ubuntu's own etcd-server is 3.4, which has none of the 3.6 metrics the probe reads, so the official release is used: the exact version, and the SHA-256 of its linux-amd64 tarball as the release publishes it in its SHA256SUMS asset (https://github.com/etcd-io/etcd/releases/download/v3.6.15/SHA256SUMS), checked before anything is extracted. A mismatch fails the job.
env:
ETCD_VERSION: v3.6.15
ETCD_LINUX_AMD64_SHA256: b51d86e6bff2168de5ba8cd631c0fe6c785c7a1b32b083079f5677d6b7e2772c
run: |
set -euo pipefail
[ "$(uname -m)" = x86_64 ] || { echo "the pinned checksum is for linux-amd64, not $(uname -m)" >&2; exit 1; }
dir="${RUNNER_TEMP}/etcd"
mkdir -p "$dir"
curl -fsSL -o "${dir}/etcd.tar.gz" "https://github.com/etcd-io/etcd/releases/download/${ETCD_VERSION}/etcd-${ETCD_VERSION}-linux-amd64.tar.gz"
echo "${ETCD_LINUX_AMD64_SHA256} ${dir}/etcd.tar.gz" | sha256sum --check --strict
tar -xzf "${dir}/etcd.tar.gz" -C "$dir" --strip-components=1 "etcd-${ETCD_VERSION}-linux-amd64/etcd" "etcd-${ETCD_VERSION}-linux-amd64/etcdctl"
echo "ETCD_BINARY=${dir}/etcd" >> "$GITHUB_ENV"
- name: Unit-test the collection
# Ubuntu's ansible package puts ansible-core on the system Python the filter plugins import from. The runner image can carry another Ansible earlier on PATH, so the tests run with /usr/bin first and ANSIBLE_PLAYBOOK pointing at Ubuntu's, which makes every playbook a test runs use the same ansible-core as the filters it imports; both versions are printed for the record. The modules this shard runs are the ones scripts/ci/shard_unit_tests.py deals to it, imported from this checkout, so ansible must already be importable and ANSIBLE_PLAYBOOK set, as they are here.
env:
SHARD: ${{ matrix.shard }}
SHARDS: ${{ strategy.job-total }}
run: |
set -euo pipefail
export PATH="/usr/bin:${PATH}" ANSIBLE_PLAYBOOK=/usr/bin/ansible-playbook
ansible-playbook --version | head -n 1
python3 -c 'import ansible.release; print("ansible-core", ansible.release.__version__)'
# An assignment, not a process substitution, so that a failed dealing stops the step instead of leaving a shard with no modules to pass.
shard_modules=$(python3 scripts/ci/shard_unit_tests.py "$SHARD" "$SHARDS")
mapfile -t modules <<< "$shard_modules"
echo "Shard ${SHARD} of ${SHARDS}: ${#modules[@]} module(s)"
# Every module runs whatever an earlier one did, so one failure never hides another's; the shard fails afterwards, naming each module that failed.
failed=()
for module in "${modules[@]}"; do
python3 -m unittest discover -s tests/unit -p "$(basename "$module")" -v || failed+=("$module")
done
if [ "${#failed[@]}" -gt 0 ]; then
echo "::error::Failed module(s): ${failed[*]}"
exit 1
fi
ansible-lint:
name: ansible-lint
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Install ansible-lint
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq python3-pip
python3 -m pip install --user --break-system-packages 'ansible-lint>=25'
# pip --user puts the console script in ~/.local/bin, which isn't on the runner's PATH by default.
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Lint the collection
# playbooks/collections/ansible_collections/exadev/github_runner is a symlink back to this checkout's own root, for local FQCN resolution (see ansible/ansible.cfg's comment on roles_path). Excluding it stops ansible-lint's own directory walk from following that symlink back into the tree it is already linting, which otherwise relints the whole collection a second time under a growing path on every recursion.
run: ansible-lint --profile production --exclude playbooks/collections
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: 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
tar -tzf "$tarball" | grep -Fx roles/github_runner_cluster/files/node-link.sh
for file in Dockerfile fleet-regrade.sh fleet-regrade-core.sh fleet-regrade-k3s.sh fleet-regrade-docker.sh fleet-regrade-systemd.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
tarball=$(ls exadev-github_runner-*.tar.gz)
install_path="$(mktemp -d)"
ansible-galaxy collection install "$tarball" -p "$install_path"
export ANSIBLE_COLLECTIONS_PATH="$install_path"
echo "Roles shipped in the built collection:"
ls "$install_path/ansible_collections/exadev/github_runner/roles"
echo "Filter plugins shipped in the built collection:"
ansible-doc -t filter -l exadev.github_runner | cat
commitlint:
name: commitlint
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
# Runs the repo's own pinned commitlint rather than a Docker-based action: container actions need the runner to pull an image, which the self-hosted runners can't always do.
- name: Lint every commit in the pull request
env:
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
# 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:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE" | npx --no-install commitlint --verbose
fleet-role-image:
name: Role controller image build
needs: verified
if: ${{ !cancelled() && needs.verified.outputs.skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v3
# Built and not pushed, from the same Dockerfile and context the release workflow builds it from, so a missing COPY source or a package apk cannot install fails the pull request and not the release.
- uses: docker/build-push-action@v6
with:
context: .
file: fleet-role/Dockerfile
push: false
load: true
tags: fleet-role:ci
- name: Run what the pods run
# The tools and files the pods need, the jq modules under the image's own jq, the probe's check mode through the regrade core the image carries, and one pass of the controller against a kubectl that answers an empty cluster. Nothing reaches a cluster.
run: tests/fleet_role/image-smoke.sh fleet-role:ci
required-checks:
name: Required Checks
needs: [shellcheck, actionlint, yamllint, hadolint, ansible-syntax-check, unit-tests, ansible-lint, collection-build, commitlint, fleet-role-image]
# 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
timeout-minutes: 5
steps:
- name: Fail if any required job failed or was cancelled
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs did not succeed:"
echo '${{ toJSON(needs) }}'
exit 1
fi