Skip to content

Commit 4a2f00b

Browse files
committed
feat(tailwindcss-patch): externalize shard resolver and lint workflows
1 parent 8cc6dfe commit 4a2f00b

8 files changed

Lines changed: 477 additions & 191 deletions

File tree

.changeset/empty-donuts-serve.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"tailwindcss-patch": patch
3+
---
4+
5+
Improve CI template maintainability with a testable affected-shard resolver and workflow linting.
6+
7+
- extract affected-shard detection logic to `examples/github-actions/scripts/resolve-shards.mjs`
8+
- add unit tests covering resolver behavior and output contract
9+
- add `.github/workflows/workflow-lint.yml` to lint workflow templates and verify local template wiring
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Workflow Lint
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- ".github/workflows/**"
7+
- "packages/tailwindcss-patch/examples/github-actions/**"
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
lint-workflows:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 10
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v6
21+
22+
- name: Lint workflow files
23+
uses: rhysd/actionlint@v1
24+
with:
25+
args: >-
26+
-color
27+
.github/workflows/*.yml
28+
packages/tailwindcss-patch/examples/github-actions/*.yml
29+
30+
- name: Validate example workflow wiring
31+
shell: bash
32+
run: |
33+
set -euo pipefail
34+
action_path='packages/tailwindcss-patch/examples/github-actions/actions/validate-migration-report/action.yml'
35+
script_path='packages/tailwindcss-patch/examples/github-actions/scripts/resolve-shards.mjs'
36+
37+
test -f "$action_path"
38+
test -f "$script_path"
39+
40+
rg -n "uses: ./packages/tailwindcss-patch/examples/github-actions/actions/validate-migration-report" \
41+
packages/tailwindcss-patch/examples/github-actions/validate-migration-report.yml \
42+
packages/tailwindcss-patch/examples/github-actions/validate-migration-report-matrix.yml \
43+
packages/tailwindcss-patch/examples/github-actions/validate-migration-report-affected.yml

packages/tailwindcss-patch/MIGRATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Migration mapping:
9999
- A matrix-based monorepo GitHub Actions example is available at `packages/tailwindcss-patch/examples/github-actions/validate-migration-report-matrix.yml`.
100100
- An affected-shards monorepo GitHub Actions example (PR diff aware) is available at `packages/tailwindcss-patch/examples/github-actions/validate-migration-report-affected.yml`.
101101
- The three templates share a local composite action: `packages/tailwindcss-patch/examples/github-actions/actions/validate-migration-report/action.yml`.
102+
- The affected-shards template resolver is externalized at `packages/tailwindcss-patch/examples/github-actions/scripts/resolve-shards.mjs` for testability.
102103
- The affected-shards template supports repo-level shard config via `.tw-patch/ci-shards.json` (example: `packages/tailwindcss-patch/examples/github-actions/ci-shards.example.json`).
103104
- Migration report tooling now has public exports from package entry (`migrateConfigFiles`, `restoreConfigFiles`, report constants/types) and published JSON schema subpaths: `tailwindcss-patch/migration-report.schema.json`, `tailwindcss-patch/restore-result.schema.json`, `tailwindcss-patch/validate-result.schema.json`.
104105
- Commands resolve configuration from `tailwindcss-patch.config.ts` via `@tailwindcss-mangle/config`. Existing configuration files continue to work without changes.

packages/tailwindcss-patch/README-cn.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ GitHub Actions 模板:
143143
- monorepo 矩阵分片(`root/apps/packages`):`packages/tailwindcss-patch/examples/github-actions/validate-migration-report-matrix.yml`
144144
- monorepo 按变更分片(基于 PR diff):`packages/tailwindcss-patch/examples/github-actions/validate-migration-report-affected.yml`
145145
- 三个模板共用的 composite action:`packages/tailwindcss-patch/examples/github-actions/actions/validate-migration-report/action.yml`
146+
- 按变更分片解析脚本:`packages/tailwindcss-patch/examples/github-actions/scripts/resolve-shards.mjs`
146147

147148
对于按变更分片模板,可在仓库中添加 `.tw-patch/ci-shards.json` 自定义分片匹配规则和全量触发规则。
148149
示例配置见 `packages/tailwindcss-patch/examples/github-actions/ci-shards.example.json`

packages/tailwindcss-patch/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ GitHub Actions templates:
200200
- monorepo matrix shards (`root/apps/packages`): `packages/tailwindcss-patch/examples/github-actions/validate-migration-report-matrix.yml`
201201
- monorepo affected shards (PR diff-aware): `packages/tailwindcss-patch/examples/github-actions/validate-migration-report-affected.yml`
202202
- shared composite action (used by all templates): `packages/tailwindcss-patch/examples/github-actions/actions/validate-migration-report/action.yml`
203+
- affected-shard resolver script: `packages/tailwindcss-patch/examples/github-actions/scripts/resolve-shards.mjs`
203204

204205
For the affected-shards template, you can customize shard matching and run-all triggers by adding `.tw-patch/ci-shards.json` in your repo.
205206
A sample config is available at `packages/tailwindcss-patch/examples/github-actions/ci-shards.example.json`.
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import fs from 'node:fs'
2+
import { execFileSync } from 'node:child_process'
3+
import { pathToFileURL } from 'node:url'
4+
5+
export const DEFAULT_SHARD_CONFIG = {
6+
runAllPatterns: [
7+
'packages/tailwindcss-patch/**',
8+
'pnpm-lock.yaml',
9+
'pnpm-workspace.yaml',
10+
'package.json',
11+
'turbo.json',
12+
'.npmrc',
13+
'.nvmrc',
14+
'.node-version',
15+
'.github/workflows/**',
16+
],
17+
shards: [
18+
{
19+
name: 'root',
20+
reportFile: '.tw-patch/migrate-report-root.json',
21+
matchPatterns: ['tailwindcss-patch.config.*', 'tailwindcss-mangle.config.*'],
22+
},
23+
{
24+
name: 'apps',
25+
reportFile: '.tw-patch/migrate-report-apps.json',
26+
matchPatterns: ['apps/**'],
27+
},
28+
{
29+
name: 'packages',
30+
reportFile: '.tw-patch/migrate-report-packages.json',
31+
matchPatterns: ['packages/**'],
32+
},
33+
],
34+
}
35+
36+
function cloneDefaultConfig() {
37+
return JSON.parse(JSON.stringify(DEFAULT_SHARD_CONFIG))
38+
}
39+
40+
export function normalizeShardConfig(value) {
41+
const fallback = cloneDefaultConfig()
42+
if (!value || typeof value !== 'object') {
43+
return fallback
44+
}
45+
46+
const runAllPatterns = Array.isArray(value.runAllPatterns)
47+
? value.runAllPatterns.filter((p) => typeof p === 'string' && p.length > 0)
48+
: fallback.runAllPatterns
49+
50+
const shards = Array.isArray(value.shards)
51+
? value.shards
52+
.filter((s) => s && typeof s === 'object')
53+
.map((s) => ({
54+
name: typeof s.name === 'string' ? s.name : '',
55+
reportFile: typeof s.reportFile === 'string' ? s.reportFile : '',
56+
matchPatterns: Array.isArray(s.matchPatterns)
57+
? s.matchPatterns.filter((p) => typeof p === 'string' && p.length > 0)
58+
: [],
59+
}))
60+
.filter((s) => s.name && s.reportFile && s.matchPatterns.length > 0)
61+
: fallback.shards
62+
63+
if (shards.length === 0) {
64+
return fallback
65+
}
66+
67+
return {
68+
runAllPatterns,
69+
shards,
70+
}
71+
}
72+
73+
export function loadShardConfig(configPath, io = fs, logger = console) {
74+
if (!io.existsSync(configPath)) {
75+
return cloneDefaultConfig()
76+
}
77+
try {
78+
const parsed = JSON.parse(io.readFileSync(configPath, 'utf8'))
79+
logger.log(`::notice::Loaded shard config from ${configPath}`)
80+
return normalizeShardConfig(parsed)
81+
} catch (error) {
82+
const message = error instanceof Error ? error.message : String(error)
83+
logger.log(`::warning::Invalid ${configPath}, fallback to defaults: ${message}`)
84+
return cloneDefaultConfig()
85+
}
86+
}
87+
88+
export function globToRegExp(pattern) {
89+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
90+
const withGlob = escaped
91+
.replace(/\*\*/g, '__TW_PATCH_GLOBSTAR__')
92+
.replace(/\*/g, '[^/]*')
93+
.replace(/__TW_PATCH_GLOBSTAR__/g, '.*')
94+
.replace(/\?/g, '.')
95+
return new RegExp(`^${withGlob}$`)
96+
}
97+
98+
export function matchesAny(filePath, patterns) {
99+
return patterns.some((pattern) => globToRegExp(pattern).test(filePath))
100+
}
101+
102+
function toMatrix(config, names) {
103+
const selected = new Set(names)
104+
return config.shards
105+
.filter((shard) => selected.has(shard.name))
106+
.map((shard) => ({ name: shard.name, report_file: shard.reportFile }))
107+
}
108+
109+
function payload(config, names, hasChanges) {
110+
return {
111+
hasChanges,
112+
shards: names,
113+
matrix: toMatrix(config, names),
114+
}
115+
}
116+
117+
export function computeShardSelection(changedFiles, config) {
118+
const files = changedFiles.map((file) => file.trim()).filter(Boolean)
119+
if (files.length === 0) {
120+
return payload(config, [], false)
121+
}
122+
123+
const allNames = config.shards.map((s) => s.name)
124+
if (files.some((file) => matchesAny(file, config.runAllPatterns))) {
125+
return payload(config, allNames, true)
126+
}
127+
128+
const matchedNames = config.shards
129+
.filter((shard) => files.some((file) => matchesAny(file, shard.matchPatterns)))
130+
.map((shard) => shard.name)
131+
132+
if (matchedNames.length === 0) {
133+
return payload(config, [], false)
134+
}
135+
136+
return payload(config, matchedNames, true)
137+
}
138+
139+
export function resolveShardsFromGit(params) {
140+
const {
141+
config,
142+
eventName,
143+
baseSha,
144+
baseRef,
145+
headSha = 'HEAD',
146+
runGit,
147+
hasCommit,
148+
logger = console,
149+
} = params
150+
151+
const allNames = config.shards.map((s) => s.name)
152+
if (eventName === 'workflow_dispatch') {
153+
logger.log('::notice::workflow_dispatch => run all shards')
154+
return payload(config, allNames, true)
155+
}
156+
157+
let base = baseSha || ''
158+
if (!hasCommit(base) && baseRef) {
159+
try {
160+
base = runGit(['merge-base', 'HEAD', `origin/${baseRef}`])
161+
logger.log(`::notice::Fallback base resolved by merge-base: ${base}`)
162+
} catch {
163+
logger.log('::warning::Unable to resolve PR base by merge-base, run all shards')
164+
return payload(config, allNames, true)
165+
}
166+
}
167+
168+
if (!base) {
169+
logger.log('::warning::Missing base sha, run all shards')
170+
return payload(config, allNames, true)
171+
}
172+
173+
let changedFiles = []
174+
try {
175+
changedFiles = runGit(['diff', '--name-only', base, headSha])
176+
.split('\n')
177+
.map((line) => line.trim())
178+
.filter(Boolean)
179+
} catch (error) {
180+
const message = error instanceof Error ? error.message : String(error)
181+
logger.log(`::warning::git diff failed (${message}), run all shards`)
182+
return payload(config, allNames, true)
183+
}
184+
185+
const result = computeShardSelection(changedFiles, config)
186+
if (!result.hasChanges) {
187+
logger.log('::notice::No affected shards for current diff')
188+
}
189+
return result
190+
}
191+
192+
export function toGithubOutputLines(result) {
193+
return [
194+
`has_changes=${result.hasChanges ? 'true' : 'false'}`,
195+
`shards=${result.shards.length > 0 ? result.shards.join(',') : 'none'}`,
196+
`matrix=${JSON.stringify({ shard: result.matrix })}`,
197+
].join('\n')
198+
}
199+
200+
function defaultRunGit(args) {
201+
return execFileSync('git', args, { encoding: 'utf8' }).trim()
202+
}
203+
204+
function defaultHasCommit(sha) {
205+
if (!sha) {
206+
return false
207+
}
208+
try {
209+
execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' })
210+
return true
211+
} catch {
212+
return false
213+
}
214+
}
215+
216+
export function main(env = process.env, io = fs, logger = console) {
217+
const configPath = env.CI_SHARDS_CONFIG_PATH || '.tw-patch/ci-shards.json'
218+
const config = loadShardConfig(configPath, io, logger)
219+
220+
const result = resolveShardsFromGit({
221+
config,
222+
eventName: env.EVENT_NAME || '',
223+
baseSha: env.BASE_SHA || '',
224+
baseRef: env.BASE_REF || '',
225+
headSha: env.HEAD_SHA || 'HEAD',
226+
runGit: defaultRunGit,
227+
hasCommit: defaultHasCommit,
228+
logger,
229+
})
230+
231+
const lines = toGithubOutputLines(result)
232+
if (env.GITHUB_OUTPUT) {
233+
io.appendFileSync(env.GITHUB_OUTPUT, `${lines}\n`)
234+
} else {
235+
logger.log(lines)
236+
}
237+
return result
238+
}
239+
240+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
241+
try {
242+
main()
243+
} catch (error) {
244+
const message = error instanceof Error ? error.stack || error.message : String(error)
245+
console.error(`::error::resolve-shards failed: ${message}`)
246+
process.exit(1)
247+
}
248+
}

0 commit comments

Comments
 (0)