Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/sarif-lint-regression-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@gtbuchanan/cli': minor
---

Rework lint enforcement as a SARIF ratchet. Lint tasks are now
reporters: `lint:eslint` writes `dist/sarif/eslint.sarif` and no
longer fails on warnings (fatal errors still fail). The new
`gtb sarif compare` command fails only on findings that are new
relative to the merge base, and `gtb sarif baseline` snapshots HEAD's
logs so CI can seed a cross-PR baseline cache — see the new
`lint-regression.yml` and `lint-baseline.yml` reusable workflows.
7 changes: 7 additions & 0 deletions .github/dependency-review-config.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
---
allow-dependencies-licenses:
# jschardet LGPL-2.1-or-later: transitive dep of
# @microsoft/eslint-formatter-sarif, used only by its
# SARIF_ESLINT_EMBED feature (which the gtb lint pipeline never
# enables). Unmodified and installed separately from the registry —
# the clean LGPL case. Name-scoped so LGPL stays gated elsewhere;
# retire if sarif-sdk makes its embed deps optional.
- pkg:npm/jschardet
# lightningcss MPL-2.0 (dev-only build tool): excluded by name rather
# than allow-listing MPL globally, so future MPL deps still get gated.
# All platform binaries enumerated (no wildcard support).
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/lint-baseline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
env:
# GitHub Actions has no TTY, so force color; Actions renders the ANSI.
FORCE_COLOR: '1'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
seed:
name: Lint Baseline Seed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: gtbuchanan/tooling/.github/actions/mise-setup@main

- uses: gtbuchanan/tooling/.github/actions/pnpm-tasks@main

# Cache-hits against the CI workflow's lint run of this commit
# when the turbo remote cache is configured.
- name: Lint HEAD
run: >-
${{ inputs.gtb-from-source && 'pnpm run gtb' || 'pnpm exec gtb' }}
turbo run lint

- name: Snapshot baselines
run: >-
${{ inputs.gtb-from-source && 'pnpm run gtb' || 'pnpm exec gtb' }}
sarif baseline

- uses: actions/cache/save@v6
with:
key: lint-baseline-${{ github.sha }}
path: |
**/dist/sarif/base/**
!**/node_modules/**
dist/sarif/base.ref

name: Lint Baseline

# Reusable: seed the cross-PR lint baseline cache from a default-branch
# commit. Any future PR's merge base is that commit itself, so its
# baseline is just the commit's own lint output — snapshot it via
# `gtb sarif baseline` and save it keyed on the SHA. Caches created on
# the default branch are readable from every PR, so the
# `lint-regression.yml` compare restores it and skips merge-base
# production entirely. The gate stays self-sufficient without it — a
# cache miss just means the compare lints the merge base itself.
on:
workflow_call:
inputs:
gtb-from-source:
default: false
description: >-
Run gtb from the workspace source (`pnpm run gtb`) instead of
the installed bin (`pnpm exec gtb`). Set true only by the repo
that vendors `@gtbuchanan/cli` as a workspace package (tooling
itself). Consumers leave it false.
type: boolean

permissions:
contents: read
216 changes: 216 additions & 0 deletions .github/workflows/lint-regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
---
env:
# GitHub Actions has no TTY, so force color; Actions renders the ANSI.
FORCE_COLOR: '1'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
run:
name: Lint Regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Depth 2 resolves both parents of the PR merge ref the
# checkout lands on; the first parent is the target branch
# head — the merge base of the merged checkout — so no branch
# fetch or merge-base resolution is needed.
fetch-depth: 2

- uses: gtbuchanan/tooling/.github/actions/mise-setup@main

- uses: gtbuchanan/tooling/.github/actions/pnpm-tasks@main

# A label only vouches for the commits it was applied to. Drop it
# on the first attempt of every new push — and on reopen, since
# labels survive closure and pushes to a closed PR emit no
# synchronize events, so a reopened head may have moved under a
# stale label — but never on re-runs, which replay the original
# payload and would otherwise strip a label applied after the
# push (re-running the job is exactly how a fresh label takes
# effect; see Enforce). Fork PRs run with a read-only token that
# can't edit labels; Enforce discounts the label on these same
# events, so skipping the dismissal there costs nothing.
- env:
GH_TOKEN: ${{ github.token }}
LABEL: ${{ inputs.override-label }}
PR: ${{ github.event.pull_request.number }}
if: >-
contains(fromJSON('["synchronize", "reopened"]'), github.event.action) &&
github.run_attempt == '1' &&
contains(github.event.pull_request.labels.*.name, inputs.override-label) &&
!github.event.pull_request.head.repo.fork
name: Dismiss stale regression acceptance
run: gh pr edit "$PR" --remove-label "$LABEL"

# Produce this commit's SARIF logs. Turbo dedupes against the CI
# workflow's lint run via the shared remote cache when configured.
- name: Lint HEAD
run: >-
${{ inputs.gtb-from-source && 'pnpm run gtb' || 'pnpm exec gtb' }}
turbo run lint

# The checkout must be the merge ref (a two-parent commit):
# re-checking-out the PR head would make HEAD^1 the head's own
# parent, silently gating against the wrong commit. The guard
# fails the step with an explicit error rather than proceeding.
- id: merge-base
name: Resolve the merge base (merge ref first parent)
run: |
git rev-parse --verify HEAD^2 > /dev/null 2>&1 || {
echo '::error::Checkout is not the PR merge ref; gating' \
'against a PR-head parent would use the wrong baseline'
exit 1
}
echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"

# Restore baselines produced by an earlier run against the same
# merge base; the compare's stamp check skips the worktree lint on
# a hit. Exact key only — a near-miss baseline is a wrong baseline
# (the stamp check would reject it anyway).
- uses: actions/cache@v6
with:
key: lint-baseline-${{ steps.merge-base.outputs.sha }}
path: |
**/dist/sarif/base/**
!**/node_modules/**
dist/sarif/base.ref

- continue-on-error: true
env:
BASE_SHA: ${{ steps.merge-base.outputs.sha }}
id: compare
name: Compare lint results against the merge base
run: |
${{ inputs.gtb-from-source && 'pnpm run gtb' || 'pnpm exec gtb' }} \
sarif compare --base-sha "$BASE_SHA" 2>&1 \
| tee lint-regression.txt
exit "${PIPESTATUS[0]}"

# The findings land in the run's job summary rather than a PR
# comment: a summary is pinned to its run, so it can't go stale
# after a green re-run or collide with other bot comments, and it
# needs no write token, so fork PRs get the full report too.
- env:
LABEL: ${{ inputs.override-label }}
if: steps.compare.outcome == 'failure'
name: Report new violations in the job summary
run: |
{
echo '### New lint violations relative to the merge base'
echo
echo '```text'
cat lint-regression.txt
echo '```'
echo
echo 'Fix the new findings or suppress them in-source with' \
'a reason (suppressed findings are exempt). For a bulk' \
'introduction (e.g. a dependency bump adding a rule), a' \
"maintainer can apply the \`$LABEL\`" \
'label and re-run this job to accept them.'
} >> "$GITHUB_STEP_SUMMARY"

# Run-but-neutral override: the label lets the check pass, but the
# job summary above still records what was accepted. Labels are
# read live (not from the event payload) so the accept flow is:
# apply the label, then re-run this job — a re-run replays the
# original payload, which predates the label. The label doesn't
# count on a first-attempt synchronize or reopened run, where the
# dismissal step just dropped it (or would have, for forks).
# GitHub has no per-label permission rules — anyone with triage
# can apply any label — so the label is honored only when its most
# recent applier holds `override-role` or higher; anything
# unresolvable fails closed. Known residual: cancelling a run
# before its dismissal step and then re-running preserves a stale
# label, but that needs write access and deliberate timing from
# someone already looking at the PR.
- env:
EVENT_ACTION: ${{ github.event.action }}
GH_TOKEN: ${{ github.token }}
LABEL: ${{ inputs.override-label }}
OVERRIDE_ROLE: ${{ inputs.override-role }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
if: steps.compare.outcome == 'failure'
name: Enforce
run: |
if { [ "$GITHUB_RUN_ATTEMPT" -gt 1 ] ||
{ [ "$EVENT_ACTION" != 'synchronize' ] &&
[ "$EVENT_ACTION" != 'reopened' ]; }; } &&
gh pr view "$PR" --json labels --jq '.labels[].name' |
grep -Fxq "$LABEL"; then
applier=$(gh api "repos/$REPO/issues/$PR/events" --paginate \
--jq '.[] | select(.event == "labeled")
| "\(.label.name)\t\(.actor.login)"' |
awk -F '\t' -v l="$LABEL" '$1 == l { a = $2 } END { print a }' \
|| true)
role=$(gh api "repos/$REPO/collaborators/$applier/permission" \
--jq '.role_name' || true)
case "$OVERRIDE_ROLE" in
admin) allowed='admin' ;;
write) allowed='admin maintain write' ;;
*) allowed='admin maintain' ;;
esac
case " $allowed " in
*" $role "*)
echo "'$LABEL' applied by $applier ($role); accepting" \
'the new findings'
exit 0
;;
*)
echo "'$LABEL' applied by ${applier:-unknown}" \
"(${role:-unknown}) — needs $OVERRIDE_ROLE or higher;" \
'ignoring the label'
;;
esac
fi
exit 1

name: Lint Regression

# Reusable: fail a PR only on lint violations that are new relative to
# its merge base. `gtb sarif compare --base` lints the merge base
# in a temporary git worktree to build the SARIF baseline, then
# `sarif-multitool` classifies each HEAD result as new or matched —
# pre-existing (accepted) violations never block. The routine response
# to new findings is fixing or suppressing them in-source (suppressed
# findings are gate-exempt); for bulk introductions (e.g. a dependency
# bump adding a rule) the override label turns a failing compare into a
# pass for one merge — apply it, then re-run the failed job — while the
# failing run's job summary keeps the accepted violations on record.
# The label counts only when applied by `override-role` or higher,
# since GitHub has no per-label permission rules of its own. The
# cross-PR baseline cache is seeded by the `lint-baseline.yml` reusable
# on default-branch pushes.
on:
workflow_call:
inputs:
gtb-from-source:
default: false
description: >-
Run gtb from the workspace source (`pnpm run gtb`) instead of
the installed bin (`pnpm exec gtb`). Set true only by the repo
that vendors `@gtbuchanan/cli` as a workspace package (tooling
itself). Consumers leave it false.
type: boolean
override-label:
default: accepted-lint-regression
description: >-
PR label that accepts the new violations for one merge (takes
effect on a job re-run; dismissed automatically on every new
push; honored only when applied by `override-role` or
higher). The escape hatch for bulk introductions — the
routine path is fixing or suppressing findings in-source.
type: string
override-role:
default: maintain
description: >-
Minimum repository role (`write`, `maintain`, or `admin`)
the override label's applier must hold for it to count.
type: string

# `pull-requests: write` covers the label dismissal; everything else
# reads.
permissions:
contents: read
pull-requests: write
6 changes: 6 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ jobs:
with:
config-file: .github/dependency-review-config.yml

lint-regression:
name: Lint
uses: ./.github/workflows/lint-regression.yml
with:
gtb-from-source: true

pre-commit:
name: Pre-Commit
# This repo's eslint step shells out to `pnpm exec eslint`, so hk
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ jobs:
secrets: inherit
uses: ./.github/workflows/ci.yml

# Seed the cross-PR lint baseline cache from this commit's own lint
# output. `needs: ci` orders it after the CI gate; with a turbo
# remote cache configured its lint also becomes a cache hit.
lint-baseline:
name: Lint
needs: ci
uses: ./.github/workflows/lint-baseline.yml
with:
gtb-from-source: true

name: Release

# The main-branch pipeline: re-run CI as a gate, then CD (version +
Expand Down
Loading