Skip to content

Commit 41ecdc6

Browse files
committed
feat(tailwindcss-patch): harden affected-shards workflow detection
1 parent fefc69a commit 41ecdc6

6 files changed

Lines changed: 245 additions & 56 deletions

File tree

.changeset/odd-rules-accept.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"tailwindcss-patch": patch
3+
---
4+
5+
Improve the affected-shards GitHub Actions template for monorepo validation.
6+
7+
- add optional repo-level shard config support via `.tw-patch/ci-shards.json`
8+
- add base SHA fallback logic (`merge-base`) and safer run-all fallbacks when diff resolution fails
9+
- expand default run-all triggers for root/tooling changes
10+
- add `ci-shards.example.json` and document customization in README/MIGRATION

packages/tailwindcss-patch/MIGRATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Migration mapping:
9898
- A ready-to-use GitHub Actions example is available at `packages/tailwindcss-patch/examples/github-actions/validate-migration-report.yml`.
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`.
101+
- 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`).
101102
- 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`.
102103
- Commands resolve configuration from `tailwindcss-patch.config.ts` via `@tailwindcss-mangle/config`. Existing configuration files continue to work without changes.
103104

packages/tailwindcss-patch/README-cn.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ 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

146+
对于按变更分片模板,可在仓库中添加 `.tw-patch/ci-shards.json` 自定义分片匹配规则和全量触发规则。
147+
示例配置见 `packages/tailwindcss-patch/examples/github-actions/ci-shards.example.json`
148+
146149
### `tokens` 常用参数
147150

148151
| 参数 | 说明 |

packages/tailwindcss-patch/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ 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

203+
For the affected-shards template, you can customize shard matching and run-all triggers by adding `.tw-patch/ci-shards.json` in your repo.
204+
A sample config is available at `packages/tailwindcss-patch/examples/github-actions/ci-shards.example.json`.
205+
203206
### Token report options
204207

205208
| Flag | Description |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"runAllPatterns": [
3+
"packages/tailwindcss-patch/**",
4+
"pnpm-lock.yaml",
5+
"pnpm-workspace.yaml",
6+
"package.json",
7+
"turbo.json",
8+
".npmrc",
9+
".nvmrc",
10+
".node-version",
11+
".github/workflows/**"
12+
],
13+
"shards": [
14+
{
15+
"name": "root",
16+
"reportFile": ".tw-patch/migrate-report-root.json",
17+
"matchPatterns": [
18+
"tailwindcss-patch.config.*",
19+
"tailwindcss-mangle.config.*"
20+
]
21+
},
22+
{
23+
"name": "apps",
24+
"reportFile": ".tw-patch/migrate-report-apps.json",
25+
"matchPatterns": [
26+
"apps/**"
27+
]
28+
},
29+
{
30+
"name": "packages",
31+
"reportFile": ".tw-patch/migrate-report-packages.json",
32+
"matchPatterns": [
33+
"packages/**"
34+
]
35+
}
36+
]
37+
}

packages/tailwindcss-patch/examples/github-actions/validate-migration-report-affected.yml

Lines changed: 191 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -25,69 +25,204 @@ jobs:
2525
- name: Resolve affected shards
2626
id: resolve
2727
shell: bash
28+
env:
29+
EVENT_NAME: ${{ github.event_name }}
30+
BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
31+
HEAD_SHA: ${{ github.sha }}
32+
BASE_REF: ${{ github.base_ref }}
2833
run: |
2934
set -euo pipefail
3035
31-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
32-
shards=(root apps packages)
33-
else
34-
base_sha="${{ github.event.pull_request.base.sha }}"
35-
head_sha="${{ github.sha }}"
36-
mapfile -t changed_files < <(git diff --name-only "$base_sha" "$head_sha")
37-
38-
run_all=false
39-
root=false
40-
apps=false
41-
packages=false
42-
43-
for file in "${changed_files[@]}"; do
44-
[ -z "$file" ] && continue
45-
46-
case "$file" in
47-
apps/*) apps=true ;;
48-
packages/*) packages=true ;;
49-
tailwindcss-patch.config.*|tailwindcss-mangle.config.*) root=true ;;
50-
esac
51-
52-
case "$file" in
53-
packages/tailwindcss-patch/*|pnpm-lock.yaml|pnpm-workspace.yaml)
54-
run_all=true
55-
;;
56-
esac
57-
done
58-
59-
if [ "$run_all" = true ]; then
60-
shards=(root apps packages)
61-
else
62-
shards=()
63-
[ "$root" = true ] && shards+=(root)
64-
[ "$apps" = true ] && shards+=(apps)
65-
[ "$packages" = true ] && shards+=(packages)
66-
fi
67-
fi
36+
node - <<'NODE'
37+
const fs = require('node:fs')
38+
const { execFileSync } = require('node:child_process')
6839
69-
if [ "${#shards[@]}" -eq 0 ]; then
70-
echo "has_changes=false" >> "$GITHUB_OUTPUT"
71-
echo "shards=none" >> "$GITHUB_OUTPUT"
72-
echo 'matrix={"shard":[]}' >> "$GITHUB_OUTPUT"
73-
exit 0
74-
fi
40+
const defaultConfig = {
41+
runAllPatterns: [
42+
'packages/tailwindcss-patch/**',
43+
'pnpm-lock.yaml',
44+
'pnpm-workspace.yaml',
45+
'package.json',
46+
'turbo.json',
47+
'.npmrc',
48+
'.nvmrc',
49+
'.node-version',
50+
'.github/workflows/**',
51+
],
52+
shards: [
53+
{
54+
name: 'root',
55+
reportFile: '.tw-patch/migrate-report-root.json',
56+
matchPatterns: ['tailwindcss-patch.config.*', 'tailwindcss-mangle.config.*'],
57+
},
58+
{ name: 'apps', reportFile: '.tw-patch/migrate-report-apps.json', matchPatterns: ['apps/**'] },
59+
{
60+
name: 'packages',
61+
reportFile: '.tw-patch/migrate-report-packages.json',
62+
matchPatterns: ['packages/**'],
63+
},
64+
],
65+
}
66+
67+
const configPath = '.tw-patch/ci-shards.json'
7568
76-
shard_csv="$(IFS=,; echo "${shards[*]}")"
77-
matrix_json="$(SHARDS="$shard_csv" node -e '
78-
const names = (process.env.SHARDS || "").split(",").filter(Boolean)
79-
const reportMap = {
80-
root: ".tw-patch/migrate-report-root.json",
81-
apps: ".tw-patch/migrate-report-apps.json",
82-
packages: ".tw-patch/migrate-report-packages.json",
69+
const normalizeConfig = (value) => {
70+
if (!value || typeof value !== 'object') return defaultConfig
71+
const runAllPatterns = Array.isArray(value.runAllPatterns)
72+
? value.runAllPatterns.filter((p) => typeof p === 'string' && p.length > 0)
73+
: defaultConfig.runAllPatterns
74+
const shards = Array.isArray(value.shards)
75+
? value.shards
76+
.filter((s) => s && typeof s === 'object')
77+
.map((s) => ({
78+
name: typeof s.name === 'string' ? s.name : '',
79+
reportFile: typeof s.reportFile === 'string' ? s.reportFile : '',
80+
matchPatterns: Array.isArray(s.matchPatterns)
81+
? s.matchPatterns.filter((p) => typeof p === 'string' && p.length > 0)
82+
: [],
83+
}))
84+
.filter((s) => s.name && s.reportFile && s.matchPatterns.length > 0)
85+
: defaultConfig.shards
86+
87+
if (shards.length === 0) return defaultConfig
88+
return { runAllPatterns, shards }
89+
}
90+
91+
const loadConfig = () => {
92+
if (!fs.existsSync(configPath)) return defaultConfig
93+
try {
94+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'))
95+
const config = normalizeConfig(parsed)
96+
console.log(`::notice::Loaded shard config from ${configPath}`)
97+
return config
98+
} catch (error) {
99+
console.log(`::warning::Invalid ${configPath}, fallback to defaults: ${error.message}`)
100+
return defaultConfig
83101
}
84-
const shard = names.map(name => ({ name, report_file: reportMap[name] }))
85-
process.stdout.write(JSON.stringify({ shard }))
86-
')"
102+
}
103+
104+
const globToRegExp = (pattern) => {
105+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
106+
const withGlob = escaped
107+
.replace(/\*\*/g, '__TW_PATCH_GLOBSTAR__')
108+
.replace(/\*/g, '[^/]*')
109+
.replace(/__TW_PATCH_GLOBSTAR__/g, '.*')
110+
.replace(/\?/g, '.')
111+
return new RegExp(`^${withGlob}$`)
112+
}
113+
114+
const matchesAny = (value, patterns) => patterns.some((pattern) => globToRegExp(pattern).test(value))
115+
116+
const runGit = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
117+
118+
const hasCommit = (sha) => {
119+
if (!sha) return false
120+
try {
121+
execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' })
122+
return true
123+
} catch {
124+
return false
125+
}
126+
}
127+
128+
const config = loadConfig()
129+
const allShardNames = config.shards.map((s) => s.name)
130+
const eventName = process.env.EVENT_NAME || ''
131+
const headSha = process.env.HEAD_SHA || 'HEAD'
132+
const baseRef = process.env.BASE_REF || ''
133+
const outputPath = process.env.GITHUB_OUTPUT
134+
135+
const writeOutputs = (payload) => {
136+
const lines = [
137+
`has_changes=${payload.hasChanges ? 'true' : 'false'}`,
138+
`shards=${payload.shards.length > 0 ? payload.shards.join(',') : 'none'}`,
139+
`matrix=${JSON.stringify({ shard: payload.matrix })}`,
140+
]
141+
fs.appendFileSync(outputPath, `${lines.join('\n')}\n`)
142+
}
143+
144+
if (eventName === 'workflow_dispatch') {
145+
console.log('::notice::workflow_dispatch => run all shards')
146+
writeOutputs({
147+
hasChanges: true,
148+
shards: allShardNames,
149+
matrix: config.shards.map((s) => ({ name: s.name, report_file: s.reportFile })),
150+
})
151+
process.exit(0)
152+
}
153+
154+
let baseSha = process.env.BASE_SHA || ''
155+
if (!hasCommit(baseSha) && baseRef) {
156+
try {
157+
baseSha = runGit(['merge-base', 'HEAD', `origin/${baseRef}`])
158+
console.log(`::notice::Fallback base resolved by merge-base: ${baseSha}`)
159+
} catch {
160+
console.log('::warning::Unable to resolve PR base by merge-base, run all shards')
161+
writeOutputs({
162+
hasChanges: true,
163+
shards: allShardNames,
164+
matrix: config.shards.map((s) => ({ name: s.name, report_file: s.reportFile })),
165+
})
166+
process.exit(0)
167+
}
168+
}
169+
170+
if (!baseSha) {
171+
console.log('::warning::Missing base sha, run all shards')
172+
writeOutputs({
173+
hasChanges: true,
174+
shards: allShardNames,
175+
matrix: config.shards.map((s) => ({ name: s.name, report_file: s.reportFile })),
176+
})
177+
process.exit(0)
178+
}
179+
180+
let changedFiles = []
181+
try {
182+
changedFiles = runGit(['diff', '--name-only', baseSha, headSha])
183+
.split('\n')
184+
.map((f) => f.trim())
185+
.filter(Boolean)
186+
} catch (error) {
187+
console.log(`::warning::git diff failed (${error.message}), run all shards`)
188+
writeOutputs({
189+
hasChanges: true,
190+
shards: allShardNames,
191+
matrix: config.shards.map((s) => ({ name: s.name, report_file: s.reportFile })),
192+
})
193+
process.exit(0)
194+
}
195+
196+
if (changedFiles.length === 0) {
197+
console.log('::notice::No changed files in PR diff')
198+
writeOutputs({ hasChanges: false, shards: [], matrix: [] })
199+
process.exit(0)
200+
}
201+
202+
if (changedFiles.some((file) => matchesAny(file, config.runAllPatterns))) {
203+
console.log('::notice::Matched runAllPatterns => run all shards')
204+
writeOutputs({
205+
hasChanges: true,
206+
shards: allShardNames,
207+
matrix: config.shards.map((s) => ({ name: s.name, report_file: s.reportFile })),
208+
})
209+
process.exit(0)
210+
}
211+
212+
const matched = config.shards.filter((shard) => changedFiles.some((file) => matchesAny(file, shard.matchPatterns)))
213+
214+
if (matched.length === 0) {
215+
console.log('::notice::Changes do not affect configured shard patterns')
216+
writeOutputs({ hasChanges: false, shards: [], matrix: [] })
217+
process.exit(0)
218+
}
87219
88-
echo "has_changes=true" >> "$GITHUB_OUTPUT"
89-
echo "shards=$shard_csv" >> "$GITHUB_OUTPUT"
90-
echo "matrix=$matrix_json" >> "$GITHUB_OUTPUT"
220+
writeOutputs({
221+
hasChanges: true,
222+
shards: matched.map((s) => s.name),
223+
matrix: matched.map((s) => ({ name: s.name, report_file: s.reportFile })),
224+
})
225+
NODE
91226
92227
validate-migration-report:
93228
needs: detect-shards

0 commit comments

Comments
 (0)