Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,50 @@ jobs:
- "gradle/**"
- "gradlew"
- "gradlew.bat"
capture_inputs:
- "tools/marketing-capture-inputs.txt"
Comment thread
veryCrunchy marked this conversation as resolved.
- "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
Comment thread
veryCrunchy marked this conversation as resolved.

- name: Require a changelog fragment
if: >-
github.event_name != 'workflow_dispatch' &&
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/231-capture-deployment-verification.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion tools/capture-marketing-screenshots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/."
5 changes: 5 additions & 0 deletions tools/marketing-capture-inputs.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 14 additions & 6 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,5 +24,8 @@
"markdown-it-anchor": "9.2.1",
"vite": "8.1.5",
"vue": "3.5.40"
},
"devDependencies": {
"pngjs": "7.0.0"
}
}
8 changes: 7 additions & 1 deletion website/public/screenshots/capture-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
{
Expand Down
77 changes: 70 additions & 7 deletions website/scripts/content-surfaces.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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"),
);
Expand All @@ -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"),
Expand Down
17 changes: 10 additions & 7 deletions website/scripts/marketing-captures.mjs
Original file line number Diff line number Diff line change
@@ -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)), "..");
Expand Down Expand Up @@ -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 ` +
Expand Down Expand Up @@ -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,
};
}

Expand Down
17 changes: 17 additions & 0 deletions website/scripts/verify-marketing-capture-assets.mjs
Original file line number Diff line number Diff line change
@@ -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.`,
);
}
Loading