Saltar al contenido
mypipelines
Shared (cross-cutting)·Reusable workflow·on: pull_request, push

Ci

CI

.github/workflows/ci.yml

.github/workflows/ci.yml
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: inherit