name: CI
# This repository publishes the CI that every other repository runs, and until now# it ran none of its own: no workflow here triggers on pull_request, so a PR could# not be wrong in a way anything would notice. Every defect shipped from here lands# in every consumer at once, so the bar should be higher here than downstream, not# lower.
on: pull_request: branches: [develop, main] push: branches: [develop, main]
# Read-only. Nothing in this workflow writes to the repository, and a job that# cannot write cannot be turned into one that does by a dependency that moves.permissions: contents: read
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
jobs: actionlint: name: Lint workflows runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run actionlint env: # Pinned deliberately: this is the gate, so it should not change under us. ACTIONLINT_VERSION: '1.7.7' run: | set -euo pipefail curl -fsSL -o actionlint.tar.gz \ "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" tar -xzf actionlint.tar.gz actionlint
# Errors fail the build. shellcheck info/style does not: there are ~350 of # those already and failing on them would mean fixing all of them first. # Revisit once that backlog is down. ./actionlint -shellcheck= -oneline .github/workflows/*.yml
job-timeouts: name: Every runnable job has a timeout runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# A job without timeout-minutes holds a runner for six hours when a step # hangs, and in a reusable workflow it does so in every consumer at once. # A value must be an int from 1 to 360, or an expression (e.g. the # `${{ inputs.timeout_minutes }}` a reusable workflow forwards from its # caller) -- 0, out-of-range and non-numeric values hang or misfire just # the same. Jobs that only `uses:` another workflow cannot set one and # are skipped. templates/ is scanned too: consumers copy those jobs # verbatim, so a missing timeout there ships straight into every repo # that copies the file. - name: Check run: | python3 - <<'PY' import glob, sys, yaml
expr_prefix = '$' + '{{' # split so actionlint's own expression lexer doesn't trip on this literal
def is_valid(value): if isinstance(value, str): return value.startswith(expr_prefix) return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 360
bad = 0 for f in sorted(glob.glob('.github/workflows/*.yml') + glob.glob('templates/*.yml')): for name, job in (yaml.safe_load(open(f)).get('jobs') or {}).items(): if 'uses' in job: continue if 'timeout-minutes' not in job: print(f"::error file={f}::job '{name}' has no timeout-minutes") bad += 1 continue value = job['timeout-minutes'] if not is_valid(value): print(f"::error file={f}::job '{name}' has timeout-minutes {value}; expected 1-360 or an expression") bad += 1 print(f"{bad} jobs without a valid timeout") sys.exit(1 if bad else 0) PY
yaml: name: Parse YAML runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Every workflow and template must parse run: | python3 - <<'PY' import glob, sys, yaml bad = 0 for f in sorted(glob.glob('.github/workflows/*.yml') + glob.glob('templates/*.yml')): try: yaml.safe_load(open(f)) except Exception as e: print(f"::error file={f}::{e}") bad += 1 print(f"{bad} parse failures") sys.exit(1 if bad else 0) PY
template-pins: name: Templates pin a release runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: No template may track a moving ref run: | set -euo pipefail
# Consumers copy templates/ verbatim, so whatever ref a template names is # what runs in their repository. A template on @main means any commit here # reaches them with no review and no version bump -- which is how the only # destructive template (shared-cleanup-packages) ended up scheduled against # the default branch in every consumer. Releases are what consumers pin. if BAD=$(grep -rnE 'uses:.*ci-templates/\.github/workflows/.*@' templates/ \ | grep -vE '@v[0-9]+$'); then echo "::error::Templates must pin a release tag (@vN, e.g. @v2), not a branch or SHA." echo "$BAD" | sed 's/^/ /' exit 1 fi echo "All template references pin a release tag."
template-concurrency: name: Templates declare concurrency runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# A deploy template without a group lets two merges deploy at once, and # the older one can finish last. A group alone doesn't say which runs may # be cancelled: a stale validation run should go, a deploy cancelled # mid-flight leaves a service half-shipped. "Never cancel a deploy" is # the property that matters, so it is asserted below, not just the key. - name: Check run: | python3 - <<'PY' import glob, re, sys, yaml
cancels_stale = re.compile(r'(java-validate|-feature-build|-pr-develop|contracts-pr-full|shared-validate-image-pr)\.yml$')
bad = [] for f in sorted(glob.glob('templates/*.yml')): doc = yaml.safe_load(open(f)) or {} concurrency = doc.get('concurrency') if concurrency is None: bad.append(f"::error file={f}::no top-level concurrency") continue cancel_in_progress = concurrency.get('cancel-in-progress') if cancels_stale.search(f): if cancel_in_progress is not True: bad.append(f"::error file={f}::{f}: a validation template should cancel stale runs") elif cancel_in_progress is not False: bad.append(f"::error file={f}::{f}: a deploy/publish template must not cancel in progress")
for line in bad: print(line) sys.exit(1 if bad else 0) PY
action-pin-drift: name: One SHA per action runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: No action may be pinned at two different SHAs run: | python3 - <<'PY' import glob, re, sys, collections
# Dependabot's github-actions ecosystem only scans .github/workflows/. Anything # under templates/ is invisible to it, so a SHA pinned there is frozen the day # it lands. That is how templates/java-tag-deploy.yml and krakend-tag-deploy.yml # sat on actions/checkout v5.1.0 while every workflow here ran v7.0.1 -- two # major versions of drift in the two templates that deploy to production. # # Divergence is the detectable half: if the same action is pinned at two SHAs, # one of them is stale. A single shared SHA means Dependabot's bump covers the # templates too, because this job fails until they are moved together. pins = collections.defaultdict(set) where = collections.defaultdict(list) pat = re.compile(r'uses:\s*([^\s@]+)@([0-9a-f]{40})')
for f in sorted(glob.glob('.github/workflows/*.yml') + glob.glob('templates/*.yml')): for n, line in enumerate(open(f), 1): if line.lstrip().startswith('#'): continue m = pat.search(line) if m: action, sha = m.group(1), m.group(2) pins[action].add(sha) where[(action, sha)].append(f"{f}:{n}")
bad = 0 for action, shas in sorted(pins.items()): if len(shas) > 1: bad += 1 print(f"::error::{action} is pinned at {len(shas)} different SHAs") for sha in sorted(shas): print(f" {sha[:12]}") for loc in where[(action, sha)]: print(f" {loc}")
print(f"{len(pins)} actions pinned, {bad} with divergent SHAs") sys.exit(1 if bad else 0) PY
inputs-declared: name: Forwarded inputs exist runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Every inputs.X referenced must be declared in the same workflow run: | python3 - <<'PY' import glob, re, sys
# A `with:` block that forwards an input never declared in the same file is # not caught by actionlint: the expression resolves to empty and the job # silently does the wrong thing. Cheap to catch, tedious to debug. # Comment lines are stripped first, or prose mentioning an input name would # register as a use. bad = 0 for f in sorted(glob.glob('.github/workflows/*.yml')): raw = open(f).read() src = '\n'.join(re.sub(r'#.*$', '', l) for l in raw.split('\n')) head = src.split('\njobs:', 1)[0] declared = set(re.findall(r'^ ([a-z0-9_]+):', head, re.M)) used = set(re.findall(r'inputs\.([a-z0-9_]+)', src)) missing = sorted(used - declared) if missing: print(f"::error file={f}::undeclared inputs referenced: {', '.join(missing)}") bad += 1 print(f"{bad} workflows reference undeclared inputs") sys.exit(1 if bad else 0) PY
selftest-scripts: name: Script self-tests runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# java-test's threshold check is the one script here whose bug was silent: # a wrong comparison skipped the gate and the job stayed green. Run it # against a table of inputs on every PR rather than trusting a read-through. - name: Run the coverage gate against its case table run: .github/selftest/coverage-gate.test.sh
# The digest-or-tag rule is copied into three deploy workflows. This keeps # the copies identical and pins what the rule does with and without a digest. - name: Run the image-ref rule against its cases run: .github/selftest/image-ref.test.sh
commit-lint: name: Commit messages # Dogfooding: this repository's own commit-lint workflow, run against this # repository's PRs. If it breaks for consumers, it breaks here first. if: github.event_name == 'pull_request' uses: ./.github/workflows/shared-commit-lint.yml secrets: inheritShared (cross-cutting)·Reusable workflow·on: pull_request, push
Ci
CI
.github/workflows/ci.yml