Skip to content

Commit 3d79a0a

Browse files
authored
Merge branch 'main' into kyle/fix-test-container-image
2 parents d0088fd + 9ab241b commit 3d79a0a

4 files changed

Lines changed: 350 additions & 10 deletions

File tree

.github/workflows/codeql-analysis.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ name: CodeQL Analysis
22

33
# Security scanning should run on all branches and on schedule
44
on:
5-
push:
6-
branches:
7-
- '**' # All branches - security issues can be on any branch
8-
paths-ignore:
9-
- 'docs/**'
10-
pull_request:
11-
paths-ignore:
12-
- 'docs/**'
5+
# push:
6+
# branches:
7+
# - '**' # All branches - security issues can be on any branch
8+
# paths-ignore:
9+
# - 'docs/**'
10+
# pull_request:
11+
# paths-ignore:
12+
# - 'docs/**'
1313
workflow_dispatch: # Manual trigger for on-demand security scans
1414
schedule:
1515
- cron: '0 8 1 * *' # Monthly at 8 AM UTC - catches new vulnerabilities

.github/workflows/repro-bug.yml

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
name: Bug Reproduction Video
2+
3+
on:
4+
pull_request:
5+
types:
6+
- labeled
7+
workflow_dispatch:
8+
inputs:
9+
issue_number:
10+
description: Issue or PR number to reproduce against
11+
required: true
12+
type: number
13+
require_bug_label:
14+
description: Fail run if target does not have bug label
15+
required: true
16+
default: true
17+
type: boolean
18+
target_ref:
19+
description: Git ref to checkout when issue_number is not a PR
20+
required: false
21+
default: main
22+
type: string
23+
24+
permissions:
25+
contents: read
26+
issues: write
27+
actions: read
28+
pull-requests: read
29+
30+
defaults:
31+
run:
32+
shell: bash
33+
34+
env:
35+
DOTNET_VERSION: 10.0.x
36+
DOTNET_CONFIGURATION: Release
37+
38+
# Set one or both.
39+
SOLUTION_FILE: Clean.Architecture.slnx
40+
PROJECT_FILE: src/Clean.Architecture.Web/Clean.Architecture.Web.csproj
41+
42+
APP_URL: http://127.0.0.1:5010
43+
APP_HEALTH_PATH: /health
44+
APP_STARTUP_COMMAND: dotnet run --project src/Clean.Architecture.Web/Clean.Architecture.Web.csproj --configuration Release --no-build --no-launch-profile
45+
46+
jobs:
47+
reproduce-bug:
48+
# Only run on:
49+
# - workflow_dispatch (maintainer-initiated), OR
50+
# - pull_request labeled 'bug' from a branch in THIS repo (never a fork —
51+
# we build & run PR code, so external PRs would be a code-execution risk).
52+
if: >
53+
github.event_name == 'workflow_dispatch' ||
54+
(github.event_name == 'pull_request' &&
55+
github.event.label.name == 'bug' &&
56+
github.event.pull_request.head.repo.full_name == github.repository)
57+
runs-on: ubuntu-latest
58+
timeout-minutes: 30
59+
concurrency:
60+
group: repro-bug-${{ github.event.pull_request.number || github.event.inputs.issue_number }}
61+
cancel-in-progress: true
62+
63+
steps:
64+
- name: Resolve target context
65+
id: target
66+
# Pinned to v7.0.1 commit SHA for supply-chain safety.
67+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
68+
with:
69+
script: |
70+
const isDispatch = context.eventName === 'workflow_dispatch';
71+
const inputs = context.payload.inputs || {};
72+
73+
// Truncate untrusted text so it can't blow past env-var size limits
74+
// or overwhelm downstream consumers.
75+
const MAX_LEN = 8000;
76+
const truncate = (s) => {
77+
const v = (s || '').toString();
78+
return v.length > MAX_LEN ? v.slice(0, MAX_LEN) + '\n…[truncated]' : v;
79+
};
80+
81+
let issueNumber;
82+
let issueTitle;
83+
let issueBody;
84+
let issueUrl;
85+
let checkoutRef = context.ref;
86+
let labels = [];
87+
88+
if (isDispatch) {
89+
issueNumber = Number(inputs.issue_number);
90+
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
91+
core.setFailed('workflow_dispatch requires a valid issue_number input.');
92+
return;
93+
}
94+
95+
const issueResponse = await github.rest.issues.get({
96+
owner: context.repo.owner,
97+
repo: context.repo.repo,
98+
issue_number: issueNumber
99+
});
100+
101+
const issue = issueResponse.data;
102+
issueTitle = issue.title || '';
103+
issueBody = issue.body || '';
104+
issueUrl = issue.html_url;
105+
labels = (issue.labels || [])
106+
.map((label) => typeof label === 'string' ? label : label.name)
107+
.filter(Boolean);
108+
109+
if (issue.pull_request) {
110+
const prResponse = await github.rest.pulls.get({
111+
owner: context.repo.owner,
112+
repo: context.repo.repo,
113+
pull_number: issueNumber
114+
});
115+
// Refuse to build code from a fork via dispatch as well.
116+
const expected = `${context.repo.owner}/${context.repo.repo}`;
117+
if (prResponse.data.head.repo.full_name !== expected) {
118+
core.setFailed(`PR #${issueNumber} is from a fork; refusing to check out and build untrusted code.`);
119+
return;
120+
}
121+
checkoutRef = prResponse.data.head.sha;
122+
} else {
123+
checkoutRef = inputs.target_ref || 'main';
124+
}
125+
126+
const requireBugLabel = String(inputs.require_bug_label).toLowerCase() === 'true';
127+
if (requireBugLabel && !labels.includes('bug')) {
128+
core.setFailed(`Issue/PR #${issueNumber} is missing bug label.`);
129+
return;
130+
}
131+
} else {
132+
const pr = context.payload.pull_request;
133+
issueNumber = pr.number;
134+
issueTitle = pr.title || '';
135+
issueBody = pr.body || '';
136+
issueUrl = pr.html_url;
137+
checkoutRef = pr.head.sha;
138+
labels = (pr.labels || []).map((label) => label.name).filter(Boolean);
139+
140+
if (!labels.includes('bug')) {
141+
core.setFailed(`PR #${issueNumber} is missing bug label.`);
142+
return;
143+
}
144+
}
145+
146+
core.exportVariable('ISSUE_NUMBER', String(issueNumber));
147+
core.exportVariable('ISSUE_TITLE', truncate(issueTitle));
148+
core.exportVariable('ISSUE_BODY', truncate(issueBody));
149+
core.exportVariable('ISSUE_URL', issueUrl);
150+
core.exportVariable('REPO_NAME', context.repo.owner + '/' + context.repo.repo);
151+
core.setOutput('checkout_ref', checkoutRef);
152+
core.setOutput('issue_number', String(issueNumber));
153+
154+
- name: Checkout repository
155+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
156+
with:
157+
ref: ${{ steps.target.outputs.checkout_ref }}
158+
# Don't leave the GITHUB_TOKEN in .git/config — we only need to read.
159+
persist-credentials: false
160+
161+
- name: Setup Node.js
162+
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
163+
with:
164+
node-version: 22
165+
166+
- name: Setup .NET
167+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
168+
with:
169+
dotnet-version: ${{ env.DOTNET_VERSION }}
170+
171+
- name: Cache Playwright browsers
172+
id: playwright-cache
173+
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
174+
with:
175+
path: ~/.cache/ms-playwright
176+
key: playwright-${{ runner.os }}-v1
177+
178+
- name: Initialize Playwright project
179+
timeout-minutes: 5
180+
run: |
181+
set -euo pipefail
182+
npm init -y >/dev/null
183+
npm install --no-fund --no-audit @playwright/test wait-on
184+
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then
185+
npx playwright install-deps
186+
else
187+
npx playwright install --with-deps
188+
fi
189+
190+
- name: Restore .NET dependencies
191+
timeout-minutes: 10
192+
run: |
193+
set -euo pipefail
194+
if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then
195+
dotnet restore "${SOLUTION_FILE}"
196+
elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then
197+
dotnet restore "${PROJECT_FILE}"
198+
else
199+
dotnet restore
200+
fi
201+
202+
- name: Build application
203+
timeout-minutes: 15
204+
run: |
205+
set -euo pipefail
206+
if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then
207+
dotnet build "${SOLUTION_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore
208+
elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then
209+
dotnet build "${PROJECT_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore
210+
else
211+
dotnet build --configuration "${DOTNET_CONFIGURATION}" --no-restore
212+
fi
213+
214+
- name: Start application
215+
run: |
216+
set -euo pipefail
217+
export ASPNETCORE_URLS="${APP_URL}"
218+
export ASPNETCORE_ENVIRONMENT="Development"
219+
# setsid puts the app in its own process group so cleanup can kill
220+
# the whole tree (dotnet often spawns child processes).
221+
setsid bash -c "${APP_STARTUP_COMMAND} > app.log 2>&1" &
222+
echo $! > app.pid
223+
echo "Started app with PGID $(cat app.pid)"
224+
225+
- name: Wait for application startup
226+
timeout-minutes: 3
227+
run: |
228+
set -uo pipefail
229+
# Prefer a real health endpoint over a bare HTTP check; wait-on with
230+
# http-get:// considers only 2xx as ready.
231+
TARGET="http-get://127.0.0.1:5010${APP_HEALTH_PATH}"
232+
if ! npx wait-on "${TARGET}" --timeout 120000 --interval 1000; then
233+
echo "::warning::Health endpoint not ready at ${TARGET}; falling back to base URL."
234+
if ! npx wait-on "http-get://127.0.0.1:5010/" --timeout 30000 --interval 1000; then
235+
echo "::error::Application failed to become ready."
236+
if [ -f app.pid ]; then
237+
echo "Process status:"
238+
ps -p "$(cat app.pid)" -f || true
239+
fi
240+
echo "Last 200 lines of app.log:"
241+
tail -n 200 app.log || true
242+
exit 1
243+
fi
244+
fi
245+
246+
- name: Generate reproduction script
247+
run: |
248+
set -euo pipefail
249+
mkdir -p tests
250+
251+
# NOTE: ISSUE_TITLE / ISSUE_BODY are UNTRUSTED user input.
252+
# Only log them — never pass them to page.goto(), eval(), or shell.
253+
cat << 'EOF' > tests/repro.spec.js
254+
const { test } = require('@playwright/test');
255+
256+
test.use({
257+
video: 'on',
258+
trace: 'on',
259+
screenshot: 'only-on-failure'
260+
});
261+
262+
test('attempt reproduction from github pull request', async ({ page }) => {
263+
await page.goto(process.env.APP_URL, {
264+
waitUntil: 'networkidle'
265+
});
266+
267+
await page.waitForTimeout(5000);
268+
269+
console.log('Issue Title:');
270+
console.log(process.env.ISSUE_TITLE);
271+
272+
console.log('Issue Body:');
273+
console.log(process.env.ISSUE_BODY);
274+
});
275+
EOF
276+
277+
- name: Run Playwright reproduction
278+
timeout-minutes: 10
279+
run: npx playwright test --reporter=list
280+
281+
- name: Upload Playwright artifacts
282+
if: always()
283+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
284+
with:
285+
name: repro-artifacts-${{ steps.target.outputs.issue_number }}
286+
path: |
287+
app.log
288+
test-results
289+
playwright-report
290+
if-no-files-found: warn
291+
retention-days: 14
292+
293+
- name: Comment on issue or PR with artifact link
294+
if: always() && env.ISSUE_NUMBER != ''
295+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
296+
with:
297+
script: |
298+
const runUrl =
299+
`https://github.com/${context.repo.owner}/${context.repo.repo}` +
300+
`/actions/runs/${context.runId}`;
301+
302+
const body = [
303+
'🤖 Automated reproduction attempt completed.',
304+
'',
305+
'## Target',
306+
`#${process.env.ISSUE_NUMBER}`,
307+
'',
308+
'## Configuration',
309+
`- Solution: \`${process.env.SOLUTION_FILE || '(not set)'}\``,
310+
`- Project: \`${process.env.PROJECT_FILE || '(not set)'}\``,
311+
`- App URL: \`${process.env.APP_URL}\``,
312+
'',
313+
'## Artifacts',
314+
'- 🎥 Playwright video',
315+
'- 📷 Screenshots',
316+
'- 🧭 Trace files',
317+
'- 📄 Console output',
318+
'',
319+
'Download artifacts from the workflow run:',
320+
'',
321+
runUrl
322+
].join('\n');
323+
324+
await github.rest.issues.createComment({
325+
owner: context.repo.owner,
326+
repo: context.repo.repo,
327+
issue_number: Number(process.env.ISSUE_NUMBER),
328+
body
329+
});
330+
331+
- name: Cleanup app
332+
if: always()
333+
run: |
334+
if [ -f app.pid ]; then
335+
PGID="$(cat app.pid)"
336+
# Kill the entire process group started via setsid.
337+
kill -TERM -"${PGID}" 2>/dev/null || true
338+
sleep 2
339+
kill -KILL -"${PGID}" 2>/dev/null || true
340+
fi

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
<PackageVersion Include="Testcontainers.MsSql" Version="4.11.0" />
4343
<PackageVersion Include="xunit.v3" Version="3.2.2" />
4444
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
45-
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.3.0" />
45+
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.3.5" />
4646
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
4747
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
4848
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />

src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Aspire.AppHost.Sdk/13.3.0">
1+
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>

0 commit comments

Comments
 (0)