Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2c04b7f
ci: gate Playwright E2E to stage/main only
evereq May 27, 2026
f348a19
perf(items): stream similar-items to fix blank first paint on /items/…
evereq May 27, 2026
6da466d
Fix/profile button in seting page (#944)
joel-kalema May 27, 2026
5be521d
Fix/client securities (#943)
joel-kalema May 27, 2026
bb7a1a3
Fix/k8s registry visibility handling and secret provisioning
paradoxe35 May 28, 2026
033689b
fix: tiny
evereq May 28, 2026
bcb49b5
Merge branch 'develop' of github.com:ever-works/ever-works-website-te…
evereq May 28, 2026
4a42234
feat(profile): match followers page UX to following page (#946)
evereq May 29, 2026
e589bec
fix(users): live search without page reload + community link in profi…
joel-kalema May 29, 2026
6bc486c
build(deps): Bump axios from 1.15.2 to 1.16.0
dependabot[bot] May 29, 2026
992e2a7
build(deps): Bump axios from 1.15.2 to 1.16.0 (#948)
evereq May 30, 2026
f08ce2b
@
evereq Jun 13, 2026
0c47547
feat: .
evereq Jun 14, 2026
28ae4f4
Fix/danger zone (#953)
joel-kalema Jun 16, 2026
47e79c3
Fix/notification bug (#952)
joel-kalema Jun 16, 2026
cfe2e33
Merge pull request #950 from ever-works/session/neon-vercel-docs
evereq Jun 16, 2026
556b036
fix(deploy): add startupProbe + explicit probe timeouts to k8s deploy…
evereq Jun 16, 2026
36c7976
feat(deploy): materialize runtime-env Secret + envFrom for k8s deploy…
evereq Jun 16, 2026
d3637ea
Feat/item detail and FIX UX (#956)
joel-kalema Jun 17, 2026
3650c4f
refactor(pricing): minimalist redesign (#949)
evereq Jun 17, 2026
1ff481c
fix(e2e): make git-CMS writes CI-safe + fix favorite-toggle session r…
evereq Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .deploy/k8s-platform/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,38 @@ spec:
ports:
- containerPort: 3000
name: http
startupProbe:
httpGet:
path: /
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /
port: http
periodSeconds: 5
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 1Gi
envFrom:
- secretRef:
name: ${WORK_SLUG}-runtime-env
optional: true
env:
- name: NODE_ENV
value: production
Expand Down
75 changes: 55 additions & 20 deletions .github/workflows/deploy_k8s.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
workflow_dispatch:
inputs:
environment:
description: "Target environment (production, staging, …)"
description: 'Target environment (production, staging, …)'
required: true
type: string
default: production
Expand All @@ -29,20 +29,23 @@ concurrency:
cancel-in-progress: true

env:
NODE_OPTIONS: "--max-old-space-size=4096"
# K8S_REGISTRY_KIND drives every per-registry conditional below.
# Defaults to 'github' when the platform pushed nothing — that's the
# zero-config GHCR flow.
K8S_REGISTRY_KIND: ${{ secrets.K8S_REGISTRY_KIND || 'github' }}
# Hoisted from secrets so step `if:` conditionals can reference it.
# `secrets.X` is not a valid expression context in step-level `if:`,
# but workflow/job-level `env:` is — so we mirror the secret into env
# once and let downstream `env.K8S_REGISTRY_VISIBILITY` work everywhere.
K8S_REGISTRY_VISIBILITY: ${{ secrets.K8S_REGISTRY_VISIBILITY || 'public' }}
NODE_OPTIONS: '--max-old-space-size=4096'
# Keep registry settings at job level. GitHub does not allow the
# `secrets` context in workflow-level env, and using it there can make
# the run fail during workflow startup before any job is created.

jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
# K8S_REGISTRY_KIND drives every per-registry conditional below.
# Defaults to 'github' when the platform pushed nothing — that's
# the zero-config GHCR flow.
K8S_REGISTRY_KIND: ${{ secrets.K8S_REGISTRY_KIND || 'github' }}
# `auto` mirrors the platform/plugin default. Treat auto like
# private for pull-secret creation; an extra pull secret is safe
# for public GHCR, while a missing one breaks private repos.
K8S_REGISTRY_VISIBILITY: ${{ secrets.K8S_REGISTRY_VISIBILITY || 'auto' }}
steps:
- name: Echo target environment
run: echo "Deploying to ${{ inputs.environment }}"
Expand Down Expand Up @@ -94,7 +97,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: '22'
cache: pnpm

# The Dockerfile copies the working tree into the build context
Expand Down Expand Up @@ -198,7 +201,7 @@ jobs:
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -

- name: Provision image-pull secret (private registries)
if: env.K8S_REGISTRY_KIND != 'github' || env.K8S_REGISTRY_VISIBILITY == 'private'
if: env.K8S_REGISTRY_KIND != 'github' || env.K8S_REGISTRY_VISIBILITY != 'public'
env:
NAMESPACE: ${{ secrets.K8S_NAMESPACE || 'ever-works' }}
WORK_SLUG: ${{ github.event.repository.name }}
Expand All @@ -213,12 +216,9 @@ jobs:
# `packages:read` for the workflow's own repo, not for
# images owned by a different account.
# 3. GITHUB_TOKEN — fallback that works for same-repo GHCR.
# DEPLOY_TOKEN is what the Ever Works platform's k8s-plugin
# actually pushes — the read:packages PAT created via the
# GitHub OAuth `plugins/read-packages` flow. The legacy
# GITHUB_READ_PACKAGES_TOKEN name is kept in the chain for
# back-compat.
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_READ_PACKAGES_TOKEN || secrets.DEPLOY_TOKEN || secrets.GITHUB_TOKEN }}
# The legacy GITHUB_READ_PACKAGES_TOKEN name is kept in
# the chain for back-compat with older platform pushes.
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_READ_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }}
run: |
kubectl create secret docker-registry "${WORK_SLUG}-pull" \
--docker-server="$REGISTRY_HOST" \
Expand All @@ -227,6 +227,41 @@ jobs:
-n "$NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -

- name: Provision runtime-env secret
# The deployed directory site needs its app runtime env — which
# Vercel supplied via project env + the Neon integration. The
# platform pushes these as repo secrets on a k8s deploy
# (DeployService.ensureRuntimeEnv); here we materialize them into
# a Secret the Deployment mounts via envFrom. NEXT_PUBLIC_APP_URL /
# COOKIE_DOMAIN are derived from the ingress host. Skipped cleanly
# when none are present (e.g. a Work without a configured DB).
env:
NAMESPACE: ${{ secrets.K8S_NAMESPACE || 'ever-works' }}
WORK_SLUG: ${{ github.event.repository.name }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }}
COOKIE_SECURE: ${{ secrets.COOKIE_SECURE }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
INGRESS_HOST: ${{ secrets.K8S_INGRESS_HOST }}
run: |
set -euo pipefail
ARGS=()
[[ -n "${AUTH_SECRET:-}" ]] && ARGS+=(--from-literal=AUTH_SECRET="$AUTH_SECRET")
[[ -n "${COOKIE_SECRET:-}" ]] && ARGS+=(--from-literal=COOKIE_SECRET="$COOKIE_SECRET")
[[ -n "${COOKIE_SECURE:-}" ]] && ARGS+=(--from-literal=COOKIE_SECURE="$COOKIE_SECURE")
[[ -n "${DATABASE_URL:-}" ]] && ARGS+=(--from-literal=DATABASE_URL="$DATABASE_URL")
if [[ -n "${INGRESS_HOST:-}" ]]; then
ARGS+=(--from-literal=NEXT_PUBLIC_APP_URL="https://${INGRESS_HOST}")
ARGS+=(--from-literal=COOKIE_DOMAIN="${INGRESS_HOST}")
fi
if [[ ${#ARGS[@]} -gt 0 ]]; then
kubectl create secret generic "${WORK_SLUG}-runtime-env" -n "$NAMESPACE" \
"${ARGS[@]}" --dry-run=client -o yaml | kubectl apply -f -
echo "Provisioned ${WORK_SLUG}-runtime-env (${#ARGS[@]} keys)"
else
echo "No runtime-env secrets present; skipping ${WORK_SLUG}-runtime-env"
fi

- name: Render and apply manifests
env:
IMAGE: ${{ steps.image.outputs.image }}
Expand All @@ -241,7 +276,7 @@ jobs:
set -euo pipefail

# Pull-secret reference for the Deployment podSpec.
if [[ -n "${REGISTRY_USERNAME:-}${{ secrets.REGISTRY_USERNAME }}" || "${{ secrets.K8S_REGISTRY_VISIBILITY }}" == "private" ]]; then
if [[ "$K8S_REGISTRY_KIND" != "github" || "$K8S_REGISTRY_VISIBILITY" != "public" ]]; then
export PULL_SECRET_BLOCK="imagePullSecrets:
- name: ${WORK_SLUG}-pull"
else
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
name: E2E Tests

# Playwright e2e suite (4-shard parallel run across 300+ specs).
#
# Scope: only the release branches stage / main. PR and develop triggers
# were removed in 2026-05-27 (see
# Workspace/knowledge/runbooks/CI_RELEASE_GATES.md) — running 4 shards of
# Playwright on every PR and every develop push was burning CI minutes
# without protecting the release branches better than gating on
# stage/main would. Lightweight CI (Web CI / Docs CI / CodeQL) still
# runs on PRs to main/develop and catches the cheap failure modes.

permissions:
contents: read

on:
push:
branches: [main, develop, stage]
pull_request:
branches: [main, develop, stage]
branches: [stage, main]
workflow_dispatch:
inputs:
project:
Expand Down
22 changes: 16 additions & 6 deletions apps/web-e2e/page-objects/admin/collections.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ export class AdminCollectionsPage extends BasePage {
return this.page.locator('.fixed.inset-0.z-50').first();
}

/** Collection form ID input (uses HeroUI Input — target by placeholder). */
/**
* Collection form ID input. In create mode there is no ID input — the ID
* is auto-generated from the name and shown as a URL-slug hint. In edit
* mode the ID is rendered read-only. The getter still returns a locator
* (the read-only input in edit mode) so existing edit assertions work,
* but create-mode tests should not try to fill it.
*/
get collectionIdInput() {
return this.collectionFormModal.getByPlaceholder(/frontend-frameworks/i);
return this.collectionFormModal.locator('input[readonly]').first();
}

/** Collection form name input. */
Expand Down Expand Up @@ -77,11 +83,15 @@ export class AdminCollectionsPage extends BasePage {
return this.collectionFormModal.getByRole('button', { name: /save changes/i });
}

/** Fill the collection form. */
/**
* Fill the collection form. The `id` parameter is accepted for backwards
* compatibility with existing tests but ignored — the form auto-generates
* the ID (numeric timestamp) and a URL slug from the name. Tests pass
* `id` only to make their data deterministic in their own scope (e.g.
* computing the resulting collection name).
*/
async fillCollectionForm(data: { id?: string; name: string; description?: string }) {
if (data.id) {
await this.collectionIdInput.fill(data.id);
}
void data.id;
await this.collectionNameInput.fill(data.name);
if (data.description) {
await this.collectionDescriptionInput.fill(data.description);
Expand Down
32 changes: 22 additions & 10 deletions apps/web-e2e/page-objects/public/item-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,36 @@ export class ItemDetailPage extends BasePage {
return label === 'Remove upvote';
}

/** Click the favorite toggle button and wait for its aria-label to
* flip. React's onClick handler attaches at hydrate time — clicking
* too early on a cold-start server silently no-ops, leaving the
* label unchanged. Retry until the toggle actually fires (or give
* up after a generous timeout). */
/** Click the favorite toggle button and wait for its aria-label to flip.
* Two CI-specific hazards are handled here:
* 1. The button's onClick reads the current user from `useCurrentUser`;
* if the `/api/current-user` fetch hasn't resolved yet, an early click
* opens the login modal *instead* of toggling. A click that opened the
* modal did not toggle, so we don't count it — we retry until the
* session is ready and the click actually flips the label.
* 2. If a login modal did open, its full-screen `z-50` backdrop sits over
* the button and eats subsequent clicks; dismiss it (Escape) first. */
async clickFavorite() {
const loginDialog = this.page.locator('[role="dialog"][aria-modal="true"]');
const before = await this.favoriteButton.getAttribute('aria-label');
const deadline = Date.now() + 15_000;
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
await this.favoriteButton.click({ trial: false }).catch(() => undefined);
// Clear a login modal opened by an earlier (pre-hydration) click so
// its backdrop stops blocking the favorite button.
if (await loginDialog.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape').catch(() => undefined);
await loginDialog.waitFor({ state: 'hidden', timeout: 2_000 }).catch(() => undefined);
}
await this.favoriteButton.click({ timeout: 2_000 }).catch(() => undefined);
// Wait briefly for the optimistic UI update / API roundtrip.
await this.page.waitForTimeout(500);
// A click that opened the login modal did NOT toggle — retry.
if (await loginDialog.isVisible().catch(() => false)) continue;
const after = await this.favoriteButton.getAttribute('aria-label');
if (after !== before) return;
}
// Last best-effort: if state never changed, throw the next assertion
// instead of returning a silent success. The caller asserts on the
// flipped label and will surface a clear failure.
// If the label never flipped, the caller's assertion on the flipped
// label surfaces a clear failure rather than a silent success.
}

/** Post a comment with the given text. */
Expand Down
Loading
Loading