-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcompute-affected-apps.mjs
More file actions
286 lines (264 loc) · 9.64 KB
/
Copy pathcompute-affected-apps.mjs
File metadata and controls
286 lines (264 loc) · 9.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// Computes which apps in apps/* are affected by a PR's changes to
// packages/, pnpm-workspace.yaml, turbo.json, or the root package.json.
// Renders a Markdown body for the sticky PR comment.
//
// The sticky comment ALWAYS posts. When no apps are affected, the comment
// explains why so the PR has a record instead of silent silence.
//
// Inputs (env):
// BASE_SHA PR base commit (github.event.pull_request.base.sha)
// HEAD_SHA PR head commit (github.event.pull_request.head.sha)
// GITHUB_REPOSITORY owner/name, used to build dispatch links
// GITHUB_OUTPUT path to the step's output file
// GITHUB_STEP_SUMMARY path to the job summary file (optional, GH-provided)
//
// Outputs (to $GITHUB_OUTPUT):
// markdown=<multiline> Markdown body for the sticky comment
import { execSync } from "node:child_process"
import { appendFileSync, existsSync, readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
const baseSha = process.env.BASE_SHA
const headSha = process.env.HEAD_SHA
const repo = process.env.GITHUB_REPOSITORY
const ghOutput = process.env.GITHUB_OUTPUT
const ghSummary = process.env.GITHUB_STEP_SUMMARY
if (!baseSha || !headSha || !repo || !ghOutput) {
console.error(
"Missing required env. Need BASE_SHA, HEAD_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT",
)
process.exit(1)
}
const diff = execSync(`git diff --name-only ${baseSha}...${headSha}`, {
encoding: "utf8",
}).trim()
const changedPackages = new Set()
const broadImpact = new Set()
for (const file of diff.split("\n")) {
if (!file) continue
const m = file.match(/^packages\/([^/]+)\//)
if (m) {
changedPackages.add(m[1])
continue
}
if (file === "pnpm-workspace.yaml") broadImpact.add("pnpm-workspace.yaml")
else if (file === "turbo.json") broadImpact.add("turbo.json")
else if (file === "package.json") broadImpact.add("package.json")
}
console.log(
`Changed packages: ${[...changedPackages].sort().join(", ") || "(none)"}`,
)
console.log(
`Broad-impact files: ${[...broadImpact].sort().join(", ") || "(none)"}`,
)
// Which apps this monorepo deploys is discovered from the filesystem: every
// apps/<name>/ directory containing a package.json is a tracked app. Do NOT
// key this off .github/CODEOWNERS: its rules get commented out whenever the
// team pauses code-owner review, and this comment must keep posting through
// that. CODEOWNERS only enriches the Owner column when its /apps/<name>/
// entries are active.
let appNames = []
try {
appNames = readdirSync("apps")
.filter((entry) => existsSync(join("apps", entry, "package.json")))
.sort()
} catch {
// Missing apps/ falls through to the fail-closed check below.
}
if (appNames.length === 0) {
console.error(
"No apps found under apps/ (expected apps/<name>/package.json). Incomplete checkout?",
)
process.exit(1)
}
// Owner enrichment, best effort: a CODEOWNERS line like
// `/apps/main/ @user1 @user2` assigns owners for the Owner column. A missing
// file or zero active entries is a valid, deliberate state, never an error.
const appOwners = {}
let codeowners = ""
try {
codeowners = readFileSync(".github/CODEOWNERS", "utf8")
} catch {
// CODEOWNERS absent: every app renders as unassigned.
}
for (const rawLine of codeowners.split("\n")) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) continue
const parts = line.split(/\s+/)
const pattern = parts[0]
const owners = parts.slice(1)
const m = pattern.match(/^\/apps\/([^/]+)\/$/)
if (m && owners.length) appOwners[m[1]] = owners
}
// Drift guard: when CODEOWNERS is active (at least one /apps/<name>/ entry),
// partial coverage or dead entries are maintenance gaps. Warn so it shows up
// as a workflow annotation, but don't fail. Zero entries means ownership
// tracking is paused on purpose; note it once without warning noise.
const ownerEntries = Object.keys(appOwners)
const unownedApps = appNames.filter((app) => !(app in appOwners))
const deadOwnerEntries = ownerEntries.filter((app) => !appNames.includes(app))
if (ownerEntries.length === 0) {
console.log(
"CODEOWNERS has no active /apps/<name>/ entries (code-owner review paused?). Owner column will show unassigned.",
)
} else {
if (unownedApps.length > 0) {
console.log(
`::warning title=CODEOWNERS drift::apps/ on disk has ${unownedApps.join(", ")} with no /apps/<name>/ entry in .github/CODEOWNERS. Still tracked, but shown as unassigned in the package-impact comment.`,
)
}
if (deadOwnerEntries.length > 0) {
console.log(
`::warning title=CODEOWNERS drift::.github/CODEOWNERS lists ${deadOwnerEntries.join(", ")} but no matching apps/<name>/package.json exists. The entry is dead.`,
)
}
}
const appDeps = {}
for (const app of appNames) {
let pkg
try {
pkg = JSON.parse(readFileSync(join("apps", app, "package.json"), "utf8"))
} catch {
continue
}
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
appDeps[app] = new Set(
Object.keys(all)
.filter((d) => d.startsWith("@repo/"))
.map((d) => d.slice("@repo/".length)),
)
}
const affected = new Set()
if (broadImpact.size > 0) {
for (const app of appNames) affected.add(app)
} else {
for (const app of appNames) {
for (const pkg of changedPackages) {
if (appDeps[app]?.has(pkg)) {
affected.add(app)
break
}
}
}
}
const repoUrl = `https://github.com/${repo}`
const workflowUrl = `${repoUrl}/actions/workflows/deploy-amplify.yml`
let markdown
let summaryLine
if (affected.size === 0) {
const lines = []
lines.push("## Package change impact: no apps flagged")
lines.push("")
if (changedPackages.size === 0 && broadImpact.size === 0) {
lines.push(
"No `packages/**` or broad-impact files were detected in this PR's diff against the base branch.",
)
lines.push("")
lines.push(
"This usually means the workflow's path filter matched a file that the script does not classify (likely a future addition under one of the watched paths). If you expected an app list here, ping the deploy maintainers.",
)
summaryLine = `Skipped (no packages/** or broad-impact files in diff)`
} else {
const list = [...changedPackages]
.sort()
.map((p) => `\`@repo/${p}\``)
.join(", ")
lines.push(
`Changed packages: ${list || "(none)"}. None of the apps in this monorepo depend on these packages, so no app needs a redeploy.`,
)
lines.push("")
lines.push(
"If you have just introduced a new dependency that should be visible to one of the apps, add `@repo/<package>` to that app's `package.json` and re-run.",
)
summaryLine = `Skipped (no app depends on changed packages: ${[...changedPackages].sort().join(", ")})`
}
markdown = lines.join("\n")
} else {
const lines = []
lines.push("## Package change impact")
lines.push("")
lines.push(
"This PR changes one or more shared workspaces. The apps below depend on the changed code and may need a manual deploy after merge.",
)
lines.push("")
if (changedPackages.size > 0) {
const list = [...changedPackages]
.sort()
.map((p) => `\`@repo/${p}\``)
.join(", ")
lines.push(`**Changed packages**: ${list}`)
}
if (broadImpact.size > 0) {
const list = [...broadImpact]
.sort()
.map((f) => `\`${f}\``)
.join(", ")
lines.push(`**Broad-impact files changed**: ${list}`)
lines.push("")
lines.push(
"> A broad-impact file changed (workspace, `turbo.json`, or root `package.json`). All apps are listed because their builds could be affected.",
)
}
lines.push("")
lines.push("| App | Owner(s) | Dispatch |")
lines.push("|---|---|---|")
for (const app of [...affected].sort()) {
const owners = (appOwners[app] || []).join(" ") || "_unassigned_"
const link = `[Deploy ${app}](${workflowUrl})`
lines.push(`| ${app} | ${owners} | ${link} |`)
}
lines.push("")
lines.push(
"> No app will auto-deploy from this PR. When you are ready to ship the package change into your app, click your row's **Dispatch** link and run `Deploy to Amplify` with your app and branch `dev`.",
)
if ([...affected].some((app) => !(app in appOwners))) {
lines.push("")
lines.push(
"> Owners come from `/apps/<name>/` entries in `.github/CODEOWNERS` and show as _unassigned_ while those entries are commented out or missing.",
)
}
markdown = lines.join("\n")
summaryLine = `Flagged apps: ${[...affected].sort().join(", ")}`
}
const delimiter = `EOF_${Math.random().toString(36).slice(2)}`
appendFileSync(ghOutput, `markdown<<${delimiter}\n${markdown}\n${delimiter}\n`)
if (ghSummary) {
const summary = []
summary.push("### Package change impact")
summary.push("")
summary.push(`- Decision: **${summaryLine}**`)
if (changedPackages.size > 0) {
summary.push(
`- Changed packages: ${[...changedPackages]
.sort()
.map((p) => `\`@repo/${p}\``)
.join(", ")}`,
)
}
if (broadImpact.size > 0) {
summary.push(
`- Broad-impact files: ${[...broadImpact]
.sort()
.map((f) => `\`${f}\``)
.join(", ")}`,
)
}
if (ownerEntries.length === 0) {
summary.push(
"- Owners: no active CODEOWNERS entries (owner column shows unassigned)",
)
} else {
if (unownedApps.length > 0) {
summary.push(
`- :warning: CODEOWNERS drift: no owner entry for ${unownedApps.join(", ")}`,
)
}
if (deadOwnerEntries.length > 0) {
summary.push(
`- :warning: CODEOWNERS drift: dead entries for ${deadOwnerEntries.join(", ")}`,
)
}
}
appendFileSync(ghSummary, summary.join("\n") + "\n")
}
console.log(`Decision: ${summaryLine}`)
console.log("Comment markdown written to $GITHUB_OUTPUT")