Skip to content

fix(ci): lint PR titles as the squash subject (AND-1960) #38

fix(ci): lint PR titles as the squash subject (AND-1960)

fix(ci): lint PR titles as the squash subject (AND-1960) #38

Workflow file for this run

name: ci
on:
pull_request:
# `edited` included: the PR TITLE is the squash subject the pr-title job
# gates, so a title edit must re-evaluate CI (AND-1960) — the default
# types (opened/synchronize/reopened) never see it.
types: [opened, synchronize, reopened, edited]
push:
branches: [main]
workflow_dispatch:
concurrency:
# PR-event runs get a PER-RUN group on purpose (no dedup, nothing to cancel):
# a run that ends "cancelled" on the head SHA can strand the required `ci`
# context (AND-1960), and both cancel-in-progress settings can produce one —
# true cancels in-flight runs, false still cancels queued ones. Push runs
# keep the shared group + cancellation; nothing gates on them.
group: ci-${{ github.ref }}-${{ github.event_name == 'push' && 'push' || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' }}
permissions:
contents: read
pull-requests: read
jobs:
detect:
name: detect stack
runs-on: ubuntu-latest
outputs:
python: ${{ steps.set.outputs.python }}
node: ${{ steps.set.outputs.node }}
swift: ${{ steps.set.outputs.swift }}
steps:
- uses: actions/checkout@v7
- id: set
run: |
echo "python=$([ -f pyproject.toml ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "node=$([ -f package.json ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "swift=$([ -f Package.swift ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
commitlint:
name: conventional commits
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
pr-title:
name: pr title (squash subject)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v7
# This repo squash-merges: the PR TITLE becomes the commit subject on
# main, so per-commit commitlint alone cannot protect main. Lint the
# title against the same config the commit lint uses.
# Title goes through env (never template-interpolated into the script).
# Isolated temp prefix (Codex P2 on anders-repo-template#14): npm install
# in the checkout would first install the PROJECT's own dependency tree —
# a private/heavy dep could fail the gate on a valid title. The prefix
# holds only the two exact-pinned lint packages (--ignore-scripts), and
# the config is copied beside them because commitlint resolves `extends`
# from the CONFIG FILE's directory.
- name: lint PR title against commitlint config
env:
TITLE: ${{ github.event.pull_request.title }}
PRNUM: ${{ github.event.pull_request.number }}
run: |
mkdir -p "$RUNNER_TEMP/title-lint"
cp .commitlintrc.json "$RUNNER_TEMP/title-lint/"
npm install --prefix "$RUNNER_TEMP/title-lint" --no-save --no-audit --no-fund \
--ignore-scripts @commitlint/cli@21.2.1 @commitlint/config-conventional@21.2.0
# Lint the REAL squash subject: GitHub appends " (#N)" on squash-merge
# (Codex P2 on anders-skills-lite#6), so a title at the length limit
# would land over it on main.
printf '%s (#%s)\n' "$TITLE" "$PRNUM" \
| npx --prefix "$RUNNER_TEMP/title-lint" --no-install commitlint \
--config "$RUNNER_TEMP/title-lint/.commitlintrc.json"
gitleaks:
name: secret scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shellcheck:
name: shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: shellcheck scripts/*.sh
python:
name: python lint + test
needs: detect
if: needs.detect.outputs.python == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with: { version: "latest" }
# --all-extras: PEP 621 [project.optional-dependencies]
# --all-groups: PEP 735 [dependency-groups]
- run: uv sync --frozen --all-extras --all-groups
- name: ruff check
run: uvx ruff@0.6.9 check .
- name: ruff format check
run: uvx ruff@0.6.9 format --check .
- name: pytest
if: hashFiles('tests/**') != ''
run: uv run pytest -q
node:
name: node lint + test
needs: detect
if: needs.detect.outputs.node == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20'
# npm install (not ci) tolerates lockfile drift and missing lockfile.
# Lock down to `npm ci` per-repo once the lockfile is reliably in sync.
- run: npm install --no-audit --no-fund
- name: lint
run: npm run lint --if-present
- name: test
run: npm test --if-present
swift:
name: swift build + test
needs: detect
if: needs.detect.outputs.swift == 'true'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: swift build
run: swift build
- name: swift test
if: hashFiles('Tests/**') != ''
run: swift test
ci:
name: ci
needs: [commitlint, pr-title, gitleaks, shellcheck, python, node, swift]
if: always()
runs-on: ubuntu-latest
steps:
- name: aggregate
run: |
# Fail if any required upstream job failed (skipped is OK for stack-conditional jobs)
for r in "${{ needs.commitlint.result }}" "${{ needs.pr-title.result }}" "${{ needs.gitleaks.result }}" \
"${{ needs.shellcheck.result }}" \
"${{ needs.python.result }}" "${{ needs.node.result }}" "${{ needs.swift.result }}"; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "Upstream job failed: $r"
exit 1
fi
done
echo "All required checks passed."