Skip to content

Commit c812235

Browse files
authored
(ci) E2E supabase moves to local runner, more parallelism (#734)
1 parent 57bd395 commit c812235

44 files changed

Lines changed: 1249 additions & 212 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy.yml

Lines changed: 247 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Deploy Branch Preview
1+
name: E2E Tests
22
on:
33
push:
44
branches:
@@ -10,75 +10,281 @@ on:
1010
# Secure baseline: no permissions. Jobs that need them override explicitly.
1111
permissions: {}
1212

13+
# Cancel an in-flight run when a newer one comes in for the same PR (or push
14+
# ref). Under pull_request_target, github.ref resolves to the base branch, so
15+
# keying on it alone would make every PR against staging collide; prefer
16+
# pull_request.number when present so each PR has its own group.
17+
concurrency:
18+
group: e2e-local-${{ github.event.pull_request.number || github.ref }}
19+
cancel-in-progress: true
20+
1321
jobs:
14-
deploy:
15-
runs-on: pawtograder-ci
16-
environment: staging
22+
# Local-Supabase E2E. Spins up a clean dev environment on the runner and runs
23+
# the Playwright suite against it. The Coolify branch-preview job that used to
24+
# run alongside this one is gone — it'll come back once the deployment target
25+
# moves into the same k8s cluster as this runner.
26+
e2e-local:
27+
runs-on: pawtograder-e2e
28+
timeout-minutes: 75
1729
permissions:
1830
contents: read
19-
packages: write
31+
env:
32+
# Per-run project name so that two e2e-local jobs running on different
33+
# refs (or different runners in the same pool) don't clobber each
34+
# other's Supabase containers / Docker volumes. The Supabase CLI takes
35+
# this from `supabase/config.toml` `project_id`, so we sed-edit the
36+
# checkout copy below before any `supabase` invocation.
37+
SUPABASE_PROJECT: pawtograder-platform-${{ github.run_id }}-${{ github.run_attempt }}
38+
# Supabase local-dev keys are deterministic (signed with the public
39+
# "supabase-demo" JWT secret). Hardcoding lets us write .env.local — and
40+
# therefore start `next build` — before `supabase start` finishes.
41+
LOCAL_SUPABASE_URL: http://127.0.0.1:54321
42+
LOCAL_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
43+
LOCAL_SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
44+
# Per-run scratch dir for PID / log files. /tmp persists across jobs on
45+
# self-hosted runners, so namespacing on run_id+attempt prevents the
46+
# cleanup `kill $(cat ...pid)` from targeting a stale PID that the OS
47+
# has since recycled to an unrelated process.
48+
RUN_TMP: /tmp/e2e-${{ github.run_id }}-${{ github.run_attempt }}
2049
steps:
2150
- uses: actions/checkout@v4
2251
with:
2352
ref: ${{ github.event.pull_request.head.sha || github.sha }}
53+
2454
- name: Cache npm dependencies
2555
uses: actions/cache@v4
2656
with:
2757
path: ~/.npm
2858
key: npm-${{ runner.os }}-node-22-${{ hashFiles('package-lock.json') }}
2959
restore-keys: |
3060
npm-${{ runner.os }}-node-22-
31-
- uses: supabase/setup-cli@v1
61+
62+
- name: Cache Next.js build
63+
uses: actions/cache@v4
3264
with:
33-
version: latest
34-
- name: Deploy a branch preview environment
35-
id: deploy
36-
uses: pawtograder/coolify-supabase-deployment-action@main
65+
path: .next/cache
66+
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.[jt]s', '**/*.[jt]sx', '!**/node_modules/**') }}
67+
restore-keys: |
68+
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
69+
nextjs-${{ runner.os }}-
70+
71+
- uses: actions/setup-node@v4
3772
with:
38-
ephemeral: false
39-
coolify_api_url: https://coolify.in.ripley.cloud/api/v1
40-
coolify_server_uuid: ${{ secrets.COOLIFY_SERVER_UUID }}
41-
coolify_api_token: ${{ secrets.COOLIFY_API_TOKEN }}
42-
coolify_project_uuid: ${{ secrets.COOLIFY_PROJECT_UUID }}
43-
coolify_environment_name: ${{ vars.COOLIFY_ENVIRONMENT_NAME }}
44-
coolify_environment_uuid: ${{ secrets.COOLIFY_ENVIRONMENT_UUID }}
45-
coolify_supabase_api_url: ${{ secrets.COOLIFY_SUPABASE_API_URL }}
46-
deployment_app_uuid: ${{ secrets.COOLIFY_DEPLOYMENT_APP_UUID }}
47-
bugsink_dsn: ${{ secrets.BUGSINK_DSN }}
48-
discord_webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
49-
frontend_image_repo: ghcr.io/${{ github.repository_owner }}/platform-frontend
50-
dockerfile_path: ./Dockerfile
51-
docker_registry_username: ${{ github.actor }}
52-
docker_registry_password: ${{ secrets.GITHUB_TOKEN }}
73+
node-version: 22
74+
75+
- uses: supabase/setup-cli@v1
76+
with:
77+
# Pin to a known-good version. `latest` makes runs non-reproducible —
78+
# bump deliberately when we want a new CLI.
79+
version: 2.92.1
80+
81+
- name: Install dependencies
82+
run: npm ci
83+
84+
- name: Write .env.local
5385
env:
5486
GITHUB_APP_ID: ${{ secrets.PAWTOGRADER_GITHUB_APP_ID }}
87+
GITHUB_PRIVATE_KEY_STRING: ${{ secrets.PAWTOGRADER_GITHUB_PRIVATE_KEY_STRING }}
5588
GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PAWTOGRADER_GITHUB_OAUTH_CLIENT_ID }}
5689
GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PAWTOGRADER_GITHUB_OAUTH_CLIENT_SECRET }}
57-
GITHUB_PRIVATE_KEY_STRING: ${{ secrets.PAWTOGRADER_GITHUB_PRIVATE_KEY_STRING }}
58-
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
59-
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
6090
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
6191
UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
6292
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
63-
- name: Setup Node
64-
uses: actions/setup-node@v4
65-
with:
66-
node-version: 22
67-
- name: Install dependencies
68-
run: npm ci
69-
- name: Install Playwright Browsers
70-
run: npx playwright install --with-deps
93+
run: |
94+
set -euo pipefail
95+
cat > .env.local <<EOF
96+
NEXT_PUBLIC_SUPABASE_URL=${LOCAL_SUPABASE_URL}
97+
NEXT_PUBLIC_SUPABASE_ANON_KEY=${LOCAL_SUPABASE_ANON_KEY}
98+
SUPABASE_URL=${LOCAL_SUPABASE_URL}
99+
SUPABASE_ANON_KEY=${LOCAL_SUPABASE_ANON_KEY}
100+
SUPABASE_SERVICE_ROLE_KEY=${LOCAL_SUPABASE_SERVICE_ROLE_KEY}
101+
ENABLE_SIGNUPS=true
102+
NEXT_PUBLIC_PAWTOGRADER_WEB_URL=http://localhost:3001
103+
E2E_ENABLE=true
104+
END_TO_END_SECRET=not-a-secret
105+
EDGE_FUNCTION_SECRET=some-secret-value
106+
GITHUB_APP_ID=${GITHUB_APP_ID}
107+
GITHUB_OAUTH_CLIENT_ID=${GITHUB_OAUTH_CLIENT_ID}
108+
GITHUB_OAUTH_CLIENT_SECRET=${GITHUB_OAUTH_CLIENT_SECRET}
109+
UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
110+
UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
111+
SENTRY_DSN=${SENTRY_DSN}
112+
EOF
113+
# Single-line escaped PEM — supabase functions serve --env-file does
114+
# not support multi-line quoted values; .env.local.staging uses the
115+
# same shape. The Octokit / GitHub-App SDK unescapes \n at runtime.
116+
KEY_ESCAPED=$(printf '%s' "${GITHUB_PRIVATE_KEY_STRING}" | awk 'BEGIN{ORS=""} {printf "%s\\n", $0}')
117+
printf 'GITHUB_PRIVATE_KEY_STRING="%s"\n' "$KEY_ESCAPED" >> .env.local
118+
119+
# Make supabase/config.toml's project_id unique per run so the supabase
120+
# CLI tags its containers and volumes with our SUPABASE_PROJECT, not the
121+
# repo-default `pawtograder-platform`. Without this, two concurrent runs
122+
# would mutually clobber each other's stack.
123+
- name: Pin supabase project_id per run
124+
run: |
125+
set -euo pipefail
126+
sed -i.bak -E "s|^project_id = .*$|project_id = \"${SUPABASE_PROJECT}\"|" supabase/config.toml
127+
grep "^project_id" supabase/config.toml
128+
129+
# Kick Supabase off in the background so its container pulls + migration
130+
# run overlap with `next build` and `playwright install`.
131+
- name: Start local Supabase (background, fresh)
132+
run: |
133+
set -euo pipefail
134+
mkdir -p "$RUN_TMP"
135+
npx supabase stop --no-backup || true
136+
docker volume ls --filter label=com.supabase.cli.project=${SUPABASE_PROJECT} -q \
137+
| xargs -r docker volume rm || true
138+
139+
nohup npx supabase start > "$RUN_TMP/supabase-start.log" 2>&1 &
140+
echo $! > "$RUN_TMP/supabase-start.pid"
141+
142+
# Background the browser install (~30-90s, mostly downloads + apt-get).
143+
- name: Install Playwright Browsers (background)
144+
run: |
145+
set -euo pipefail
146+
rm -f $RUN_TMP/playwright-install.exitcode
147+
nohup bash -c 'npx playwright install --with-deps \
148+
> $RUN_TMP/playwright-install.log 2>&1; \
149+
echo $? > $RUN_TMP/playwright-install.exitcode' &
150+
151+
- name: Build Next.js (prod, port 3001)
152+
env:
153+
NEXT_PUBLIC_PAWTOGRADER_WEB_URL: http://localhost:3001
154+
run: npm run build
155+
156+
- name: Wait for Playwright install
157+
run: |
158+
set -euo pipefail
159+
for i in $(seq 1 600); do
160+
[ -f $RUN_TMP/playwright-install.exitcode ] && break
161+
sleep 1
162+
done
163+
rc="$(cat $RUN_TMP/playwright-install.exitcode 2>/dev/null || echo 'timeout')"
164+
if [ "$rc" != "0" ]; then
165+
echo "playwright install failed (rc=$rc)"
166+
echo '--- playwright-install.log ---'
167+
cat $RUN_TMP/playwright-install.log || true
168+
exit 1
169+
fi
170+
171+
- name: Wait for Supabase + finalize schema
172+
run: |
173+
set -euo pipefail
174+
PID="$(cat $RUN_TMP/supabase-start.pid)"
175+
# `wait` is useless here — the bg process was launched in a previous
176+
# step's shell and is reparented to PID 1. Poll the API until it
177+
# answers. `supabase start` can exit successfully while kong is
178+
# still warming up, so don't bail on the first PID-gone tick —
179+
# give the API a grace period to come up before declaring failure.
180+
grace_ticks=0
181+
for i in $(seq 1 600); do
182+
if curl -sf -o /dev/null \
183+
-H "apikey: ${LOCAL_SUPABASE_ANON_KEY}" \
184+
"${LOCAL_SUPABASE_URL}/rest/v1/"; then
185+
break
186+
fi
187+
if ! kill -0 "$PID" 2>/dev/null; then
188+
grace_ticks=$((grace_ticks + 1))
189+
if [ "$grace_ticks" -gt 20 ]; then
190+
echo 'supabase start exited and the API never came up within 20s'
191+
echo '--- supabase-start.log ---'
192+
cat $RUN_TMP/supabase-start.log || true
193+
exit 1
194+
fi
195+
fi
196+
sleep 1
197+
done
198+
# Final readiness check — fail loudly if we fell out of the loop.
199+
curl -sf -o /dev/null \
200+
-H "apikey: ${LOCAL_SUPABASE_ANON_KEY}" \
201+
"${LOCAL_SUPABASE_URL}/rest/v1/"
202+
203+
# Audit table is partitioned; seed today's partition (and the next 7 days).
204+
docker exec -i "supabase_db_${SUPABASE_PROJECT}" psql -U postgres -d postgres -c \
205+
"SELECT public.audit_maintain_partitions();"
206+
207+
- name: Serve Edge Functions
208+
run: |
209+
set -euo pipefail
210+
nohup npx supabase functions serve --env-file .env.local \
211+
> $RUN_TMP/edge-functions.log 2>&1 &
212+
echo $! > $RUN_TMP/edge-functions.pid
213+
# Wait until the gateway answers. `curl -s -w %{http_code}` prints
214+
# `000` on connect-refused, so check the exit status (or filter
215+
# `000`) — otherwise the loop breaks on the first tick.
216+
ready=0
217+
for i in $(seq 1 60); do
218+
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:54321/functions/v1/ || true)
219+
if [ -n "$code" ] && [ "$code" != "000" ]; then ready=1; break; fi
220+
sleep 1
221+
done
222+
if [ "$ready" != "1" ]; then
223+
echo "Edge Functions never became reachable"
224+
tail -n 200 $RUN_TMP/edge-functions.log || true
225+
exit 1
226+
fi
227+
228+
- name: Start Next.js prod server
229+
run: |
230+
set -euo pipefail
231+
nohup env PORT=3001 npm run start > $RUN_TMP/next-server.log 2>&1 &
232+
echo $! > $RUN_TMP/next-server.pid
233+
ready=0
234+
for i in $(seq 1 60); do
235+
if curl -sf -o /dev/null http://localhost:3001/; then ready=1; break; fi
236+
sleep 1
237+
done
238+
if [ "$ready" != "1" ]; then
239+
echo "Next.js prod server never became reachable"
240+
tail -n 200 $RUN_TMP/next-server.log || true
241+
exit 1
242+
fi
243+
71244
- name: Run Playwright tests
72245
env:
246+
BASE_URL: http://localhost:3001
247+
SUPABASE_URL: ${{ env.LOCAL_SUPABASE_URL }}
248+
SUPABASE_ANON_KEY: ${{ env.LOCAL_SUPABASE_ANON_KEY }}
249+
SUPABASE_SERVICE_ROLE_KEY: ${{ env.LOCAL_SUPABASE_SERVICE_ROLE_KEY }}
250+
# EDGE_FUNCTION_SECRET / END_TO_END_SECRET are sourced from .env.local
251+
# (written above) via the test-runner's dotenv load — single source
252+
# of truth so the two values can't drift.
73253
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
74-
SUPABASE_URL: ${{ steps.deploy.outputs.supabase_url }}
75-
SUPABASE_SERVICE_ROLE_KEY: ${{ steps.deploy.outputs.supabase_service_role_key }}
76-
SUPABASE_ANON_KEY: ${{ steps.deploy.outputs.supabase_anon_key }}
77-
BASE_URL: ${{ steps.deploy.outputs.app_url }}
78-
EDGE_FUNCTION_SECRET: ${{ steps.deploy.outputs.edge_function_secret }}
79254
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
80255
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81256
run: npx playwright test
257+
258+
- name: Tail server logs on failure
259+
if: failure()
260+
run: |
261+
echo '--- edge-functions.log ---'; tail -n 500 $RUN_TMP/edge-functions.log || true
262+
echo '--- next-server.log ---'; tail -n 500 $RUN_TMP/next-server.log || true
263+
264+
- name: Stop background services
265+
if: always()
266+
run: |
267+
kill "$(cat $RUN_TMP/edge-functions.pid 2>/dev/null)" 2>/dev/null || true
268+
kill "$(cat $RUN_TMP/next-server.pid 2>/dev/null)" 2>/dev/null || true
269+
# Capture full Supabase container logs while the stack is still up.
270+
mkdir -p server-logs
271+
for c in $(docker ps --filter "label=com.supabase.cli.project=${SUPABASE_PROJECT}" --format '{{.Names}}'); do
272+
docker logs "$c" > "server-logs/${c}.log" 2>&1 || true
273+
done
274+
cp -f $RUN_TMP/edge-functions.log server-logs/edge-functions.log 2>/dev/null || true
275+
cp -f $RUN_TMP/next-server.log server-logs/next-server.log 2>/dev/null || true
276+
npx supabase stop --no-backup || true
277+
278+
- name: Upload server logs
279+
# Use always() (not !cancelled()) so timeout-cancelled runs — exactly
280+
# the case where these logs are most useful — still upload.
281+
if: always()
282+
uses: actions/upload-artifact@v4
283+
with:
284+
name: server-logs
285+
path: server-logs/
286+
retention-days: 14
287+
82288
- uses: actions/upload-artifact@v4
83289
if: ${{ !cancelled() }}
84290
with:

app/course/[course_id]/assignments/[assignment_id]/finalizeSubmissionEarly.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,29 @@ export default function FinalizeSubmissionEarly({
8989
});
9090
return;
9191
}
92-
reviewAssignments.refetchAll();
92+
// The submission *is* finalized at this point — the RPC committed the
93+
// state change. Anything that fails after this is post-success cache
94+
// refresh; it must not surface as a "Could not finalize submission"
95+
// error or the user will think the action failed and retry.
9396
toaster.success({
9497
title: "Submission finalized",
9598
description: "Your submission time is set. You can continue with self-review if your course uses it."
9699
});
100+
101+
// Refresh the review_assignments controller so the self-review row
102+
// created by finalize_submission_early is in cache before the UI
103+
// renders the "Complete Self Review" button. Failures here are
104+
// non-fatal (the row will arrive via realtime or the next page load).
105+
try {
106+
await reviewAssignments.refetchAll();
107+
} catch (refetchErr) {
108+
console.warn("Submission finalized, but reviewAssignments refetch failed:", refetchErr);
109+
toaster.create({
110+
type: "warning",
111+
title: "Self-review may take a moment to appear",
112+
description: "Your submission was finalized. If the self-review button doesn't show up, refresh the page."
113+
});
114+
}
97115
} catch (err) {
98116
console.error("Unexpected error finalizing submission:", err);
99117
toaster.error({

app/course/[course_id]/discussion/[root_id]/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ function DiscussionPost({ root_id }: { root_id: number }) {
218218
const discussion_topics = useDiscussionTopics();
219219
const { discussionThreadTeasers } = useCourseController();
220220
const rootThread = useTableControllerValueById(discussionThreadTeasers, root_id);
221+
// Force a single-row fetch on mount / root_id change so the heading paints
222+
// reliably on first navigation. The course-wide teasers controller is
223+
// populated by an initial-load query plus realtime broadcasts; if neither
224+
// has delivered this row yet (race when the user navigates before the
225+
// teasers controller's initial fetch resolves, or when realtime delivery
226+
// for a freshly-created thread lags the navigation), getById() returns
227+
// undefined and the page sits on the Skeleton until reload. refetchByIds
228+
// always queries the DB and adds the row, which fires the listener wired
229+
// up by useTableControllerValueById.
230+
useEffect(() => {
231+
discussionThreadTeasers.refetchByIds([root_id]);
232+
}, [discussionThreadTeasers, root_id]);
233+
221234
const [editing, setEditing] = useState(false);
222235
const [visibility, setVisibility] = useState(rootThread?.instructors_only ? "instructors_only" : "all");
223236
const isGraderOrInstructor = useIsGraderOrInstructor();

0 commit comments

Comments
 (0)