From 50ae88c30dec7da9fa037584d9a43980674863c7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 5 Dec 2025 23:54:29 +0300 Subject: [PATCH 01/18] Add play / pause controls to hero computer animation --- @types/window.d.ts | 1 + _TODO.md | 13 --- package.json | 3 +- .../Computers/client/__tests__/index.spec.ts | 28 +++++ .../Animations/Computers/client/index.ts | 106 ++++++++++++++++++ .../Animations/Computers/index.astro | 17 ++- src/components/scripts/store/index.ts | 2 + src/pages/testing/animations-computers.astro | 25 +++++ .../animations-computers.spec.ts | 91 +++++++++++++++ test/e2e/specs/07-performance/PERFORMANCE.md | 2 + 10 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 src/pages/testing/animations-computers.astro create mode 100644 test/e2e/specs/04-components/animations-computers.spec.ts diff --git a/@types/window.d.ts b/@types/window.d.ts index ab5f2f8de..bf1702aa8 100644 --- a/@types/window.d.ts +++ b/@types/window.d.ts @@ -89,6 +89,7 @@ declare global { updateConsent?: (_category: 'analytics' | 'marketing' | 'functional', _value: boolean) => void cacheEmbed?: (_key: string, _data: unknown, _ttl: number) => void saveMastodonInstance?: (_domain: string) => void + setOverlayPauseState?: (_source: string, _isPaused: boolean) => void /** * Custom evaluation error injected during Playwright tests diff --git a/_TODO.md b/_TODO.md index a1e01d3bb..a9c94e894 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,23 +1,10 @@ # TODO -## Pause and Play - -Next, I'd like to add a "pause" and "play" icon to src/components/Animations/Computers -There are icons with those names already configured for the Icon component. -There are hooks for pause and play already setup in the component. -The icon should be displayed in the low right hand corner of the animation, with 4px of padding from the bottom and right side. It should overlay the animation, not expand the bounding box of the animation. - ## Performance Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md -## GitHub - -- Make sure actions workflows are working correctly after performance tests pass and whole suite is green -- Change Dependabut to open a single PR with all dependency updates -- Add 'hotfix' branch and add branch protection rules - ## Analytics Vercel Analytics diff --git a/package.json b/package.json index 2afc8b589..fe2f5b99a 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,7 @@ "format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", "format:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", "format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"", - "lint": "npm run lint:base && npm run lint:actions", - "lint:base": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code", + "lint": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code && npm run lint:actions && npm run check", "lint:code": "npx eslint \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" \"test/**/*.{js,ts,tsx,astro}\"", "lint:tsc:check": "tsc --noEmit -p tsconfig.json --pretty false", "lint:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", diff --git a/src/components/Animations/Computers/client/__tests__/index.spec.ts b/src/components/Animations/Computers/client/__tests__/index.spec.ts index 8ed220680..4fc281adb 100644 --- a/src/components/Animations/Computers/client/__tests__/index.spec.ts +++ b/src/components/Animations/Computers/client/__tests__/index.spec.ts @@ -171,12 +171,19 @@ describe('ComputersAnimationElement', () => { const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0] const pauseHandler = controllerArgs?.onPause const resumeHandler = controllerArgs?.onPlay + const toggleButton = element.querySelector('[data-animation-toggle]') pauseHandler?.() + expect(element.getAttribute('data-animation-state')).toBe('paused') + expect(toggleButton?.getAttribute('aria-pressed')).toBe('true') + expect(toggleButton?.getAttribute('aria-label')).toBe('Play animation') resumeHandler?.() expect(timelineMock.pause).toHaveBeenCalled() expect(timelineMock.play).toHaveBeenCalled() + expect(element.getAttribute('data-animation-state')).toBe('playing') + expect(toggleButton?.getAttribute('aria-pressed')).toBe('false') + expect(toggleButton?.getAttribute('aria-label')).toBe('Pause animation') expect(getBreadcrumbOperations()).toEqual(expect.arrayContaining(['pause', 'resume'])) }) }) @@ -221,10 +228,31 @@ describe('ComputersAnimationElement', () => { element.initialize() element.pause() + expect(element.getAttribute('data-animation-state')).toBe('paused') element.resume() expect(timelineMock.pause).toHaveBeenCalledTimes(1) expect(timelineMock.play).toHaveBeenCalledTimes(1) + expect(element.getAttribute('data-animation-state')).toBe('playing') + }) + }) + + it('requests pause and play through the animation controller when the toggle is clicked', async () => { + await renderComputersAnimation(async ({ element }) => { + element.initialize() + + const toggleButton = element.querySelector('[data-animation-toggle]') + const controllerHandle = getLastControllerHandle() + + expect(toggleButton).toBeTruthy() + + toggleButton?.click() + expect(controllerHandle?.requestPause).toHaveBeenCalledTimes(1) + + element.pause() + + toggleButton?.click() + expect(controllerHandle?.requestPlay).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/Animations/Computers/client/index.ts b/src/components/Animations/Computers/client/index.ts index abe742796..2725851c9 100644 --- a/src/components/Animations/Computers/client/index.ts +++ b/src/components/Animations/Computers/client/index.ts @@ -2,9 +2,11 @@ import { LitElement } from 'lit' import { gsap } from 'gsap' import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' +import { addButtonEventListeners } from '@components/scripts/elementListeners' import { createAnimationController, type AnimationControllerHandle, + type AnimationPlayState, } from '@components/scripts/store' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' @@ -41,10 +43,14 @@ export class ComputersAnimationElement extends LitElement { private timeline: Timeline | null = null private initialized = false private animationController: AnimationControllerHandle | undefined + private toggleButton: HTMLButtonElement | null = null private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.initialize() } + private readonly toggleClickHandler = (event: Event) => { + this.handleToggleClick(event) + } override createRenderRoot() { return this @@ -78,9 +84,22 @@ export class ComputersAnimationElement extends LitElement { try { this.startAnimation() + this.toggleButton = this.querySelector('[data-animation-toggle]') as HTMLButtonElement | null + + if (this.toggleButton) { + addButtonEventListeners(this.toggleButton, this.toggleClickHandler, this) + } + + const defaultState = this.getDefaultAnimationState() + this.setAnimationState(defaultState) + if (defaultState === 'paused') { + this.timeline?.pause(0) + } + this.animationController = createAnimationController({ animationId: 'computers-animation', debugLabel: SCRIPT_NAME, + defaultState, onPause: () => { this.pause() }, @@ -100,6 +119,7 @@ export class ComputersAnimationElement extends LitElement { try { this.timeline?.pause() + this.setAnimationState('paused') } catch (error) { handleScriptError(error, context) } @@ -111,6 +131,7 @@ export class ComputersAnimationElement extends LitElement { try { this.timeline?.play() + this.setAnimationState('playing') } catch (error) { handleScriptError(error, context) } @@ -131,12 +152,97 @@ export class ComputersAnimationElement extends LitElement { this.timeline = null } + this.resetToggleButton() + this.removeAttribute('data-animation-state') this.initialized = false } catch (error) { handleScriptError(error, context) } } + private setAnimationState(state: AnimationPlayState): void { + this.setAttribute('data-animation-state', state) + this.updateToggleButton(state) + } + + private updateToggleButton(state: AnimationPlayState): void { + if (!this.toggleButton) return + + this.toggleButton.setAttribute('aria-pressed', state === 'paused' ? 'true' : 'false') + this.toggleButton.setAttribute('aria-label', state === 'paused' ? 'Play animation' : 'Pause animation') + this.toggleButton.dataset['animationState'] = state + + const pauseIcon = this.toggleButton.querySelector('[data-animation-icon="pause"]') + const playIcon = this.toggleButton.querySelector('[data-animation-icon="play"]') + + pauseIcon?.classList.toggle('hidden', state === 'paused') + playIcon?.classList.toggle('hidden', state === 'playing') + } + + private resetToggleButton(): void { + if (!this.toggleButton) return + + this.toggleButton.removeAttribute('data-animation-state') + this.toggleButton.setAttribute('aria-label', 'Pause animation') + this.toggleButton.setAttribute('aria-pressed', 'false') + + const pauseIcon = this.toggleButton.querySelector('[data-animation-icon="pause"]') + const playIcon = this.toggleButton.querySelector('[data-animation-icon="play"]') + + pauseIcon?.classList.remove('hidden') + playIcon?.classList.add('hidden') + + this.toggleButton = null + } + + private handleToggleClick(event: Event): void { + event.preventDefault() + + if (!this.toggleButton) return + + const state = this.getAnimationState() + + if (state === 'playing') { + if (this.animationController) { + this.animationController.requestPause() + } else { + this.pause() + } + return + } + + if (this.animationController) { + this.animationController.requestPlay() + return + } + + this.resume() + } + + private getAnimationState(): AnimationPlayState { + const state = this.getAttribute('data-animation-state') as AnimationPlayState | null + return state ?? 'playing' + } + + private getDefaultAnimationState(): AnimationPlayState { + if (typeof window === 'undefined') { + return 'playing' + } + + try { + if (typeof window.matchMedia === 'function') { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + if (mediaQuery.matches) { + return 'paused' + } + } + } catch { + // Best effort only; fall through to playing + } + + return 'playing' + } + private startAnimation() { if (typeof document === 'undefined') return if (document.getElementById('heroAnimation') == undefined) return diff --git a/src/components/Animations/Computers/index.astro b/src/components/Animations/Computers/index.astro index b63537600..4d4e66837 100644 --- a/src/components/Animations/Computers/index.astro +++ b/src/components/Animations/Computers/index.astro @@ -2,10 +2,25 @@ /** * Animation of laptop, table, and desktop for home page Hero */ +import Icon from '@components/Icon/index.astro' --- -
+
+
+
+

+ This dedicated route hosts the hero computers animation inside a stable shell so automated tests can + validate pause and play semantics without relying on home page content or layout changes. +

+

+ The animation below uses the same lifecycle controller and accessibility as production, + ensuring coverage reflects the real user experience. +

+ + +
+ +
+ diff --git a/test/e2e/specs/04-components/animations-computers.spec.ts b/test/e2e/specs/04-components/animations-computers.spec.ts new file mode 100644 index 000000000..35821e2dd --- /dev/null +++ b/test/e2e/specs/04-components/animations-computers.spec.ts @@ -0,0 +1,91 @@ +import { BasePage, test, expect } from '@test/e2e/helpers' +import type { Page } from '@playwright/test' + +type AnimationPlayState = 'playing' | 'paused' | null + +const selectors = { + host: 'computers-animation', + toggle: '[data-animation-toggle]', +} + +const overlaySource = 'e2e-computers-animation' + +interface FixtureOptions { + reducedMotion?: 'no-preference' | 'reduce' +} + +async function loadComputersFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { + const page = await BasePage.init(playwrightPage) + await page.page.emulateMedia({ reducedMotion: options.reducedMotion ?? 'no-preference' }) + await page.goto('/testing/animations-computers') + await page.waitForSelector(selectors.host) + return page +} + +async function getAnimationState(page: BasePage): Promise { + return await page.evaluate(() => { + return document.querySelector('computers-animation')?.getAttribute('data-animation-state') as AnimationPlayState + }) +} + +async function waitForAnimationState(page: BasePage, expected: Exclude): Promise { + await expect.poll(async () => await getAnimationState(page)).toBe(expected) +} + +async function getToggleAttributes(page: BasePage): Promise<{ label: string | null; pressed: string | null }> { + return await page.evaluate((toggleSelector) => { + const button = document.querySelector(toggleSelector) + return { + label: button?.getAttribute('aria-label') ?? null, + pressed: button?.getAttribute('aria-pressed') ?? null, + } + }, selectors.toggle) +} + +async function setOverlayPause(page: BasePage, isPaused: boolean): Promise { + await page.evaluate(({ paused, source }) => { + window.setOverlayPauseState?.(source, paused) + }, { paused: isPaused, source: overlaySource }) +} + +test.describe('Computers Animation Component', () => { + test('plays by default when no pause sources exist', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage) + + await waitForAnimationState(page, 'playing') + + const toggle = page.locator(selectors.toggle) + await expect(toggle).toHaveAttribute('aria-label', 'Pause animation') + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + }) + + test('honors prefers-reduced-motion by starting paused', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage, { reducedMotion: 'reduce' }) + + await waitForAnimationState(page, 'paused') + + const toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Play animation') + expect(toggleAttributes.pressed).toBe('true') + }) + + test('responds to overlay pause and resume actions from the animation store', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage) + + await waitForAnimationState(page, 'playing') + + await setOverlayPause(page, true) + await waitForAnimationState(page, 'paused') + + let toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Play animation') + expect(toggleAttributes.pressed).toBe('true') + + await setOverlayPause(page, false) + await waitForAnimationState(page, 'playing') + + toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Pause animation') + expect(toggleAttributes.pressed).toBe('false') + }) +}) diff --git a/test/e2e/specs/07-performance/PERFORMANCE.md b/test/e2e/specs/07-performance/PERFORMANCE.md index 86cc3395e..3b476c76d 100644 --- a/test/e2e/specs/07-performance/PERFORMANCE.md +++ b/test/e2e/specs/07-performance/PERFORMANCE.md @@ -1,6 +1,8 @@ # Performance +continue + Latest run (Dec 2, 2025): - Mobile Chrome LCP measured 4.3 s (threshold 2.5 s) From 2fd4d2e2d39116f988669669aecc768952d80742 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 5 Dec 2025 23:55:53 +0300 Subject: [PATCH 02/18] Lint fix in BreadCrumbs page object model --- src/lib/config/pwa.ts | 6 ++++++ test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/config/pwa.ts b/src/lib/config/pwa.ts index 05692e1a8..6c3da24c8 100644 --- a/src/lib/config/pwa.ts +++ b/src/lib/config/pwa.ts @@ -23,6 +23,9 @@ export const pwaConfig: PwaOptions = { }, manifestFilename: 'manifest.json', registerType: 'autoUpdate', + /** + * Options for manifest.json generation + */ manifest: { background_color: '#f3f4f6', description: contactData.company.description, @@ -57,6 +60,9 @@ export const pwaConfig: PwaOptions = { }, ], }, + /** + * Options for Workbox service worker generation + */ workbox: { // ID to be prepended to cache names cacheId: 'webstackbuilders', diff --git a/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts b/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts index 992a54775..9f85e25c2 100644 --- a/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts +++ b/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts @@ -69,7 +69,10 @@ export class BreadCrumbPage extends BasePage { return } - const navigationPromise = this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }) + const navigationPromise = this.page.waitForURL( + (url: URL) => url.pathname === targetHref, + { waitUntil: 'domcontentloaded', timeout: 15000 }, + ) await this.click(`a[href="${targetHref}"]`) await navigationPromise await this.waitForLoadState('networkidle') From 1eae48c0c09850a75ebfeacea61289931020f2a6 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 00:01:25 +0300 Subject: [PATCH 03/18] Update to tasks for lint in package.json to align with Action workflow --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fe2f5b99a..b5b4a0411 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", "format:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", "format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"", - "lint": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code && npm run lint:actions && npm run check", + "lint": "npm run lint:base && npm run lint:actions && npm run check", + "lint:base": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code", "lint:code": "npx eslint \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" \"test/**/*.{js,ts,tsx,astro}\"", "lint:tsc:check": "tsc --noEmit -p tsconfig.json --pretty false", "lint:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", From 3731f7613a922ad53009a1238c01abef6eea7da4 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 00:17:39 +0300 Subject: [PATCH 04/18] Update deployment action workflow to manually add succeed / fail deployment links for Vercel, pin Node dependency because GitHub uses 22.x and Vercel is on 24.x, which breaks build, and update dependencies --- .cache/pages.json | 11 +- .github/workflows/deployment.yml | 38 ++++++- package-lock.json | 168 +++++++++++++++---------------- package.json | 10 +- 4 files changed, 134 insertions(+), 93 deletions(-) diff --git a/.cache/pages.json b/.cache/pages.json index f52647700..d08ed4917 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,10 +22,15 @@ "contact", "offline", { - "privacy": ["my-data"] + "privacy": [ + "my-data" + ] }, { - "services": ["create-custom-font-sets", "overview"] + "services": [ + "create-custom-font-sets", + "overview" + ] }, { "tags": [ @@ -40,4 +45,4 @@ "typescript" ] } -] +] \ No newline at end of file diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 6daaba223..361d44966 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -59,11 +59,12 @@ jobs: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} - github-comment: true + github-comment: false env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Comment preview URL on PR + if: steps.vercel-preview.outcome == 'success' uses: actions/github-script@v8 with: script: | @@ -80,6 +81,25 @@ jobs: body: `āœ… Tests passed! Preview deployment ready:\n\nšŸ”— ${previewUrl}` }); + - name: Comment preview failure on PR + if: steps.vercel-preview.outcome != 'success' + uses: actions/github-script@v8 + with: + script: | + const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'; + const pr = context.payload.workflow_run.pull_requests && context.payload.workflow_run.pull_requests[0]; + if (!pr) { + core.warning('No pull request metadata available; skipping preview failure comment.'); + return; + } + const linkLine = previewUrl ? `\n\nšŸ”— ${previewUrl}` : '\n\nšŸ”— View the failed deployment in Vercel.'; + await github.rest.issues.createComment({ + issue_number: pr.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `āŒ Preview deployment failed.${linkLine}\n\nPlease review the Vercel build logs for details.` + }); + deploy-production: name: Deploy to Production (Vercel) runs-on: ubuntu-latest @@ -111,3 +131,19 @@ jobs: run: | echo "šŸš€ Production deployment completed" echo "Production URL: ${{ steps.vercel-production.outputs.preview-url }}" + + - name: Comment production deployment failure on commit + if: steps.vercel-production.outcome != 'success' + uses: actions/github-script@v8 + with: + script: | + const targetUrl = '${{ steps.vercel-production.outputs.preview-url }}'; + const body = targetUrl + ? `āŒ Production deployment failed.\n\nšŸ”— ${targetUrl}\n\nPlease review the Vercel logs.` + : 'āŒ Production deployment failed. Please review the Vercel logs.' + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.workflow_run.head_sha, + body, + }); diff --git a/package-lock.json b/package-lock.json index 178cf5789..8065c2dca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,10 @@ "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.28.0", - "@sentry/browser": "^10.28.0", + "@sentry/astro": "^10.29.0", + "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.86.0", + "@supabase/supabase-js": "^2.86.2", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", @@ -126,7 +126,7 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.5", + "supabase": "^2.65.6", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", @@ -137,7 +137,7 @@ "vitest-axe": "0.1.0" }, "engines": { - "node": ">=22.0.0", + "node": ">=22.0.0 <23.0.0", "npm": ">=10.0.0" }, "optionalDependencies": { @@ -6032,64 +6032,64 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.28.0.tgz", - "integrity": "sha512-FYcslFXo+Lq5/9/G83NSVK2vQlcXRkbJ6AHrMwZyPv1Qd9KJ08qoZo4buxMv63MzYDicNF591HBAqCxsv5gXsA==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.29.0.tgz", + "integrity": "sha512-M3kycMY6f3KY9a8jDYac+yG0E3ZgWVWSxlOEC5MhYyX+g7mqxkwrb3LFQyuxSm/m+CCgMTCaPOOaB2twXP6EQg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.28.0" + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.28.0.tgz", - "integrity": "sha512-vIv59ZN7Ig/oa6se/qGR69Odx3SQRoW2sIcbmJpxbjRF44Re0ZLFk6vBB3AyUvU3Lqnvabbw3y5AAwJt7Z//ug==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.29.0.tgz", + "integrity": "sha512-Y7IRsNeS99cEONu1mZWZc3HvbjNnu59Hgymm0swFFKbdgbCgdT6l85kn2oLsuq4Ew8Dw/pL/Sgpwsl9UgYFpUg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.28.0" + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.28.0.tgz", - "integrity": "sha512-umFBdM5eVJJYnUbrjrSJdjfqs21OMDz5pJtNPTNO8+KjTNSMg/QozBkEyaQZEEfdjYZy9MAcwfQPDPfEMvfUuQ==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.29.0.tgz", + "integrity": "sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.28.0", - "@sentry/core": "10.28.0" + "@sentry-internal/browser-utils": "10.29.0", + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.28.0.tgz", - "integrity": "sha512-/5KnIJXms0DHiqwOsND23fBMIJ1wUzAH5DiGHdY5yHGQTYy9BmVgUxW/Pv57kXqkgA3nBvE38z5Nu6+Cq6uixw==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.29.0.tgz", + "integrity": "sha512-typY4JrpAQQGPuSyd/BD8+nNCbvTV2UVvKzr+iKgI0m1qc4Dz8tHZ4Nfais2Z8eYn/pL1kqVQN5ERTmJoYFdIw==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.28.0", - "@sentry/core": "10.28.0" + "@sentry-internal/replay": "10.29.0", + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/astro": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.28.0.tgz", - "integrity": "sha512-NoM7ZXuudbSm5tSw9Ys23Sbun+3NoKwmDpQuhiR227Bc6k6guqW5dxZ6YzKsApf+HsSQUyDM/G1+3R7dZz/cXw==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.29.0.tgz", + "integrity": "sha512-pqnPY7qXTcOqtFAmdwafKpRF31FYuobIJXAt7rfadO5QNFUZPjdrEdayax7IS6Sh+CvTp+UW0yY1WGcckVU+nQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.28.0", - "@sentry/core": "10.28.0", - "@sentry/node": "10.28.0", + "@sentry/browser": "10.29.0", + "@sentry/core": "10.29.0", + "@sentry/node": "10.29.0", "@sentry/vite-plugin": "^4.1.0" }, "engines": { @@ -6109,16 +6109,16 @@ } }, "node_modules/@sentry/browser": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.28.0.tgz", - "integrity": "sha512-OJY5L/2IDB82Eh5Ko83I9YgBN45VBtFi0TFUxSrVDcdeha1tC9YS/975U294K9T2B0kKG65jF8JkWa6x3Gi6HA==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.29.0.tgz", + "integrity": "sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.28.0", - "@sentry-internal/feedback": "10.28.0", - "@sentry-internal/replay": "10.28.0", - "@sentry-internal/replay-canvas": "10.28.0", - "@sentry/core": "10.28.0" + "@sentry-internal/browser-utils": "10.29.0", + "@sentry-internal/feedback": "10.29.0", + "@sentry-internal/replay": "10.29.0", + "@sentry-internal/replay-canvas": "10.29.0", + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" @@ -6321,18 +6321,18 @@ } }, "node_modules/@sentry/core": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.28.0.tgz", - "integrity": "sha512-9yFIPxyfWkDzt+IaRjboeNiXOKi22ZRGG3ELmZlLak8JCC+vA+q/+AmF/8Jnw59WlL3/KVC1Q8+t8bLCkxlswg==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.29.0.tgz", + "integrity": "sha512-olQ2DU9dA/Bwsz3PtA9KNXRMqBWRQSkPw+MxwWEoU1K1qtiM9L0j6lbEFb5iSY3d7WYD5MB+1d5COugjSBrHtw==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.28.0.tgz", - "integrity": "sha512-aih3iqagUU/9Xa6RObgdS9cKL3q5eerYNMJoO9SflMgeyhHBM5BRqo0IPSMQ9nuogrDBp443sgtW450VXYO7Bg==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.29.0.tgz", + "integrity": "sha512-9j8VzV06VCj+H8tlxpfa7BNN4HzH5exv68WOufdMTXzzWLOXnzrdNDoYplm1G2S3LMvWsc1SVI3a8A0yBY7oWg==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -6365,9 +6365,9 @@ "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.19.0", - "@sentry/core": "10.28.0", - "@sentry/node-core": "10.28.0", - "@sentry/opentelemetry": "10.28.0", + "@sentry/core": "10.29.0", + "@sentry/node-core": "10.29.0", + "@sentry/opentelemetry": "10.29.0", "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, @@ -6376,14 +6376,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.28.0.tgz", - "integrity": "sha512-OOmNtMSPHjiVb+dmTC9Lq+uIrC2FplZSdst033mH+ucBF7xjyY1/WAk02pw+hqNVFQKwaItqhGNFTmC7aST60Q==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.29.0.tgz", + "integrity": "sha512-f/Y0okHhPPb5HnYNBqCivJ2YuXtSadvcIx16dzU5mHQxZhgGednUCPEX7rsvPcd4HneQz12HKLqxbAmNu+b3FA==", "license": "MIT", "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.28.0", - "@sentry/opentelemetry": "10.28.0", + "@sentry/core": "10.29.0", + "@sentry/opentelemetry": "10.29.0", "import-in-the-middle": "^2" }, "engines": { @@ -6400,12 +6400,12 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.28.0.tgz", - "integrity": "sha512-SiSLN294vlxipDG0/FvMYIFmyXEffXmPvvdyp5DUqY8NyJytYPPUJ3DuQhc9XRVyEd9XeOgra661nxNIKPr1pg==", + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.29.0.tgz", + "integrity": "sha512-5QvtAwS73HlI/+OTF1poAFELzsc0se+PHmMsXGGrOeNBvjCr3ZE8qvke09aeMn7uRImf3Nc9J6i2KtSHJnbKPA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.28.0" + "@sentry/core": "10.29.0" }, "engines": { "node": ">=18" @@ -7189,9 +7189,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.86.0.tgz", - "integrity": "sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.86.2.tgz", + "integrity": "sha512-7k8IAhgSnZuD9Zex2+ohHKY3aWGDd4ls0xlxMGl3/jPyHSSXrIYfmtJyUH0+DPd4B3psBqHC0Ev0/nZEHdW58w==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7201,9 +7201,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.86.0.tgz", - "integrity": "sha512-AlOoVfeaq9XGlBFIyXTmb+y+CZzxNO4wWbfgRM6iPpNU5WCXKawtQYSnhivi3UVxS7GA0rWovY4d6cIAxZAojA==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.86.2.tgz", + "integrity": "sha512-OLpy3NIlj7q3yGMFwUpPkDPJbRx4aU+u73SiXqiMnA5ARwzVcOReSzI2u4oOqioE+3ud0fRx7sRsfoklBwYOmg==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7213,9 +7213,9 @@ } }, "node_modules/@supabase/postgrest-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.86.0.tgz", - "integrity": "sha512-QVf+wIXILcZJ7IhWhWn+ozdf8B+oO0Ulizh2AAPxD/6nQL+x3r9lJ47a+fpc/jvAOGXMbkeW534Kw6jz7e8iIA==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.86.2.tgz", + "integrity": "sha512-KVgOF2QASvUfQnzMGAmxR7f3ZF/eZ8PFp2F5Q7SAPQlmB83FEaZ7C/QMzfVXXqkMbotfh96xcaBNSKnxowFObA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7225,9 +7225,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.86.0.tgz", - "integrity": "sha512-dyS8bFoP29R/sj5zLi0AP3JfgG8ar1nuImcz5jxSx7UIW7fbFsXhUCVrSY2Ofo0+Ev6wiATiSdBOzBfWaiFyPA==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.86.2.tgz", + "integrity": "sha512-uLUYrOMeK1qXHISxdMFVfBs0sGV5PmqYewIHvLBnMYbb//LERojxfKlVSJBgZ+aAwxANmtQKcprjGZI7DJ6lNQ==", "license": "MIT", "dependencies": { "@types/phoenix": "^1.6.6", @@ -7240,9 +7240,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.86.0.tgz", - "integrity": "sha512-PM47jX/Mfobdtx7NNpoj9EvlrkapAVTQBZgGGslEXD6NS70EcGjhgRPBItwHdxZPM5GwqQ0cGMN06uhjeY2mHQ==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.86.2.tgz", + "integrity": "sha512-zyR4PkO7R4f4/xRBVJho3Dm7y4512BoCqGmD7LjNV2GVtWt8vEmambiuMB2Ty3l76mqw+ynQyHY8yFWSERrHXA==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.0", @@ -7253,16 +7253,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.86.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.86.0.tgz", - "integrity": "sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==", + "version": "2.86.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.86.2.tgz", + "integrity": "sha512-KXoiqFf7zZhL/+lj7oBFFUvVDQ6gy03v9wQ5E++f7xiJUuqmI4DuBhrv8uFo6B2EGTQTA3vkXjbxmYIug/zfWw==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.86.0", - "@supabase/functions-js": "2.86.0", - "@supabase/postgrest-js": "2.86.0", - "@supabase/realtime-js": "2.86.0", - "@supabase/storage-js": "2.86.0" + "@supabase/auth-js": "2.86.2", + "@supabase/functions-js": "2.86.2", + "@supabase/postgrest-js": "2.86.2", + "@supabase/realtime-js": "2.86.2", + "@supabase/storage-js": "2.86.2" }, "engines": { "node": ">=20.0.0" @@ -14507,9 +14507,9 @@ } }, "node_modules/iceberg-js": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.0.tgz", - "integrity": "sha512-kmgmea2nguZEvRqW79gDqNXyxA3OS5WIgMVffrHpqXV4F/J4UmNIw2vstixioLTNSkd5rFB8G0s3Lwzogm6OFw==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", "license": "MIT", "engines": { "node": ">=20.0.0" @@ -21381,9 +21381,9 @@ } }, "node_modules/supabase": { - "version": "2.65.5", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.65.5.tgz", - "integrity": "sha512-+G3T09fA25nuorldsbHOkDWs9tPv4l+cm4pLag7ASSf+k485bfR08qaDaRZBSq1fi+318YC6W4n9HnGeHJJAdQ==", + "version": "2.65.6", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.65.6.tgz", + "integrity": "sha512-PKeKFwIpx/H65WZ8BVqvQU1cve2n2Er++Yo0EGjSfV/vNwnLkpdnlpD9o8ZJJLDF/hxlduOCwxHiTnBimnbXdA==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index b5b4a0411..d41c5d413 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "private": "true", "engines": { - "node": ">=22.0.0", + "node": ">=22.0.0 <23.0.0", "npm": ">=10.0.0" }, "type": "module", @@ -74,10 +74,10 @@ "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.28.0", - "@sentry/browser": "^10.28.0", + "@sentry/astro": "^10.29.0", + "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.86.0", + "@supabase/supabase-js": "^2.86.2", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", @@ -180,7 +180,7 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.5", + "supabase": "^2.65.6", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", From 260e3ba0580d04a18efc8a753015c9c040ce1e56 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 00:27:50 +0300 Subject: [PATCH 05/18] Spelling fix, alone to trigger new PR workflow --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index ab4b772e1..85a9a09d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -96,6 +96,7 @@ "squoosh", "tanabata", "TIMESTAMPTZ", + "tktco", "Trino", "TRUNC", "Tscompile", From 3d2597df7fc0e53b06a5ba0c48335f2d1276963f Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 00:43:24 +0300 Subject: [PATCH 06/18] Update deployment workflow to always update with deployment outcome even on CLI failure return code --- .cache/pages.json | 11 +++-------- .github/workflows/deployment.yml | 7 ++++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.cache/pages.json b/.cache/pages.json index d08ed4917..f52647700 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,15 +22,10 @@ "contact", "offline", { - "privacy": [ - "my-data" - ] + "privacy": ["my-data"] }, { - "services": [ - "create-custom-font-sets", - "overview" - ] + "services": ["create-custom-font-sets", "overview"] }, { "tags": [ @@ -45,4 +40,4 @@ "typescript" ] } -] \ No newline at end of file +] diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 361d44966..a0de1be12 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -64,7 +64,7 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Comment preview URL on PR - if: steps.vercel-preview.outcome == 'success' + if: always() && steps.vercel-preview.outcome == 'success' uses: actions/github-script@v8 with: script: | @@ -82,7 +82,7 @@ jobs: }); - name: Comment preview failure on PR - if: steps.vercel-preview.outcome != 'success' + if: always() && steps.vercel-preview.outcome != 'success' uses: actions/github-script@v8 with: script: | @@ -128,12 +128,13 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - name: Log production deployment + if: always() && steps.vercel-production.outcome == 'success' run: | echo "šŸš€ Production deployment completed" echo "Production URL: ${{ steps.vercel-production.outputs.preview-url }}" - name: Comment production deployment failure on commit - if: steps.vercel-production.outcome != 'success' + if: always() && steps.vercel-production.outcome != 'success' uses: actions/github-script@v8 with: script: | From 0a25ea8fb9d86bc640e67ddaa6c223fd034d2b29 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 01:21:21 +0300 Subject: [PATCH 07/18] Update deployment workflow with wider permissions for Vercel bot --- .github/workflows/deployment.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index a0de1be12..ee599f575 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -7,6 +7,12 @@ on: types: - completed +permissions: + contents: write + pull-requests: write + issues: write + deployments: write + jobs: verify-ci: name: Verify CI Results From c4f64e2b0ba7933db1e5b8d194b0a650f432e9fe Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 02:17:32 +0300 Subject: [PATCH 08/18] Improve notification card for Vercel deployment action workflow --- .github/workflows/deployment.yml | 113 ++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index ee599f575..9984da882 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -75,30 +75,125 @@ jobs: with: script: | const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'; - const pr = context.payload.workflow_run.pull_requests && context.payload.workflow_run.pull_requests[0]; - if (!pr) { - core.warning('No pull request metadata available; skipping preview comment.'); + const workflowRun = context.payload.workflow_run; + const pr = workflowRun?.pull_requests?.[0]; + if (!pr || !previewUrl) { + core.warning('Missing pull request metadata or preview URL; skipping preview success comment.'); return; } - await github.rest.issues.createComment({ - issue_number: pr.number, + + const commentTag = ''; + const branch = workflowRun.head_branch ?? 'unknown-branch'; + const sha = workflowRun.head_sha ?? ''; + const shortSha = sha ? sha.slice(0, 7) : 'unknown'; + const commitUrl = sha + ? `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}`; + const actor = workflowRun.actor ?? 'workflow_run'; + + const body = [ + commentTag, + 'āœ… **Preview deployment ready**', + '', + '| Field | Value |', + '| --- | --- |', + `| Branch | \`${branch}\` |`, + `| Commit | [${shortSha}](${commitUrl}) |`, + `| Preview | [Open preview](${previewUrl}) |`, + '', + `_Triggered by @${actor}_` + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, - body: `āœ… Tests passed! Preview deployment ready:\n\nšŸ”— ${previewUrl}` + issue_number: pr.number, + per_page: 100 }); + const existingComment = comments.find((comment) => comment.body?.includes(commentTag)); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body + }); + } - name: Comment preview failure on PR if: always() && steps.vercel-preview.outcome != 'success' uses: actions/github-script@v8 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} with: script: | const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'; - const pr = context.payload.workflow_run.pull_requests && context.payload.workflow_run.pull_requests[0]; + const workflowRun = context.payload.workflow_run; + const pr = workflowRun?.pull_requests?.[0]; if (!pr) { core.warning('No pull request metadata available; skipping preview failure comment.'); return; } - const linkLine = previewUrl ? `\n\nšŸ”— ${previewUrl}` : '\n\nšŸ”— View the failed deployment in Vercel.'; + + const fallbackRunUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const fetchDeploymentUrl = async () => { + if (!process.env.VERCEL_TOKEN || !process.env.VERCEL_PROJECT_ID) { + core.info('Missing Vercel credentials; cannot query deployment API.'); + return null; + } + const query = new URLSearchParams({ + projectId: process.env.VERCEL_PROJECT_ID, + 'meta-githubCommitSha': workflowRun?.head_sha ?? '', + limit: '1' + }); + if (process.env.VERCEL_ORG_ID) { + query.set('teamId', process.env.VERCEL_ORG_ID); + } + if (typeof fetch !== 'function') { + core.info('Fetch API unavailable in this runtime.'); + return null; + } + const response = await fetch(`https://api.vercel.com/v6/deployments?${query.toString()}`, { + headers: { + Authorization: `Bearer ${process.env.VERCEL_TOKEN}` + } + }); + if (!response.ok) { + core.warning(`Unable to fetch deployment info (status ${response.status}).`); + return null; + } + const data = await response.json(); + const deployment = data?.deployments?.[0]; + if (deployment?.url) { + return `https://${deployment.url}`; + } + if (deployment?.inspectorUrl) { + return deployment.inspectorUrl.startsWith('http') + ? deployment.inspectorUrl + : `https://${deployment.inspectorUrl}`; + } + return null; + }; + + let failedDeploymentUrl = previewUrl || await fetchDeploymentUrl(); + if (!failedDeploymentUrl) { + failedDeploymentUrl = fallbackRunUrl; + } + + const linkText = failedDeploymentUrl === fallbackRunUrl + ? 'View the workflow logs' + : 'Open the failed Vercel deployment'; + const linkLine = `\n\nšŸ”— [${linkText}](${failedDeploymentUrl})`; await github.rest.issues.createComment({ issue_number: pr.number, owner: context.repo.owner, @@ -147,7 +242,7 @@ jobs: const targetUrl = '${{ steps.vercel-production.outputs.preview-url }}'; const body = targetUrl ? `āŒ Production deployment failed.\n\nšŸ”— ${targetUrl}\n\nPlease review the Vercel logs.` - : 'āŒ Production deployment failed. Please review the Vercel logs.' + : 'āŒ Production deployment failed. Please review the Vercel logs.'; await github.rest.repos.createCommitComment({ owner: context.repo.owner, repo: context.repo.repo, From 65e4faed2e244b6c3732e5eddb71f9947a70ab45 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 02:58:02 +0300 Subject: [PATCH 09/18] Update link for failed preview in Vercel deployment action workflow --- .github/workflows/deployment.yml | 56 ++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 9984da882..685dcf45b 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -72,13 +72,63 @@ jobs: - name: Comment preview URL on PR if: always() && steps.vercel-preview.outcome == 'success' uses: actions/github-script@v8 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} with: script: | - const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'; const workflowRun = context.payload.workflow_run; const pr = workflowRun?.pull_requests?.[0]; - if (!pr || !previewUrl) { - core.warning('Missing pull request metadata or preview URL; skipping preview success comment.'); + if (!pr) { + core.warning('Missing pull request metadata; skipping preview success comment.'); + return; + } + + const rawPreviewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'.trim(); + const isVercelUrl = (url) => typeof url === 'string' && /vercel\.(app|com)/.test(url); + const fetchDeploymentUrl = async () => { + if (!process.env.VERCEL_TOKEN || !process.env.VERCEL_PROJECT_ID) { + core.info('Missing Vercel credentials; cannot query deployment API.'); + return null; + } + if (typeof fetch !== 'function') { + core.info('Fetch API unavailable in this runtime.'); + return null; + } + const query = new URLSearchParams({ + projectId: process.env.VERCEL_PROJECT_ID, + 'meta-githubCommitSha': workflowRun?.head_sha ?? '', + limit: '1' + }); + if (process.env.VERCEL_ORG_ID) { + query.set('teamId', process.env.VERCEL_ORG_ID); + } + const response = await fetch(`https://api.vercel.com/v6/deployments?${query.toString()}`, { + headers: { + Authorization: `Bearer ${process.env.VERCEL_TOKEN}` + } + }); + if (!response.ok) { + core.warning(`Unable to fetch deployment info (status ${response.status}).`); + return null; + } + const data = await response.json(); + const deployment = data?.deployments?.[0]; + if (deployment?.url) { + return `https://${deployment.url}`; + } + if (deployment?.inspectorUrl) { + return deployment.inspectorUrl.startsWith('http') + ? deployment.inspectorUrl + : `https://${deployment.inspectorUrl}`; + } + return null; + }; + + let previewUrl = isVercelUrl(rawPreviewUrl) ? rawPreviewUrl : await fetchDeploymentUrl(); + if (!isVercelUrl(previewUrl)) { + core.warning('Unable to resolve Vercel preview URL; skipping preview success comment.'); return; } From 7d0e8aeddc5dc077820256eee47d20b71090b5ce Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 03:02:30 +0300 Subject: [PATCH 10/18] Update package.json prepare script so it only runs Husky when a .git directory is present --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d41c5d413..f511972ba 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "test:e2e:full": "dotenv -e .env.development -- cross-env FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test", "test:unit": "FORCE_COLOR=1 npx vitest run", "upgrade": "npx @astrojs/upgrade", - "prepare": "husky" + "prepare": "node -e \"const fs=require('node:fs');if(!fs.existsSync('.git')){console.log('Skipping Husky install (missing .git directory)');process.exit(0);}\" && husky" }, "dependencies": { "@astrojs/check": "0.9.6", From 0f70211a3923b0b8278c17311f2563feb1aa7689 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 03:10:22 +0300 Subject: [PATCH 11/18] Trivial doc fix to trigger Action workflow for debugging --- _TODO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/_TODO.md b/_TODO.md index a9c94e894..203f11f57 100644 --- a/_TODO.md +++ b/_TODO.md @@ -22,6 +22,8 @@ See note in src/components/scripts/sentry/client.ts - "User Feedback - allow use docs/CONTACT_FORM.md +Where to upload to? + ## Search Add Upstash Search as a Vercel Marketplace Integration. From 5f2aed563f15e55825b5f121e46c58d1c2e44741 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 03:43:02 +0300 Subject: [PATCH 12/18] Refactor date-check strategy in privacy policy unit test to run on Vercel --- .../PrivacyPolicyVersion/index.ts | 52 ++++++++++++------- .../privacy-policy-version.spec.ts | 46 ++++++++++++++++ 2 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 test/unit/integrations/privacy-policy-version.spec.ts diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index 6e95aa1f3..79fcf8549 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -16,15 +16,17 @@ import { execSync } from 'node:child_process' import type { AstroIntegration } from 'astro' -import { BuildError } from '../../lib/errors/BuildError' + +const PRIVACY_POLICY_PATH = 'src/pages/privacy/index.astro' + +export const toIsoDateString = (date: Date): string => date.toISOString().slice(0, 10) /** * Get privacy policy version from git commit date * @param filePath - Path to privacy policy file (relative to project root) * @returns ISO date string (YYYY-MM-DD) of last commit - * @throws {BuildError} If git command fails or returns empty result */ -function getPrivacyPolicyVersionFromGit(filePath: string): string { +function getPrivacyPolicyVersionFromGit(filePath: string): string | null { try { // Get last commit date for privacy policy file in YYYY-MM-DD format const lastCommitDate = execSync( @@ -36,21 +38,37 @@ function getPrivacyPolicyVersionFromGit(filePath: string): string { return lastCommitDate } - // If no commits found (new file), throw error - throw new BuildError( - `No git commits found for privacy policy file: ${filePath}`, - { phase: 'config-setup', filePath }, + console.warn( + `[privacy-policy-version] No git commits found for privacy policy file: ${filePath}. Falling back to current date.`, ) + return null } catch (error) { - // Git not available or command failed - if (error instanceof BuildError) { - throw error - } - throw new BuildError( - `Could not get privacy policy version from git: ${error instanceof Error ? error.message : String(error)}`, - { phase: 'config-setup', tool: 'git', cause: error }, + console.warn( + `[privacy-policy-version] Could not get privacy policy version from git: ${error instanceof Error ? error.message : String(error)}`, ) + return null + } +} + +/** + * Resolve privacy policy version using env, git metadata, or current date fallback. + */ +export function resolvePrivacyPolicyVersion(): string { + const envVersion = process.env.PRIVACY_POLICY_VERSION?.trim() + if (envVersion) { + console.log(`āœ… Privacy policy version sourced from env: ${envVersion}`) + return envVersion + } + + const gitVersion = getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_PATH) + if (gitVersion) { + console.log(`āœ… Privacy policy version set from git: ${gitVersion}`) + return gitVersion } + + const fallback = toIsoDateString(new Date()) + console.log(`āš ļø Privacy policy version fallback applied: ${fallback}`) + return fallback } /** @@ -61,11 +79,7 @@ export function privacyPolicyVersion(): AstroIntegration { name: 'privacy-policy-version', hooks: { 'astro:config:setup': async ({ updateConfig }) => { - // Get version from git commit date - const privacyPolicyPath = 'src/pages/privacy/index.astro' - const version = getPrivacyPolicyVersionFromGit(privacyPolicyPath) - - console.log(`āœ… Privacy policy version set to: ${version}`) + const version = resolvePrivacyPolicyVersion() // Inject as Vite define so it's available as import.meta.env.PRIVACY_POLICY_VERSION updateConfig({ diff --git a/test/unit/integrations/privacy-policy-version.spec.ts b/test/unit/integrations/privacy-policy-version.spec.ts new file mode 100644 index 000000000..bd006ddb9 --- /dev/null +++ b/test/unit/integrations/privacy-policy-version.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, afterEach, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})) + +import { execSync } from 'node:child_process' + +import { resolvePrivacyPolicyVersion } from '../../../src/integrations/PrivacyPolicyVersion/index' + +describe('resolvePrivacyPolicyVersion', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.useRealTimers() + }) + + it('returns PRIVACY_POLICY_VERSION when provided', () => { + vi.stubEnv('PRIVACY_POLICY_VERSION', '2024-01-02') + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2024-01-02') + expect(execSync).not.toHaveBeenCalled() + }) + + it('falls back to git metadata when env var is absent', () => { + vi.mocked(execSync).mockReturnValue('2023-05-05\n' as never) + + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2023-05-05') + }) + + it('falls back to the current date when git is unavailable', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T12:00:00Z')) + vi.mocked(execSync).mockImplementation(() => { + throw new Error('git missing') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2025-12-06') + }) +}) From b233de59b2a8379f02c5b21583909b681027a309 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 03:54:49 +0300 Subject: [PATCH 13/18] Fix lint error in privacy policy unit test --- src/integrations/PrivacyPolicyVersion/index.ts | 4 +++- src/lib/config/environmentServer.ts | 4 ++++ test/unit/integrations/privacy-policy-version.spec.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index 79fcf8549..4ce4bc617 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -16,6 +16,7 @@ import { execSync } from 'node:child_process' import type { AstroIntegration } from 'astro' +import { getOptionalEnv } from '../../lib/config/environmentServer' const PRIVACY_POLICY_PATH = 'src/pages/privacy/index.astro' @@ -54,7 +55,8 @@ function getPrivacyPolicyVersionFromGit(filePath: string): string | null { * Resolve privacy policy version using env, git metadata, or current date fallback. */ export function resolvePrivacyPolicyVersion(): string { - const envVersion = process.env.PRIVACY_POLICY_VERSION?.trim() + const rawEnvVersion = getOptionalEnv('PRIVACY_POLICY_VERSION') + const envVersion = typeof rawEnvVersion === 'string' ? rawEnvVersion.trim() : '' if (envVersion) { console.log(`āœ… Privacy policy version sourced from env: ${envVersion}`) return envVersion diff --git a/src/lib/config/environmentServer.ts b/src/lib/config/environmentServer.ts index 10a8625a0..0aef72f5c 100644 --- a/src/lib/config/environmentServer.ts +++ b/src/lib/config/environmentServer.ts @@ -66,3 +66,7 @@ export function getSentryAuthToken(): string { } return token } + +export const getOptionalEnv = (key: string): string | undefined => { + return process.env[key] +} diff --git a/test/unit/integrations/privacy-policy-version.spec.ts b/test/unit/integrations/privacy-policy-version.spec.ts index bd006ddb9..a0dc915a4 100644 --- a/test/unit/integrations/privacy-policy-version.spec.ts +++ b/test/unit/integrations/privacy-policy-version.spec.ts @@ -4,19 +4,24 @@ vi.mock('node:child_process', () => ({ execSync: vi.fn(), })) +vi.mock('../../../src/lib/config/environmentServer', () => ({ + getOptionalEnv: vi.fn(), +})) + import { execSync } from 'node:child_process' +import { getOptionalEnv } from '../../../src/lib/config/environmentServer' import { resolvePrivacyPolicyVersion } from '../../../src/integrations/PrivacyPolicyVersion/index' describe('resolvePrivacyPolicyVersion', () => { afterEach(() => { vi.restoreAllMocks() - vi.unstubAllEnvs() + vi.clearAllMocks() vi.useRealTimers() }) it('returns PRIVACY_POLICY_VERSION when provided', () => { - vi.stubEnv('PRIVACY_POLICY_VERSION', '2024-01-02') + vi.mocked(getOptionalEnv).mockReturnValueOnce('2024-01-02') const version = resolvePrivacyPolicyVersion() expect(version).toBe('2024-01-02') @@ -24,6 +29,7 @@ describe('resolvePrivacyPolicyVersion', () => { }) it('falls back to git metadata when env var is absent', () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) vi.mocked(execSync).mockReturnValue('2023-05-05\n' as never) const version = resolvePrivacyPolicyVersion() @@ -32,6 +38,7 @@ describe('resolvePrivacyPolicyVersion', () => { }) it('falls back to the current date when git is unavailable', () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) vi.useFakeTimers() vi.setSystemTime(new Date('2025-12-06T12:00:00Z')) vi.mocked(execSync).mockImplementation(() => { From bfc5124efdc0455e38629fd8ed55cb5ddfcd5278 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 04:02:19 +0300 Subject: [PATCH 14/18] Fix import error in privacy policy unit test --- .../__tests__/index.spec.ts | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts index 0a92d8adc..11856ad17 100644 --- a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts +++ b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts @@ -2,17 +2,24 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { execSync } from 'node:child_process' import { TestError } from '@test/errors' +vi.mock('../../../lib/config/environmentServer', () => ({ + getOptionalEnv: vi.fn(() => undefined), +})) + // Mock child_process vi.mock('node:child_process', () => ({ execSync: vi.fn(), })) +import { getOptionalEnv } from '../../../lib/config/environmentServer' + describe('PrivacyPolicyVersion Integration', () => { let consoleLogSpy: ReturnType let consoleWarnSpy: ReturnType beforeEach(() => { - // Spy on console methods + vi.mocked(getOptionalEnv).mockReturnValue(undefined) + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -27,6 +34,7 @@ describe('PrivacyPolicyVersion Integration', () => { // Restore console methods consoleLogSpy.mockRestore() consoleWarnSpy.mockRestore() + vi.useRealTimers() }) describe('getPrivacyPolicyVersionFromGit', () => { @@ -61,7 +69,7 @@ describe('PrivacyPolicyVersion Integration', () => { }) expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Privacy policy version set to: 2024-03-15'), + expect.stringContaining('Privacy policy version set from git: 2024-03-15'), ) }) @@ -89,44 +97,58 @@ describe('PrivacyPolicyVersion Integration', () => { }) }) - it('should throw BuildError when git command fails', async () => { - // Mock git command failure + it('logs warning and falls back when git command fails', async () => { vi.mocked(execSync).mockImplementation(() => { throw new TestError('Git command failed') }) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') const mockUpdateConfig = vi.fn() const integration = privacyPolicyVersion() - await expect( - integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, - }), - ).rejects.toThrow('Could not get privacy policy version from git') + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) - expect(consoleWarnSpy).not.toHaveBeenCalled() + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-12-06"', + }, + }, + }) + expect(consoleWarnSpy).toHaveBeenCalled() }) - it('should throw BuildError when git returns empty string', async () => { - // Mock git command returning empty string + it('logs warning and falls back when git returns empty string', async () => { vi.mocked(execSync).mockReturnValue('') + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-05-01T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') const mockUpdateConfig = vi.fn() const integration = privacyPolicyVersion() - await expect( - integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, - }), - ).rejects.toThrow('No git commits found for privacy policy file') + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-05-01"', + }, + }, + }) + expect(consoleWarnSpy).toHaveBeenCalled() }) }) From 21503481abbaa82ecd2c902e21bff4586722abdc Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 6 Dec 2025 23:53:36 +0300 Subject: [PATCH 15/18] Remove relative paths from Husky called from 'prepare' task on npm install and privacy policy version integration to avoid breakage during build on Vercel --- .husky/prepare.js | 30 ++++++++++ package-lock.json | 8 +-- package.json | 4 +- .../__tests__/index.spec.ts | 55 +++++++++++++++++++ .../PrivacyPolicyVersion/index.ts | 37 +++++++++++-- 5 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 .husky/prepare.js diff --git a/.husky/prepare.js b/.husky/prepare.js new file mode 100644 index 000000000..8c520f5bf --- /dev/null +++ b/.husky/prepare.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/* eslint-disable no-undef */ +/** + * Husky prepare script to install git hooks. It's designed to quiet warnings on + * CI environments where .git directory may be missing when "prepare" script runs + * (e.g., during "npm install" step). + */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { execSync } from 'node:child_process' + +const projectRoot = process.cwd() +const gitDirectory = join(projectRoot, '.git') + +if (!existsSync(gitDirectory)) { + console.warn(`āœ… Skipping Husky install: missing .git directory at ${gitDirectory}`) + process.exit(0) +} + +try { + console.log(`Running Husky install from ${projectRoot}`) + execSync('husky', { stdio: 'inherit', cwd: projectRoot }) + console.log('āœ… Husky install complete') +} catch (error) { + console.error('āŒ Husky install failed') + const status = typeof error === 'object' && error && 'status' in error && typeof error.status === 'number' + ? error.status + : 1 + process.exit(status) +} diff --git a/package-lock.json b/package-lock.json index 8065c2dca..cc5e34f12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jsdoc": "61.4.1", + "eslint-plugin-jsdoc": "61.4.2", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-security": "3.0.1", "eslint-plugin-yml": "1.19.0", @@ -12558,9 +12558,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "61.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.4.1.tgz", - "integrity": "sha512-3c1QW/bV25sJ1MsIvsvW+EtLtN6yZMduw7LVQNVt72y2/5BbV5Pg5b//TE5T48LRUxoEQGaZJejCmcj3wCxBzw==", + "version": "61.4.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.4.2.tgz", + "integrity": "sha512-WzZNvefoUaG/JWikVFhNLYqE2BEd6LQD2ZyfJOe1Ld3Cir05csDMMf0AihGwrSbB/e7fHRSfQOZ4F/hik9fQww==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index f511972ba..410a57c18 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "test:e2e:full": "dotenv -e .env.development -- cross-env FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test", "test:unit": "FORCE_COLOR=1 npx vitest run", "upgrade": "npx @astrojs/upgrade", - "prepare": "node -e \"const fs=require('node:fs');if(!fs.existsSync('.git')){console.log('Skipping Husky install (missing .git directory)');process.exit(0);}\" && husky" + "prepare": "node .husky/prepare.js" }, "dependencies": { "@astrojs/check": "0.9.6", @@ -158,7 +158,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jsdoc": "61.4.1", + "eslint-plugin-jsdoc": "61.4.2", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-security": "3.0.1", "eslint-plugin-yml": "1.19.0", diff --git a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts index 11856ad17..7418ba24a 100644 --- a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts +++ b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { TestError } from '@test/errors' vi.mock('../../../lib/config/environmentServer', () => ({ @@ -11,6 +12,10 @@ vi.mock('node:child_process', () => ({ execSync: vi.fn(), })) +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), +})) + import { getOptionalEnv } from '../../../lib/config/environmentServer' describe('PrivacyPolicyVersion Integration', () => { @@ -19,6 +24,7 @@ describe('PrivacyPolicyVersion Integration', () => { beforeEach(() => { vi.mocked(getOptionalEnv).mockReturnValue(undefined) + vi.mocked(existsSync).mockReturnValue(true) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -150,6 +156,35 @@ describe('PrivacyPolicyVersion Integration', () => { }) expect(consoleWarnSpy).toHaveBeenCalled() }) + + it('skips git lookup entirely when repository metadata is missing', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-15T00:00:00Z')) + + const { privacyPolicyVersion } = await import('../index') + + const mockUpdateConfig = vi.fn() + const integration = privacyPolicyVersion() + + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(execSync).not.toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[privacy-policy-version] Git metadata not found. Skipping git lookup.', + ) + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2026-01-15"', + }, + }, + }) + }) }) describe('integration metadata', () => { @@ -211,5 +246,25 @@ describe('PrivacyPolicyVersion Integration', () => { expect.any(Object), ) }) + + it('executes git commands from the project root directory', async () => { + vi.mocked(execSync).mockReturnValue('2024-03-15') + + const { privacyPolicyVersion } = await import('../index') + + const mockUpdateConfig = vi.fn() + const integration = privacyPolicyVersion() + + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(execSync).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ cwd: process.cwd() }), + ) + }) }) }) diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index 4ce4bc617..72f29c689 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -15,10 +15,14 @@ */ import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import type { AstroIntegration } from 'astro' import { getOptionalEnv } from '../../lib/config/environmentServer' -const PRIVACY_POLICY_PATH = 'src/pages/privacy/index.astro' +const PROJECT_ROOT = process.cwd() +const PRIVACY_POLICY_PATH = join(PROJECT_ROOT, 'src', 'pages', 'privacy', 'index.astro') +const GIT_DIRECTORY_PATH = join(PROJECT_ROOT, '.git') export const toIsoDateString = (date: Date): string => date.toISOString().slice(0, 10) @@ -32,7 +36,7 @@ function getPrivacyPolicyVersionFromGit(filePath: string): string | null { // Get last commit date for privacy policy file in YYYY-MM-DD format const lastCommitDate = execSync( `git log -1 --format=%cd --date=format:%Y-%m-%d -- ${filePath}`, - { encoding: 'utf-8' }, + { encoding: 'utf-8', cwd: PROJECT_ROOT }, ).trim() if (lastCommitDate) { @@ -51,6 +55,23 @@ function getPrivacyPolicyVersionFromGit(filePath: string): string | null { } } +/** + * Determine whether git metadata is available before issuing git commands. + * Vercel preview builds, for example, do not clone the repo with git history, + * so attempting to run git commands will fail immediately. Checking for a git + * directory lets us skip the expensive call entirely. + */ +function hasGitRepository(): boolean { + try { + return existsSync(GIT_DIRECTORY_PATH) + } catch (error) { + console.warn( + `[privacy-policy-version] Unable to verify git repository: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } +} + /** * Resolve privacy policy version using env, git metadata, or current date fallback. */ @@ -62,10 +83,14 @@ export function resolvePrivacyPolicyVersion(): string { return envVersion } - const gitVersion = getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_PATH) - if (gitVersion) { - console.log(`āœ… Privacy policy version set from git: ${gitVersion}`) - return gitVersion + if (hasGitRepository()) { + const gitVersion = getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_PATH) + if (gitVersion) { + console.log(`āœ… Privacy policy version set from git: ${gitVersion}`) + return gitVersion + } + } else { + console.warn('[privacy-policy-version] Git metadata not found. Skipping git lookup.') } const fallback = toIsoDateString(new Date()) From 256f31c5bfba1bf93a899924776358002b2d6325 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 7 Dec 2025 02:23:09 +0300 Subject: [PATCH 16/18] Add single API endpoint to trigger all CRON jobs, avoiding need to upgrade Vercel plan atm --- playwright.config.ts | 5 +- src/pages/api/cron/__tests__/runner.spec.ts | 119 ++++++++++++++++++++ src/pages/api/cron/run-all.ts | 115 +++++++++++++++++++ vercel.json | 10 +- 4 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 src/pages/api/cron/__tests__/runner.spec.ts create mode 100644 src/pages/api/cron/run-all.ts diff --git a/playwright.config.ts b/playwright.config.ts index 417758296..78ae68464 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,7 +4,10 @@ import { defineConfig, devices } from '@playwright/test' * Read environment variables from file. * https://github.com/motdotla/dotenv */ -import 'dotenv/config' +import dotenv from 'dotenv' +import { isCI } from 'src/lib/config/environmentServer' + +if ( !isCI() ) dotenv.config({ path: '.env.development' }) /** * See https://playwright.dev/docs/test-configuration. diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts new file mode 100644 index 000000000..211bff13b --- /dev/null +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { APIRoute } from 'astro' +import { GET as runAll } from '@pages/api/cron/run-all' + +const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) +const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com')) + +vi.mock('@pages/api/_environment/environmentApi', async () => { + const actual = await vi.importActual( + '@pages/api/_environment/environmentApi', + ) + return { + ...actual, + getCronSecret: getCronSecretMock, + getSiteUrl: getSiteUrlMock, + } +}) + +const buildContext = (request: Request) => ({ + request, + clientAddress: '127.0.0.1', + cookies: { + get: () => undefined, + }, +}) + +describe('cron runner', () => { + let fetchMock: ReturnType + let warnSpy: ReturnType + + const run = (request: Request) => runAll(buildContext(request) as unknown as Parameters[0]) + + beforeEach(() => { + vi.clearAllMocks() + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.unstubAllGlobals() + warnSpy.mockRestore() + }) + + const createResponse = (path: string, overrides?: Partial) => + ({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'x-vercel-elapsed-time': '123' }), + json: vi.fn().mockResolvedValue({ path }), + text: vi.fn().mockResolvedValue(''), + ...overrides, + }) as unknown as Response + + it('rejects unauthorized requests', async () => { + const request = new Request('https://example.com/api/cron/run-all') + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error.code).toBe('UNAUTHORIZED') + expect(fetchMock).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + }) + + it('calls downstream cron endpoints sequentially', async () => { + fetchMock + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-confirmations')) + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) + .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) + + const request = new Request('https://example.com/api/cron/run-all', { + method: 'GET', + headers: { + authorization: 'Bearer cron-secret', + }, + }) + + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(Array.isArray(body.results)).toBe(true) + expect(body.results).toHaveLength(3) + expect(fetchMock).toHaveBeenCalledTimes(3) + + const headersUsed = fetchMock.mock.calls[0]?.[1]?.headers as Record + expect(headersUsed.Authorization ?? headersUsed.authorization).toBe('Bearer cron-secret') + }) + + it('surfaces downstream failure details', async () => { + fetchMock + .mockResolvedValueOnce( + createResponse('/api/cron/cleanup-confirmations', { + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers(), + }), + ) + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) + .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) + + const request = new Request('https://example.com/api/cron/run-all', { + method: 'GET', + headers: { + authorization: 'Bearer cron-secret', + }, + }) + + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.code).toBe('CRON_RUNNER_TARGET_FAILED') + expect(body.error.message).toBe('Cron runner failed to execute downstream jobs') + }) +}) diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts new file mode 100644 index 000000000..3f46788ee --- /dev/null +++ b/src/pages/api/cron/run-all.ts @@ -0,0 +1,115 @@ +import type { APIRoute } from 'astro' +import { getCronSecret, getSiteUrl } from '@pages/api/_environment/environmentApi' +import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { createApiFunctionContext } from '@pages/api/_utils/requestContext' + +export const prerender = false + +const ROUTE = '/api/cron/run-all' +const CRON_ENDPOINTS = [ + '/api/cron/cleanup-confirmations', + '/api/cron/cleanup-dsar-requests', + '/api/cron/ping-integrations', +] + +const buildErrorResponse = ( + error: unknown, + context: ReturnType['context'], + fallbackMessage: string, +) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage }) + +async function triggerCronEndpoint(path: string) { + const url = new URL(path, getSiteUrl()) + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${getCronSecret()}`, + }, + }) + + const elapsedMs = response.headers.get('x-vercel-elapsed-time') + const duration = typeof elapsedMs === 'string' ? Number(elapsedMs) : undefined + + let body: unknown + + try { + body = await response.json() + } catch { + body = await response.text() + } + + if (!response.ok) { + throw new ApiFunctionError( + `Cron runner failed for ${path}: ${response.status} ${response.statusText}`, + { + status: response.status, + code: 'CRON_RUNNER_TARGET_FAILED', + route: ROUTE, + operation: path, + details: { + body, + }, + }, + ) + } + + return { + path, + status: response.status, + durationMs: typeof duration === 'number' && Number.isFinite(duration) ? duration : undefined, + body, + } +} + +export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { + const { context: apiContext } = createApiFunctionContext({ + route: ROUTE, + operation: 'GET', + request, + clientAddress, + cookies, + }) + + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${getCronSecret()}`) { + console.warn('Unauthorized cron runner attempt - invalid CRON_SECRET') + apiContext.extra = { + ...(apiContext.extra || {}), + authHeader: authHeader ? 'PRESENT' : 'MISSING', + clientAddress, + } + return buildErrorResponse( + new ApiFunctionError({ + message: 'Unauthorized', + status: 401, + code: 'UNAUTHORIZED', + }), + apiContext, + 'Unauthorized cron access', + ) + } + + try { + const results = await Promise.all(CRON_ENDPOINTS.map(triggerCronEndpoint)) + + return new Response( + JSON.stringify({ + success: true, + results, + timestamp: new Date().toISOString(), + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + } catch (error) { + apiContext.extra = { + ...(apiContext.extra || {}), + authHeader: 'REDACTED', + } + return buildErrorResponse(error, apiContext, 'Cron runner failed to execute downstream jobs') + } +} diff --git a/vercel.json b/vercel.json index feb6386bc..fd5867fb6 100644 --- a/vercel.json +++ b/vercel.json @@ -4,16 +4,8 @@ "regions": ["iad1"], "crons": [ { - "path": "/api/cron/cleanup-confirmations", + "path": "/api/cron/run-all", "schedule": "0 2 * * *" - }, - { - "path": "/api/cron/cleanup-dsar-requests", - "schedule": "0 3 * * *" - }, - { - "path": "/api/cron/ping-integrations", - "schedule": "0 4 * * *" } ] } From ebc0e95adbee5ac820de1ad93a36e888a1f723ab Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 7 Dec 2025 02:26:29 +0300 Subject: [PATCH 17/18] Fix lint errors in new CRON runner and test --- src/pages/api/cron/__tests__/runner.spec.ts | 2 +- src/pages/api/cron/run-all.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts index 211bff13b..309c94210 100644 --- a/src/pages/api/cron/__tests__/runner.spec.ts +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -86,7 +86,7 @@ describe('cron runner', () => { expect(fetchMock).toHaveBeenCalledTimes(3) const headersUsed = fetchMock.mock.calls[0]?.[1]?.headers as Record - expect(headersUsed.Authorization ?? headersUsed.authorization).toBe('Bearer cron-secret') + expect(headersUsed['Authorization'] ?? headersUsed['authorization']).toBe('Bearer cron-secret') }) it('surfaces downstream failure details', async () => { diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts index 3f46788ee..39ed8c08d 100644 --- a/src/pages/api/cron/run-all.ts +++ b/src/pages/api/cron/run-all.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro' -import { getCronSecret, getSiteUrl } from '@pages/api/_environment/environmentApi' +import { getCronSecret } from '@pages/api/_environment/environmentApi' +import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' From 524db896afab602f219b50e2ba197b46ab25b7f7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 7 Dec 2025 02:42:51 +0300 Subject: [PATCH 18/18] Open preview link in new tab, fix output for 'Trigggered by' --- .github/workflows/deployment.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 685dcf45b..2d3bfaec7 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -139,7 +139,13 @@ jobs: const commitUrl = sha ? `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}` : `https://github.com/${context.repo.owner}/${context.repo.repo}`; - const actor = workflowRun.actor ?? 'workflow_run'; + const actorLogin = typeof workflowRun.actor === 'string' + ? workflowRun.actor + : workflowRun.actor?.login; + const actor = actorLogin ?? context.actor ?? 'workflow_run'; + const actorLink = workflowRun.actor?.html_url || (actorLogin ? `https://github.com/${actorLogin}` : null); + const actorDisplay = actor.startsWith('@') ? actor : `@${actor}`; + const triggeredBy = actorLink ? `[${actorDisplay}](${actorLink})` : actorDisplay; const body = [ commentTag, @@ -149,9 +155,9 @@ jobs: '| --- | --- |', `| Branch | \`${branch}\` |`, `| Commit | [${shortSha}](${commitUrl}) |`, - `| Preview | [Open preview](${previewUrl}) |`, + `| Preview | Open preview |`, '', - `_Triggered by @${actor}_` + `_Triggered by ${triggeredBy}_` ].join('\n'); const comments = await github.paginate(github.rest.issues.listComments, {