Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
db42b15
Add SARIF lint regression compare (gtb lint:eslint:compare)
gtbuchanan Jul 18, 2026
317b765
Switch lint gating to SARIF regression ratchet
gtbuchanan Jul 19, 2026
93072d6
Generalize SARIF gate: tool-agnostic layout, caching, seeding
gtbuchanan Jul 20, 2026
6720268
Resolve CI baseline from merge ref parent, not history
gtbuchanan Jul 21, 2026
c1d2ca9
Honor override label via live reads and a job re-run
gtbuchanan Jul 21, 2026
c18c431
Gate the override label by applier role
gtbuchanan Jul 21, 2026
8be015b
Discount the override label on reopened PRs
gtbuchanan Jul 21, 2026
51d337e
Unblock CI for the sarif-multitool and formatter deps
gtbuchanan Jul 21, 2026
bda6308
Pass the override label to scripts via env, not inline
gtbuchanan Jul 21, 2026
b62f11d
Cover the SARIF deps and formatter I/O directly
gtbuchanan Jul 21, 2026
9660437
Trim the SARIF ratchet changeset to a release note
gtbuchanan Jul 31, 2026
f0d6c81
Split the lint baseline seed into its own reusable workflow
gtbuchanan Jul 31, 2026
a187198
Report lint regressions in the job summary; drop seed mode
gtbuchanan Jul 31, 2026
d6f045c
Document SARIF rationale; hoist the internal ruleId fallback
gtbuchanan Jul 31, 2026
94bc3ce
Move consumer lint gate docs into the README
gtbuchanan Jul 31, 2026
a4d9a91
Merge origin/main into issue-38-lint-regression-sarif
gtbuchanan Aug 1, 2026
f51aafc
Run lint workflows through turbo-run; dedupe label logic
gtbuchanan Aug 1, 2026
7ecc609
Simplify the SARIF lint ratchet after review
gtbuchanan Aug 1, 2026
5424d4c
Switch lint:eslint to the ESLint API; share a stylish report
gtbuchanan Aug 3, 2026
31e1819
Demo the lint ratchet: tighten tsconfig max-lines
gtbuchanan Aug 3, 2026
54ca973
Revert "Demo the lint ratchet: tighten tsconfig max-lines"
gtbuchanan Aug 3, 2026
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
16 changes: 16 additions & 0 deletions .changeset/sarif-lint-regression-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@gtbuchanan/cli': minor
---

Rework lint enforcement as a SARIF ratchet. Lint tasks are now
reporters: `lint:eslint` runs ESLint through its programmatic API
(`eslint` becomes an optional peer dependency), writing
`dist/sarif/eslint.sarif` and a stylish console report from one lint
run; it no longer fails on warnings (errors still fail) and accepts
only its supported argument surface (patterns, `--fix`,
`--ignore-pattern`). The new `gtb sarif compare` command fails only on
findings that are new relative to the merge base — reported in the
same stylish layout as lint output — 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
63 changes: 63 additions & 0 deletions .github/workflows/lint-baseline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
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

# Produce this commit's SARIF logs. `turbo-run` wires up the turbo
# remote cache, so the lint dedupes against the CI workflow's lint
# run of the same commit.
- uses: gtbuchanan/tooling/.github/actions/turbo-run@main
with:
task: lint

# `pnpm-tasks` restores node_modules for `gtb sarif baseline`, since
# turbo-run skips install on a full lint cache hit.
- uses: gtbuchanan/tooling/.github/actions/pnpm-tasks@main

- 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
228 changes: 228 additions & 0 deletions .github/workflows/lint-regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
---
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

# A label only vouches for the commits it was applied to, so it's
# stale on the first attempt of a synchronize run (a new push) or
# a reopened run (labels survive closure, and pushes to a closed
# PR emit no synchronize events, so a reopened head may have
# moved under the label). Re-runs replay the original payload but
# bump the attempt counter, so a label applied after the push
# stays fresh — re-running the job is exactly how it takes effect
# (see Enforce). Computed once so the dismissal below and the
# Enforce branch can't drift apart.
- env:
STALE: >-
${{ contains(fromJSON('["synchronize", "reopened"]'), github.event.action) &&
github.run_attempt == '1' }}
id: label
name: Classify override label freshness
run: echo "stale=$STALE" >> "$GITHUB_OUTPUT"

# Fork PRs run with a read-only token that can't edit labels;
# Enforce discounts a stale label anyway, so skipping the
# dismissal there costs nothing.
- env:
GH_TOKEN: ${{ github.token }}
LABEL: ${{ inputs.override-label }}
PR: ${{ github.event.pull_request.number }}
if: >-
steps.label.outputs.stale == 'true' &&
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-run` wires up the turbo
# remote cache, so the lint dedupes against the CI workflow's lint
# run of the same commit.
- uses: gtbuchanan/tooling/.github/actions/turbo-run@main
with:
task: lint

# `pnpm-tasks` restores node_modules for `gtb sarif compare`, since
# turbo-run skips install on a full lint cache hit.
- uses: gtbuchanan/tooling/.github/actions/pnpm-tasks@main

# 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'
# FORCE_COLOR makes the report carry ANSI codes for the log
# view; the summary's code fence renders them literally, so
# strip them here.
sed 's/\x1b\[[0-9;]*m//g' 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 when the freshness step classified it stale — the
# dismissal 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:
GH_TOKEN: ${{ github.token }}
LABEL: ${{ inputs.override-label }}
LABEL_STALE: ${{ steps.label.outputs.stale }}
OVERRIDE_ROLE: ${{ inputs.override-role }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
if: steps.compare.outcome == 'failure'
name: Enforce
run: |
if [ "$LABEL_STALE" != 'true' ] &&
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
Loading