diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2950e6a51..d3e19c533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,50 @@ jobs: - "gradle/**" - "gradlew" - "gradlew.bat" + capture_inputs: + - "tools/marketing-capture-inputs.txt" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + - "gradle/libs.versions.toml" + - "gradle/wrapper/gradle-wrapper.properties" + - "ui/build.gradle.kts" + - "ui/src/commonMain/**" + - "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/**" + - "ui/src/desktopMain/resources/marketing/**" + - "website/package.json" + - "website/package-lock.json" + - "website/public/screenshots/**" + - "website/scripts/marketing-captures.mjs" + - "website/scripts/verify-marketing-captures.mjs" - name: Check repository hygiene run: | bash tools/check-repository.sh bash tools/test-prerelease-update-contract.sh + - name: Set up Node.js for capture verification + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.capture_inputs == 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install capture verification dependencies + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.capture_inputs == 'true' + run: npm ci --prefix website + + - name: Verify marketing capture freshness + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.capture_inputs == 'true' + run: node website/scripts/verify-marketing-captures.mjs + - name: Require a changelog fragment if: >- github.event_name != 'workflow_dispatch' && diff --git a/changes/unreleased/231-capture-deployment-verification.md b/changes/unreleased/231-capture-deployment-verification.md new file mode 100644 index 000000000..4c092427a --- /dev/null +++ b/changes/unreleased/231-capture-deployment-verification.md @@ -0,0 +1,7 @@ +category: internal +issue: 231 +pull: 247 +platforms: website +user-facing: no + +Website deployments now verify committed screenshot assets independently from source-freshness review gates. diff --git a/tools/capture-marketing-screenshots.sh b/tools/capture-marketing-screenshots.sh index d0e04efc4..12d41eaa6 100755 --- a/tools/capture-marketing-screenshots.sh +++ b/tools/capture-marketing-screenshots.sh @@ -10,6 +10,6 @@ fi cd "$repository_root" ./gradlew --no-daemon --max-workers=1 :ui:captureMarketingScreenshots -npm run --prefix website verify:captures +npm run --prefix website verify:captures:fresh echo "Workstation Compose captures are ready in website/public/screenshots/." diff --git a/tools/marketing-capture-inputs.txt b/tools/marketing-capture-inputs.txt index af7402d0d..6b376534b 100644 --- a/tools/marketing-capture-inputs.txt +++ b/tools/marketing-capture-inputs.txt @@ -1,4 +1,9 @@ # Capture source inventory shared by the Compose renderer and freshness verifier. +build.gradle.kts +settings.gradle.kts +gradle.properties +gradle/libs.versions.toml +gradle/wrapper/gradle-wrapper.properties ui/build.gradle.kts ui/src/commonMain/kotlin ?ui/src/commonMain/resources diff --git a/website/README.md b/website/README.md index dc139d801..46c0d1489 100644 --- a/website/README.md +++ b/website/README.md @@ -74,17 +74,25 @@ filename list. Each scenario uses production Compose components and deterministic synthetic models. The workflow does not use adb, an emulator, a phone, a Nextcloud account, or network-backed application services. -The website validates the manifest, PNG dimensions, and image hashes. Check -whether the committed catalog represents the current capture inputs with: +Production and pull request deployments validate the committed manifest, fully +decode each PNG, and check its dimensions and hash without requiring unrelated +UI source changes to regenerate the catalog: ```bash npm run --prefix website verify:captures ``` -If the command reports stale inputs, run the capture wrapper with JDK 21 and -review the updated synthetic images. The `/visual-qa/` route lists scenario, -feature, surface, state, platform, viewport, and pixel metadata. Future -scenario entries may also identify the pull request they review. +Review CI separately checks whether the catalog represents the current capture +inputs. Run the same freshness gate locally with: + +```bash +npm run --prefix website verify:captures:fresh +``` + +If the freshness command reports stale inputs, run the capture wrapper with +JDK 21 and review the updated synthetic images. The `/visual-qa/` route lists +scenario, feature, surface, state, platform, viewport, and pixel metadata. +Future scenario entries may also identify the pull request they review. The canonical Obiente organization avatar lives with the desktop capture resources. Content generation copies it into `public/` for static hosting, so diff --git a/website/package-lock.json b/website/package-lock.json index 012d72771..f6e02824d 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "nextcloud-native-homepage", "version": "0.1.0", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, "dependencies": { "@phosphor-icons/vue": "2.2.1", "@vitejs/plugin-vue": "6.0.8", @@ -18,6 +15,12 @@ "markdown-it-anchor": "9.2.1", "vite": "8.1.5", "vue": "3.5.40" + }, + "devDependencies": { + "pngjs": "7.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/helper-string-parser": { @@ -977,6 +980,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", diff --git a/website/package.json b/website/package.json index 14b0b31be..c99c72e3c 100644 --- a/website/package.json +++ b/website/package.json @@ -9,7 +9,8 @@ "scripts": { "content": "node scripts/generate-content.mjs", "test": "node --test ../tools/changelog-fragments.test.mjs && node scripts/run-tests.mjs", - "verify:captures": "node scripts/verify-marketing-captures.mjs", + "verify:captures": "node scripts/verify-marketing-capture-assets.mjs", + "verify:captures:fresh": "node scripts/verify-marketing-captures.mjs", "predev": "npm run content", "dev": "vite", "build": "npm run content && npm run test && npm run verify:captures && vite build && vite build --ssr src/entry-server.js --outDir dist-ssr && node scripts/prerender.mjs", @@ -23,5 +24,8 @@ "markdown-it-anchor": "9.2.1", "vite": "8.1.5", "vue": "3.5.40" + }, + "devDependencies": { + "pngjs": "7.0.0" } } diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 3fe73d02d..861511fe3 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -5,6 +5,11 @@ "cloudIdentity": "Nextcloud", "networkAccess": false, "captureSources": [ + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + "settings.gradle.kts", "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt", @@ -126,6 +131,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellShortcuts.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudBoardDragAutoScroll.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudBoardDragHandle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt", @@ -168,7 +174,7 @@ "ui/src/desktopMain/resources/marketing/raw-render-fixture.png", "ui/src/desktopMain/resources/marketing/raw-render-fixture.svg" ], - "captureSourceSha256": "2c7267b916de63c592d2ce2bbae824ea36154f6ea55ac4c2db9084a032d74d86", + "captureSourceSha256": "f671f0e7fc16db7c4614b0a861d37bf0fc5e1aeac5f31631b0f07a8729ab566e", "avatarSha256": "a20433eeda834a418f92d76853633b4fc9115ad3006c5622ce2611432dc1f14d", "captures": [ { diff --git a/website/scripts/content-surfaces.test.mjs b/website/scripts/content-surfaces.test.mjs index ffe48605a..72917a6c5 100644 --- a/website/scripts/content-surfaces.test.mjs +++ b/website/scripts/content-surfaces.test.mjs @@ -8,6 +8,8 @@ import { changelog } from "../src/generated/changelog.js"; import { marketingCaptures } from "../src/generated/captures.js"; import { news } from "../src/generated/news.js"; import { + decodePngDimensions, + discoverCaptureSources, readCaptureManifest, stableCapturePath, verifyCaptureAssets, @@ -173,6 +175,11 @@ test("marketing screenshots are rendered offscreen without an Android device", a manifest.captureSources.every( (relative) => relative === "ui/build.gradle.kts" || + relative === "build.gradle.kts" || + relative === "settings.gradle.kts" || + relative === "gradle.properties" || + relative === "gradle/libs.versions.toml" || + relative === "gradle/wrapper/gradle-wrapper.properties" || relative === "tools/marketing-capture-inputs.txt" || relative.startsWith("ui/src/commonMain/") || relative.startsWith( @@ -181,19 +188,20 @@ test("marketing screenshots are rendered offscreen without an Android device", a relative.startsWith("ui/src/desktopMain/resources/marketing/"), ), ); - const sourceDigest = createHash("sha256"); - for (const relative of manifest.captureSources) { - sourceDigest.update(relative); - sourceDigest.update(new Uint8Array([0])); - sourceDigest.update(await readFile(path.join(repositoryRoot, relative))); - } - assert.equal(manifest.captureSourceSha256, sourceDigest.digest("hex")); for (const capture of manifest.captures) { const bytes = await readFile( path.join(websiteRoot, "public", "screenshots", capture.file), ); + assert.deepEqual(decodePngDimensions(bytes), { + width: capture.width, + height: capture.height, + }); assert.equal(createHash("sha256").update(bytes).digest("hex"), capture.sha256); } + const validPng = await readFile( + path.join(websiteRoot, "public", "screenshots", manifest.captures[0].file), + ); + assert.throws(() => decodePngDimensions(validPng.subarray(0, 40))); const avatar = await readFile( path.join(repositoryRoot, "ui", "src", "desktopMain", "resources", "marketing", "obiente-avatar.png"), ); @@ -205,6 +213,61 @@ test("marketing screenshots are rendered offscreen without an Android device", a assert.deepEqual(websiteAvatar, avatar); }); +test("deploy builds verify committed captures while review CI checks freshness", async () => { + const packageJson = JSON.parse( + await readFile(path.join(websiteRoot, "package.json"), "utf8"), + ); + const captureWrapper = await readFile( + path.join(repositoryRoot, "tools", "capture-marketing-screenshots.sh"), + "utf8", + ); + const ciWorkflow = await readFile( + path.join(repositoryRoot, ".github", "workflows", "ci.yml"), + "utf8", + ); + + assert.equal( + packageJson.scripts["verify:captures"], + "node scripts/verify-marketing-capture-assets.mjs", + ); + assert.equal( + packageJson.scripts["verify:captures:fresh"], + "node scripts/verify-marketing-captures.mjs", + ); + assert.match(packageJson.scripts.build, /\bnpm run verify:captures\b/u); + assert.doesNotMatch(packageJson.scripts.build, /\bverify:captures:fresh\b/u); + assert.match(captureWrapper, /\bnpm run --prefix website verify:captures:fresh\b/u); + assert.match( + ciWorkflow, + /\bnode website\/scripts\/verify-marketing-captures\.mjs\b/u, + ); + assert.match(ciWorkflow, /steps\.changes\.outputs\.capture_inputs == 'true'/u); + assert.match(ciWorkflow, /- "ui\/src\/commonMain\/\*\*"/u); + assert.match(ciWorkflow, /- "gradle\/libs\.versions\.toml"/u); + assert.match(ciWorkflow, /- "website\/public\/screenshots\/\*\*"/u); + assert.match(ciWorkflow, /- "website\/scripts\/marketing-captures\.mjs"/u); + assert.match(ciWorkflow, /- "website\/scripts\/verify-marketing-captures\.mjs"/u); + assert.match(ciWorkflow, /\bnpm ci --prefix website\b/u); + assert.ok( + ciWorkflow.indexOf("npm ci --prefix website") < + ciWorkflow.indexOf("node website/scripts/verify-marketing-captures.mjs"), + ); +}); + +test("capture freshness tracks renderer build configuration", async () => { + const sources = new Set(await discoverCaptureSources()); + for (const requiredSource of [ + "build.gradle.kts", + "settings.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + "ui/build.gradle.kts", + ]) { + assert.ok(sources.has(requiredSource), `${requiredSource} must affect capture freshness`); + } +}); + test("visual QA and mobile navigation are driven by registered captures", async () => { const appSource = await readFile( path.join(websiteRoot, "src", "App.vue"), diff --git a/website/scripts/marketing-captures.mjs b/website/scripts/marketing-captures.mjs index 4b51eee38..1fe5c3661 100644 --- a/website/scripts/marketing-captures.mjs +++ b/website/scripts/marketing-captures.mjs @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { lstat, readFile, readdir, realpath } from "node:fs/promises"; import path from "node:path"; +import { PNG } from "pngjs"; import { fileURLToPath } from "node:url"; const websiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -156,7 +157,7 @@ export async function verifyCaptureAssets(manifest) { const imagePath = path.join(websiteRoot, "public", "screenshots", capture.file); try { const bytes = await readFile(imagePath); - const dimensions = pngDimensions(bytes); + const dimensions = decodePngDimensions(bytes); if (dimensions.width !== capture.width || dimensions.height !== capture.height) { failures.push( `${capture.file} is ${dimensions.width}x${dimensions.height}; manifest expects ` + @@ -277,15 +278,17 @@ async function digestCaptureSources(sources) { return digest.digest("hex"); } -function pngDimensions(bytes) { - const signature = [137, 80, 78, 71, 13, 10, 26, 10]; +export function decodePngDimensions(bytes) { + const decoded = PNG.sync.read(bytes, { + checkCRC: true, + }); requireValue( - bytes.length >= 24 && signature.every((value, index) => bytes[index] === value), - "file is not a PNG image", + decoded.data.length === decoded.width * decoded.height * 4, + "decoded PNG pixel data has an unexpected length", ); return { - width: bytes.readUInt32BE(16), - height: bytes.readUInt32BE(20), + width: decoded.width, + height: decoded.height, }; } diff --git a/website/scripts/verify-marketing-capture-assets.mjs b/website/scripts/verify-marketing-capture-assets.mjs new file mode 100644 index 000000000..ad8726947 --- /dev/null +++ b/website/scripts/verify-marketing-capture-assets.mjs @@ -0,0 +1,17 @@ +import { + readCaptureManifest, + verifyCaptureAssets, +} from "./marketing-captures.mjs"; + +const manifest = await readCaptureManifest(); +const failures = await verifyCaptureAssets(manifest); + +if (failures.length > 0) { + console.error("Committed marketing capture asset verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log( + `Verified ${manifest.captures.length} committed synthetic Compose capture assets.`, + ); +}