Skip to content
Merged
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
16 changes: 12 additions & 4 deletions .claude/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ A monorepo for Nuxt modules and plugins development.
dph-nuxt-stuff/
├── packages/ # Publishable Nuxt modules and plugins
├── .github/
│ └── workflows/ # CI/CD: ci, autofix, release, semantic-pr
│ ├── actions/setup/ # Shared checkout, toolchain, cache, and install steps
│ └── workflows/ # CI/CD: ci, publish, semantic-pull-requests
├── turbo.json # Turborepo task pipeline
├── package.json # Root workspace (pnpm)
├── tsconfig.json # Base TypeScript config (strictest)
Expand Down Expand Up @@ -62,7 +63,7 @@ pnpm run check
# Detect unused exports/deps
pnpm run knip

# Record consumer-visible Release intent
# Record consumer-visible Release intent (a native pnpm command, not a script)
pnpm change

# Inspect and preview all pending Independent package releases
Expand Down Expand Up @@ -121,6 +122,13 @@ before publishing missing package versions through npm trusted publishing.

## CI

- **ci.yml** — typecheck + format + lint on push/PR
- **release.yml** — triggered manually or on `v*` tag push
- **ci.yml** — on push to `main` and on PRs. Runs the pieces of `pnpm check` as
parallel jobs: publishing contract, format, lint, typecheck, knip, test, and
build + publint. Lint failures land as inline PR annotations; test results
land as a `Vitest` check with the junit summary.
- **publish.yml** — manual dispatch from `main` only. Reruns `pnpm check`, then
publishes missing package versions through npm trusted publishing.
- **semantic-pull-requests.yml** — validates PR title follows conventional commits

`pnpm check` remains the single local gate and the publish gate. CI splits it
only so each failure reports on its own.
19 changes: 19 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Setup
description: Check out, install the pinned toolchain, restore caches, and install dependencies.

runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
shell: bash
107 changes: 107 additions & 0 deletions .github/scripts/pr-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Renders the Markdown fragments CI posts as one sticky pull request comment.
*
* pr-report.ts jobs <needs-json> table of every job's result
* pr-report.ts lint <oxlint.json> <eslint.txt>
* pr-report.ts junit <junit.xml>...
*/
import { readFileSync } from 'node:fs'
import process from 'node:process'

interface JobOutcome {
result: 'success' | 'failure' | 'cancelled' | 'skipped'
}

interface OxlintReport {
diagnostics: { severity: 'error' | 'warning' | string }[]
}

const badge = {
success: '✅ passed',
failure: '❌ failed',
cancelled: '⚪ cancelled',
skipped: '⏭️ skipped',
} satisfies Record<JobOutcome['result'], string>

function jobsTable(needsJson: string): string {
const needs: Record<string, JobOutcome> = JSON.parse(needsJson)
const rows = Object.entries(needs).map(
([job, outcome]) => `| ${job} | ${badge[outcome.result]} |`
)
return ['| Job | Result |', '| --- | --- |', ...rows].join('\n')
}

function lintSummary(oxlintPath: string, eslintPath: string): string {
const oxlint: OxlintReport = JSON.parse(readFileSync(oxlintPath, 'utf8'))
const oxlintErrors = oxlint.diagnostics.filter(
(diagnostic) => diagnostic.severity === 'error'
).length
const oxlintWarnings = oxlint.diagnostics.length - oxlintErrors

// ESLint's stylish formatter ends with "✖ N problems (E errors, W warnings)"
// and prints nothing at all when the tree is clean.
const eslintOutput = readFileSync(eslintPath, 'utf8')
const eslintTotals = /\((\d+) errors?, (\d+) warnings?\)/.exec(eslintOutput)
const eslintErrors = Number(eslintTotals?.[1] ?? 0)
const eslintWarnings = Number(eslintTotals?.[2] ?? 0)

return [
'| Linter | Errors | Warnings |',
'| --- | ---: | ---: |',
`| Oxlint | ${oxlintErrors} | ${oxlintWarnings} |`,
`| ESLint | ${eslintErrors} | ${eslintWarnings} |`,
].join('\n')
}

interface SuiteTotals {
tests: number
failures: number
errors: number
skipped: number
}

function junitSummary(paths: string[]): string {
const rows = paths.map((path) => {
const totals = readTotals(readFileSync(path, 'utf8'))
const workspace = path.replace(/\/test-results\/junit\.xml$/, '')
const passed =
totals.tests - totals.failures - totals.errors - totals.skipped
const failed = totals.failures + totals.errors
const status = failed > 0 ? '❌' : '✅'
return `| ${status} ${workspace} | ${passed} | ${failed} | ${totals.skipped} |`
})
return [
'| Workspace | Passed | Failed | Skipped |',
'| --- | ---: | ---: | ---: |',
...rows,
].join('\n')
}

/** Vitest writes one `<testsuites>` root carrying the file totals. */
function readTotals(xml: string): SuiteTotals {
const root = /<testsuites\b[^>]*>/.exec(xml)?.[0] ?? ''
const attribute = (name: string): number =>
Number(new RegExp(`\\b${name}="(\\d+)"`).exec(root)?.[1] ?? 0)
return {
tests: attribute('tests'),
failures: attribute('failures'),
errors: attribute('errors'),
skipped: attribute('skipped'),
}
}

const [mode, ...rest] = process.argv.slice(2)
switch (mode) {
case 'jobs':
console.log(jobsTable(rest[0] ?? '{}'))
break
case 'lint':
console.log(lintSummary(rest[0] ?? '', rest[1] ?? ''))
break
case 'junit':
console.log(junitSummary(rest))
break
default:
console.error(`unknown mode: ${mode ?? '(none)'}`)
process.exitCode = 1
}
177 changes: 170 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,179 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
TURBO_CACHE_DIR: .turbo

jobs:
check:
name: Check
contract:
name: Publishing contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm publishing-contract

format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm format

lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: mkdir -p reports
# `--format=github` emits workflow commands, which surface as inline
# annotations on the pull request diff.
- name: Oxlint
run: pnpm exec oxlint . --format=github
# setup-node registers the eslint-stylish problem matcher, so the default
# output is annotated the same way. Runs even when Oxlint fails so both
# reports land in one round trip.
- name: ESLint
if: ${{ !cancelled() }}
shell: bash
run: pnpm exec eslint . 2>&1 | tee reports/eslint.txt
- name: Summarize for the pull request
if: ${{ always() }}
run: |
pnpm exec oxlint . --format=json > reports/oxlint.json || true
node .github/scripts/pr-report.ts lint reports/oxlint.json reports/eslint.txt > reports/lint.md
- uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: report-lint
path: reports/lint.md

typecheck:
name: Typecheck
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm typecheck

knip:
name: Dead code
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
# knip resolves the generated `.nuxt/tsconfig.json` files.
- run: pnpm build
- run: pnpm knip

test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm test -- --reporter=default --reporter=junit --outputFile=test-results/junit.xml
- name: Publish the Vitest check
uses: dorny/test-reporter@v2
if: ${{ !cancelled() }}
with:
name: Vitest
# Comma-separated, and non-recursive on purpose: `**` follows pnpm's
# workspace symlinks under node_modules into a loop.
path: packages/*/test-results/junit.xml,release/*/test-results/junit.xml,scaffolder/test-results/junit.xml,tools/*/test-results/junit.xml
reporter: java-junit
fail-on-error: false
use-actions-summary: false
- name: Summarize for the pull request
if: ${{ always() }}
shell: bash
run: |
mkdir -p reports
node .github/scripts/pr-report.ts junit packages/*/test-results/junit.xml release/*/test-results/junit.xml scaffolder/test-results/junit.xml tools/*/test-results/junit.xml > reports/test.md
- uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: report-test
path: reports/test.md
- name: Upload raw test results
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: test-results
path: |
packages/*/test-results/junit.xml
release/*/test-results/junit.xml
scaffolder/test-results/junit.xml
tools/*/test-results/junit.xml
if-no-files-found: ignore

build:
name: Build and publint
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm build
- run: pnpm publint

report:
name: Pull request report
if: ${{ always() && github.event_name == 'pull_request' }}
needs: [contract, format, lint, typecheck, knip, test, build]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 26
cache: pnpm
- run: pnpm install
- run: pnpm run check
node-version-file: .nvmrc
- uses: actions/download-artifact@v4
with:
pattern: report-*
path: reports
merge-multiple: true
- name: Compose the report
env:
NEEDS: ${{ toJSON(needs) }}
run: |
{
echo "## CI report"
echo
node .github/scripts/pr-report.ts jobs "$NEEDS"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
echo
echo "### Lint"
echo
cat reports/lint.md 2>/dev/null || echo "_No lint summary was produced._"
echo
echo "### Tests"
echo
cat reports/test.md 2>/dev/null || echo "_No test summary was produced._"
echo
echo "[Workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
} > report.md
- uses: marocchino/sticky-pull-request-comment@v2
with:
header: ci-report
path: report.md
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ dist/
**/.nuxt/
**/.nitro/
**/.output/

# Vitest junit output for the CI reporter
test-results/
reports/
4 changes: 1 addition & 3 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
#shamefully-hoist=true
#shell-emulator=true
#strict-peer-dependencies=false
engine-strict=true
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/*
26
3 changes: 3 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export default antfuConfig(
// Oxfmt owns formatting.
stylistic: false,
formatters: false,
// Mirror the Oxlint and Oxfmt ignores: the vendored plugin keeps upstream
// conventions, and the agent notes are not source.
ignores: ['tools/oxlint/anti-slop/**', '.claude/**'],
},

{
Expand Down
14 changes: 14 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export default {
},

workspaces: {
// Run by CI through `node`, imported by nothing.
'.': {
entry: ['.github/scripts/*.ts'],
},

// The plugin is loaded by path from `oxlint.config.ts`, which knip cannot
// follow; its test reaches `index.ts`, and the fixture is linted by path.
'tools/oxlint': {
entry: ['anti-slop/test/fixtures/*.ts'],
// The smoke test spawns the `oxlint` binary it resolves by manifest path,
// which is not an import knip can follow.
ignoreDependencies: ['oxlint'],
},

'packages/*': nuxtModuleWorkspace,

'packages/*/playground': playgroundWorkspace,
Expand Down
Loading
Loading