Skip to content

Commit 15449a5

Browse files
authored
✨ Add post-merge Playwright verification against production (#4351)
* ✨ Add post-merge Playwright verification against production Runs targeted Playwright E2E tests against console.kubestellar.io after each merge to main. Tests are selected from a JSON mapping based on issue labels and changed file paths — no AI test generation needed. Workflow: 1. Waits for Netlify to deploy (polls app-build-id meta tag) 2. Extracts PR context: linked issue, labels, changed files 3. Maps to spec files via web/e2e/spec-map.json (smoke.spec.ts always runs) 4. Runs Chromium-only Playwright against production URL 5. Reports pass/fail on the merged PR On failure: - Reopens the original issue (if linked via Fixes #NNN) - Creates a regression issue with priority/critical - Assigns Copilot to auto-fix via existing agentic workflow Signed-off-by: Andrew Anderson <andy@clubanderson.com> * 🐛 Fix post-merge workflow bugs + sync security self-assessment Workflow fixes (addresses Copilot review on #4351): - Fix null PR number: use `// empty` jq fallback, guard against "null" string - Fix whitespace in spec dedup: use printf instead of indented heredoc append - Fix exit code masking: capture Playwright exit code, propagate to job status - Fix result propagation: add job-level outputs mapping - Fix PCRE regex: use -oE (ERE) instead of -oP (PCRE, not available on all CI) - Fix report logic: require both output=passed AND job=success Security self-assessment sync (addresses TAG-Security review on cncf/toc#2106): - Add scope statement: kubestellar/console only, not KubeStellar Core - Add deployment architecture diagram with security boundaries - Expand JWT details: HS256-only, WithValidMethods, revocation, cookie attrs - Explain RBAC inheritance: no privilege escalation, kubeconfig as-is - Add GitHub Security Advisories as primary disclosure channel Signed-off-by: Andrew Anderson <andy@clubanderson.com> --------- Signed-off-by: Andrew Anderson <andy@clubanderson.com>
1 parent 079e2d2 commit 15449a5

3 files changed

Lines changed: 445 additions & 18 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
name: Post-Merge Playwright Verification
2+
3+
# Runs targeted Playwright E2E tests against console.kubestellar.io after
4+
# each merge to main that touches web/ files. Tests are selected based on
5+
# issue labels and changed file paths — no AI test generation needed.
6+
7+
on:
8+
push:
9+
branches: [main]
10+
paths:
11+
- 'web/**'
12+
- 'pkg/**'
13+
14+
permissions:
15+
contents: read
16+
issues: write
17+
pull-requests: write
18+
19+
concurrency:
20+
group: post-merge-verify
21+
cancel-in-progress: true
22+
23+
env:
24+
NODE_VERSION: '20'
25+
PRODUCTION_URL: 'https://console.kubestellar.io'
26+
# Maximum time (seconds) to wait for Netlify deploy
27+
DEPLOY_WAIT_MAX_SECONDS: 300
28+
# Interval between deploy checks (seconds)
29+
DEPLOY_POLL_INTERVAL: 30
30+
31+
jobs:
32+
# ── Job 1: Wait for Netlify to deploy the merged code ──────────────
33+
wait-for-deploy:
34+
name: Wait for Netlify deploy
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 10
37+
outputs:
38+
deployed: ${{ steps.poll.outputs.deployed }}
39+
steps:
40+
- name: Poll production for current commit
41+
id: poll
42+
run: |
43+
COMMIT_SHA="${{ github.sha }}"
44+
SHORT_SHA="${COMMIT_SHA:0:8}"
45+
echo "Waiting for $SHORT_SHA to deploy to ${{ env.PRODUCTION_URL }}..."
46+
47+
ELAPSED=0
48+
while [ $ELAPSED -lt ${{ env.DEPLOY_WAIT_MAX_SECONDS }} ]; do
49+
# Check if the build-id meta tag matches the current commit
50+
BUILD_ID=$(curl -sf "${{ env.PRODUCTION_URL }}" 2>/dev/null | grep -o 'app-build-id" content="[^"]*"' | head -1 | sed 's/.*content="//;s/".*//')
51+
if [ -n "$BUILD_ID" ]; then
52+
# Match first 8 chars — the meta tag may contain the full SHA or a short version
53+
DEPLOYED_SHORT="${BUILD_ID:0:8}"
54+
if [ "$DEPLOYED_SHORT" = "$SHORT_SHA" ]; then
55+
echo "✓ Deployed: $BUILD_ID matches $SHORT_SHA"
56+
echo "deployed=true" >> $GITHUB_OUTPUT
57+
exit 0
58+
fi
59+
echo " Current: $DEPLOYED_SHORT, waiting for: $SHORT_SHA ($ELAPSED/${DEPLOY_WAIT_MAX_SECONDS}s)"
60+
else
61+
echo " No build-id meta tag found ($ELAPSED/${DEPLOY_WAIT_MAX_SECONDS}s)"
62+
fi
63+
sleep ${{ env.DEPLOY_POLL_INTERVAL }}
64+
ELAPSED=$((ELAPSED + ${{ env.DEPLOY_POLL_INTERVAL }}))
65+
done
66+
67+
echo "⚠ Timed out waiting for deploy. Running tests against current production anyway."
68+
echo "deployed=timeout" >> $GITHUB_OUTPUT
69+
70+
# ── Job 2: Extract PR context and select tests ─────────────────────
71+
extract-context:
72+
name: Select tests from PR context
73+
runs-on: ubuntu-latest
74+
timeout-minutes: 5
75+
outputs:
76+
spec_files: ${{ steps.select.outputs.spec_files }}
77+
pr_number: ${{ steps.find-pr.outputs.pr_number }}
78+
issue_number: ${{ steps.find-pr.outputs.issue_number }}
79+
steps:
80+
- name: Checkout
81+
uses: actions/checkout@v4
82+
83+
- name: Find merged PR
84+
id: find-pr
85+
env:
86+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
87+
run: |
88+
# Find the PR that produced this merge commit
89+
PR_NUMBER=$(gh pr list --state merged --search "${{ github.sha }}" --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
90+
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
91+
# Fallback: search by commit message
92+
PR_NUMBER=$(git log -1 --format='%s' | grep -oE '#[0-9]+' | head -1 | tr -d '#' || echo "")
93+
fi
94+
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
95+
96+
if [ -n "$PR_NUMBER" ]; then
97+
# Extract "Fixes #NNN" from PR body
98+
ISSUE=$(gh pr view "$PR_NUMBER" --json body --jq '.body' 2>/dev/null | grep -oiE '(fixes|closes|resolves)\s+#[0-9]+' | head -1 | grep -oE '[0-9]+' || echo "")
99+
echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT
100+
echo "PR: #$PR_NUMBER, Issue: #$ISSUE"
101+
else
102+
echo "issue_number=" >> $GITHUB_OUTPUT
103+
echo "No PR found for commit ${{ github.sha }}"
104+
fi
105+
106+
- name: Select spec files
107+
id: select
108+
env:
109+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110+
run: |
111+
SPEC_MAP="web/e2e/spec-map.json"
112+
SPECS=""
113+
114+
# Always-run specs
115+
ALWAYS=$(jq -r '.always_run[]' "$SPEC_MAP")
116+
SPECS="$ALWAYS"
117+
118+
# From issue labels
119+
ISSUE="${{ steps.find-pr.outputs.issue_number }}"
120+
if [ -n "$ISSUE" ]; then
121+
LABELS=$(gh api "repos/${{ github.repository }}/issues/$ISSUE" --jq '.labels[].name' 2>/dev/null || echo "")
122+
for LABEL in $LABELS; do
123+
MATCHED=$(jq -r --arg l "$LABEL" '.label_map[$l] // [] | .[]' "$SPEC_MAP" 2>/dev/null)
124+
if [ -n "$MATCHED" ]; then
125+
SPECS=$(printf '%s\n%s' "$SPECS" "$MATCHED")
126+
fi
127+
done
128+
fi
129+
130+
# From changed file paths
131+
PR="${{ steps.find-pr.outputs.pr_number }}"
132+
if [ -n "$PR" ] && [ "$PR" != "null" ]; then
133+
CHANGED_FILES=$(gh pr view "$PR" --json files --jq '.files[].path' 2>/dev/null || echo "")
134+
for PREFIX in $(jq -r '.path_map | keys[]' "$SPEC_MAP"); do
135+
if echo "$CHANGED_FILES" | grep -q "^$PREFIX"; then
136+
MATCHED=$(jq -r --arg p "$PREFIX" '.path_map[$p][]' "$SPEC_MAP")
137+
SPECS=$(printf '%s\n%s' "$SPECS" "$MATCHED")
138+
fi
139+
done
140+
fi
141+
142+
# Trim whitespace, deduplicate, format as space-separated list
143+
SPEC_LIST=$(printf '%s\n' "$SPECS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sort -u | grep -v '^$' | tr '\n' ' ')
144+
echo "Selected specs: $SPEC_LIST"
145+
echo "spec_files=$SPEC_LIST" >> $GITHUB_OUTPUT
146+
147+
# ── Job 3: Run Playwright tests ────────────────────────────────────
148+
run-tests:
149+
name: Run Playwright E2E
150+
needs: [wait-for-deploy, extract-context]
151+
if: needs.extract-context.outputs.spec_files != ''
152+
runs-on: ubuntu-latest
153+
timeout-minutes: 15
154+
outputs:
155+
result: ${{ steps.test.outputs.result }}
156+
steps:
157+
- name: Checkout
158+
uses: actions/checkout@v4
159+
160+
- name: Setup Node.js
161+
uses: actions/setup-node@v4
162+
with:
163+
node-version: ${{ env.NODE_VERSION }}
164+
cache: 'npm'
165+
cache-dependency-path: web/package-lock.json
166+
167+
- name: Install dependencies
168+
working-directory: web
169+
run: npm ci
170+
171+
- name: Install Playwright Chromium
172+
working-directory: web
173+
run: npx playwright install chromium --with-deps
174+
175+
- name: Run targeted tests
176+
id: test
177+
working-directory: web
178+
env:
179+
PLAYWRIGHT_BASE_URL: ${{ env.PRODUCTION_URL }}
180+
run: |
181+
SPECS="${{ needs.extract-context.outputs.spec_files }}"
182+
echo "Running: $SPECS"
183+
184+
# Build spec file paths
185+
SPEC_ARGS=""
186+
for SPEC in $SPECS; do
187+
SPEC_ARGS="$SPEC_ARGS e2e/$SPEC"
188+
done
189+
190+
# Run with JSON + HTML reporters, Chromium only
191+
# Capture exit code without masking it
192+
EXIT_CODE=0
193+
npx playwright test $SPEC_ARGS \
194+
--project=chromium \
195+
--reporter=json,html \
196+
--output=test-results \
197+
2>&1 | tee playwright-output.txt || EXIT_CODE=$?
198+
199+
if [ "$EXIT_CODE" -eq 0 ]; then
200+
echo "result=passed" >> $GITHUB_OUTPUT
201+
else
202+
echo "result=failed" >> $GITHUB_OUTPUT
203+
fi
204+
205+
# Propagate the real exit code so the job status reflects test outcome
206+
exit $EXIT_CODE
207+
208+
- name: Upload Playwright report
209+
if: always()
210+
uses: actions/upload-artifact@v4
211+
with:
212+
name: playwright-post-merge-report
213+
path: web/playwright-report/
214+
retention-days: 30
215+
216+
- name: Upload test results
217+
if: always()
218+
uses: actions/upload-artifact@v4
219+
with:
220+
name: playwright-post-merge-results
221+
path: web/test-results/
222+
retention-days: 7
223+
224+
# ── Job 4: Report results ──────────────────────────────────────────
225+
report:
226+
name: Report verification results
227+
needs: [extract-context, run-tests]
228+
if: always() && needs.extract-context.outputs.pr_number != ''
229+
runs-on: ubuntu-latest
230+
timeout-minutes: 5
231+
steps:
232+
- name: Post result on PR
233+
env:
234+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
235+
run: |
236+
PR="${{ needs.extract-context.outputs.pr_number }}"
237+
ISSUE="${{ needs.extract-context.outputs.issue_number }}"
238+
SPECS="${{ needs.extract-context.outputs.spec_files }}"
239+
RESULT="${{ needs.run-tests.outputs.result }}"
240+
JOB_RESULT="${{ needs.run-tests.result }}"
241+
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
242+
243+
if [ "$RESULT" = "passed" ] && [ "$JOB_RESULT" = "success" ]; then
244+
ICON="✅"
245+
STATUS="passed"
246+
elif [ "$JOB_RESULT" = "skipped" ]; then
247+
ICON="⏭️"
248+
STATUS="skipped (no matching specs)"
249+
else
250+
ICON="❌"
251+
STATUS="failed"
252+
fi
253+
254+
BODY="## $ICON Post-Merge Verification: $STATUS
255+
256+
**Commit:** \`${{ github.sha }}\`
257+
**Specs run:** \`$SPECS\`
258+
**Report:** [$RUN_URL]($RUN_URL)
259+
"
260+
261+
# Comment on PR
262+
gh pr comment "$PR" --repo "${{ github.repository }}" --body "$BODY" || true
263+
264+
# On failure: reopen original issue + create regression issue + assign Copilot
265+
if [ "$STATUS" = "failed" ]; then
266+
# Reopen the original issue if it was closed by this PR
267+
if [ -n "$ISSUE" ]; then
268+
echo "Reopening original issue #$ISSUE..."
269+
gh issue reopen "$ISSUE" --repo "${{ github.repository }}" \
270+
--comment "⚠️ Reopening: post-merge Playwright verification **failed** after PR #$PR merged. The fix may not be working as expected. [See test report]($RUN_URL)" \
271+
|| true
272+
fi
273+
274+
# Create a new regression issue
275+
TITLE="🐛 Regression detected after merging PR #$PR"
276+
ISSUE_BODY="$(cat <<REGRESSION_EOF
277+
## Post-merge Playwright verification failed
278+
279+
**Merged PR:** #$PR
280+
**Original issue:** ${ISSUE:+#$ISSUE}${ISSUE:-none}
281+
**Commit:** ${{ github.sha }}
282+
**Failed specs:** \`$SPECS\`
283+
**CI run:** $RUN_URL
284+
285+
The following Playwright E2E tests failed after this PR was merged to main.
286+
This may indicate the fix didn't fully resolve the issue, or introduced a regression.
287+
288+
### Next steps
289+
1. Review the [Playwright HTML report]($RUN_URL) (download artifacts)
290+
2. Reproduce locally: \`PLAYWRIGHT_BASE_URL=https://console.kubestellar.io npx playwright test $SPECS --project=chromium\`
291+
3. Fix and create a follow-up PR
292+
REGRESSION_EOF
293+
)"
294+
NEW_ISSUE=$(gh issue create \
295+
--repo "${{ github.repository }}" \
296+
--title "$TITLE" \
297+
--body "$ISSUE_BODY" \
298+
--label "kind/bug,priority/critical,regression-detected,ai-fix-requested,triage/accepted" \
299+
2>&1 | grep -o '[0-9]*$' || echo "")
300+
301+
# Assign Copilot to auto-fix the regression
302+
if [ -n "$NEW_ISSUE" ]; then
303+
echo "Created regression issue #$NEW_ISSUE, assigning Copilot..."
304+
gh issue edit "$NEW_ISSUE" --repo "${{ github.repository }}" \
305+
--add-assignee "copilot-swe-agent[bot]" || true
306+
fi
307+
fi

0 commit comments

Comments
 (0)