-
Notifications
You must be signed in to change notification settings - Fork 59
340 lines (301 loc) · 11.9 KB
/
Copy pathrepro-bug.yml
File metadata and controls
340 lines (301 loc) · 11.9 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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
name: Bug Reproduction Video
on:
pull_request:
types:
- labeled
workflow_dispatch:
inputs:
issue_number:
description: Issue or PR number to reproduce against
required: true
type: number
require_bug_label:
description: Fail run if target does not have bug label
required: true
default: true
type: boolean
target_ref:
description: Git ref to checkout when issue_number is not a PR
required: false
default: main
type: string
permissions:
contents: read
issues: write
actions: read
pull-requests: read
defaults:
run:
shell: bash
env:
DOTNET_VERSION: 10.0.x
DOTNET_CONFIGURATION: Release
# Set one or both.
SOLUTION_FILE: DevBetterWeb.slnx
PROJECT_FILE: src/DevBetterWeb.Web/DevBetterWeb.Web.csproj
APP_URL: http://127.0.0.1:5010
APP_HEALTH_PATH: /health
APP_STARTUP_COMMAND: dotnet run --project src/DevBetterWeb.Web/DevBetterWeb.Web.csproj --configuration Release --no-build --no-launch-profile
jobs:
reproduce-bug:
# Only run on:
# - workflow_dispatch (maintainer-initiated), OR
# - pull_request labeled 'bug' from a branch in THIS repo (never a fork —
# we build & run PR code, so external PRs would be a code-execution risk).
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.label.name == 'bug' &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: repro-bug-${{ github.event.pull_request.number || github.event.inputs.issue_number }}
cancel-in-progress: true
steps:
- name: Resolve target context
id: target
# Pinned to v7.0.1 commit SHA for supply-chain safety.
uses: actions/github-script@v9
with:
script: |
const isDispatch = context.eventName === 'workflow_dispatch';
const inputs = context.payload.inputs || {};
// Truncate untrusted text so it can't blow past env-var size limits
// or overwhelm downstream consumers.
const MAX_LEN = 8000;
const truncate = (s) => {
const v = (s || '').toString();
return v.length > MAX_LEN ? v.slice(0, MAX_LEN) + '\n…[truncated]' : v;
};
let issueNumber;
let issueTitle;
let issueBody;
let issueUrl;
let checkoutRef = context.ref;
let labels = [];
if (isDispatch) {
issueNumber = Number(inputs.issue_number);
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
core.setFailed('workflow_dispatch requires a valid issue_number input.');
return;
}
const issueResponse = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
const issue = issueResponse.data;
issueTitle = issue.title || '';
issueBody = issue.body || '';
issueUrl = issue.html_url;
labels = (issue.labels || [])
.map((label) => typeof label === 'string' ? label : label.name)
.filter(Boolean);
if (issue.pull_request) {
const prResponse = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: issueNumber
});
// Refuse to build code from a fork via dispatch as well.
const expected = `${context.repo.owner}/${context.repo.repo}`;
if (prResponse.data.head.repo.full_name !== expected) {
core.setFailed(`PR #${issueNumber} is from a fork; refusing to check out and build untrusted code.`);
return;
}
checkoutRef = prResponse.data.head.sha;
} else {
checkoutRef = inputs.target_ref || 'main';
}
const requireBugLabel = String(inputs.require_bug_label).toLowerCase() === 'true';
if (requireBugLabel && !labels.includes('bug')) {
core.setFailed(`Issue/PR #${issueNumber} is missing bug label.`);
return;
}
} else {
const pr = context.payload.pull_request;
issueNumber = pr.number;
issueTitle = pr.title || '';
issueBody = pr.body || '';
issueUrl = pr.html_url;
checkoutRef = pr.head.sha;
labels = (pr.labels || []).map((label) => label.name).filter(Boolean);
if (!labels.includes('bug')) {
core.setFailed(`PR #${issueNumber} is missing bug label.`);
return;
}
}
core.exportVariable('ISSUE_NUMBER', String(issueNumber));
core.exportVariable('ISSUE_TITLE', truncate(issueTitle));
core.exportVariable('ISSUE_BODY', truncate(issueBody));
core.exportVariable('ISSUE_URL', issueUrl);
core.exportVariable('REPO_NAME', context.repo.owner + '/' + context.repo.repo);
core.setOutput('checkout_ref', checkoutRef);
core.setOutput('issue_number', String(issueNumber));
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.target.outputs.checkout_ref }}
# Don't leave the GITHUB_TOKEN in .git/config — we only need to read.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-v1
- name: Initialize Playwright project
timeout-minutes: 5
run: |
set -euo pipefail
npm init -y >/dev/null
npm install --no-fund --no-audit @playwright/test wait-on
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then
npx playwright install-deps
else
npx playwright install --with-deps
fi
- name: Restore .NET dependencies
timeout-minutes: 10
run: |
set -euo pipefail
if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then
dotnet restore "${SOLUTION_FILE}"
elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then
dotnet restore "${PROJECT_FILE}"
else
dotnet restore
fi
- name: Build application
timeout-minutes: 15
run: |
set -euo pipefail
if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then
dotnet build "${SOLUTION_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore
elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then
dotnet build "${PROJECT_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore
else
dotnet build --configuration "${DOTNET_CONFIGURATION}" --no-restore
fi
- name: Start application
run: |
set -euo pipefail
export ASPNETCORE_URLS="${APP_URL}"
export ASPNETCORE_ENVIRONMENT="Development"
# setsid puts the app in its own process group so cleanup can kill
# the whole tree (dotnet often spawns child processes).
setsid bash -c "${APP_STARTUP_COMMAND} > app.log 2>&1" &
echo $! > app.pid
echo "Started app with PGID $(cat app.pid)"
- name: Wait for application startup
timeout-minutes: 3
run: |
set -uo pipefail
# Prefer a real health endpoint over a bare HTTP check; wait-on with
# http-get:// considers only 2xx as ready.
TARGET="http-get://127.0.0.1:5010${APP_HEALTH_PATH}"
if ! npx wait-on "${TARGET}" --timeout 120000 --interval 1000; then
echo "::warning::Health endpoint not ready at ${TARGET}; falling back to base URL."
if ! npx wait-on "http-get://127.0.0.1:5010/" --timeout 30000 --interval 1000; then
echo "::error::Application failed to become ready."
if [ -f app.pid ]; then
echo "Process status:"
ps -p "$(cat app.pid)" -f || true
fi
echo "Last 200 lines of app.log:"
tail -n 200 app.log || true
exit 1
fi
fi
- name: Generate reproduction script
run: |
set -euo pipefail
mkdir -p tests
# NOTE: ISSUE_TITLE / ISSUE_BODY are UNTRUSTED user input.
# Only log them — never pass them to page.goto(), eval(), or shell.
cat << 'EOF' > tests/repro.spec.js
const { test } = require('@playwright/test');
test.use({
video: 'on',
trace: 'on',
screenshot: 'only-on-failure'
});
test('attempt reproduction from github pull request', async ({ page }) => {
await page.goto(process.env.APP_URL, {
waitUntil: 'networkidle'
});
await page.waitForTimeout(5000);
console.log('Issue Title:');
console.log(process.env.ISSUE_TITLE);
console.log('Issue Body:');
console.log(process.env.ISSUE_BODY);
});
EOF
- name: Run Playwright reproduction
timeout-minutes: 10
run: npx playwright test --reporter=list
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: repro-artifacts-${{ steps.target.outputs.issue_number }}
path: |
app.log
test-results
playwright-report
if-no-files-found: warn
retention-days: 14
- name: Comment on issue or PR with artifact link
if: always() && env.ISSUE_NUMBER != ''
uses: actions/github-script@v9
with:
script: |
const runUrl =
`https://github.com/${context.repo.owner}/${context.repo.repo}` +
`/actions/runs/${context.runId}`;
const body = [
'🤖 Automated reproduction attempt completed.',
'',
'## Target',
`#${process.env.ISSUE_NUMBER}`,
'',
'## Configuration',
`- Solution: \`${process.env.SOLUTION_FILE || '(not set)'}\``,
`- Project: \`${process.env.PROJECT_FILE || '(not set)'}\``,
`- App URL: \`${process.env.APP_URL}\``,
'',
'## Artifacts',
'- 🎥 Playwright video',
'- 📷 Screenshots',
'- 🧭 Trace files',
'- 📄 Console output',
'',
'Download artifacts from the workflow run:',
'',
runUrl
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(process.env.ISSUE_NUMBER),
body
});
- name: Cleanup app
if: always()
run: |
if [ -f app.pid ]; then
PGID="$(cat app.pid)"
# Kill the entire process group started via setsid.
kill -TERM -"${PGID}" 2>/dev/null || true
sleep 2
kill -KILL -"${PGID}" 2>/dev/null || true
fi