diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba8bf6..45fe6d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,22 @@ 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: - group: ci-${{ github.ref }} - cancel-in-progress: true + # 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 @@ -41,6 +50,38 @@ jobs: 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 @@ -111,14 +152,14 @@ jobs: ci: name: ci - needs: [commitlint, gitleaks, shellcheck, python, node, swift] + 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.gitleaks.result }}" \ + 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