diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e1259b092..a751e8155b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,9 +73,9 @@ jobs: CSSEARTH_ALLOW_MISSING_ASSETS: "1" run: pnpm setup:assets - name: Build the site - # The deploy build consumes the committed prepared metadata and the restored R2 closure. It does not run - # prepare:facilities: that authoring command can encode new preview bytes and rewrite their inventories, - # which must be published explicitly before a deploy is allowed to reference them. + # The deploy build regenerates its ignored source/facility catalogues from committed inputs and the restored + # R2 closure. Its catalog-only preparation does not publish preview bytes or rewrite asset inventories; + # those authoring outputs must be published explicitly before a deploy is allowed to reference them. # ASSET_ORIGIN points every texture, scene JSON and startup preload at the published R2 # bucket instead of bundling public/scenes (1.44 GB) into the deploy; astro.config.mts # removes dist/scenes once the build finishes and assemble:planets tolerates its absence. @@ -113,9 +113,9 @@ jobs: echo "::error::Only $page_count HTML pages were built (expected roughly 974, refusing below $min_pages). Refusing to deploy a truncated site." exit 1 fi - if ! git diff --quiet -- src/objects site/prepared-facilities.json site/prepared-sources.json; then + if ! git diff --quiet -- src/objects; then echo "::error::Deploy preparation changed committed object metadata. Prepare and publish it explicitly before deploying." - git diff --name-only -- src/objects site/prepared-facilities.json site/prepared-sources.json + git diff --name-only -- src/objects exit 1 fi - name: Publish the prebuilt site diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4410969737..a5029fb8ec 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,13 +6,18 @@ on: workflow_dispatch: pull_request: # Only the deploy-build job below runs from this trigger (each other job's `if:` stays schedule/dispatch-only): - # a full `pnpm build` is too slow to run on every PR, but a PR touching the code this specific path resolves + # a full `pnpm build:deploy` is too slow to run on every PR, but a PR touching the code this specific path resolves # through gets it anyway, instead of waiting for the nightly schedule to notice a break. paths: + - '.github/workflows/nightly.yml' + - 'package.json' + - 'pnpm-lock.yaml' - 'site/**' - 'astro.config.mts' - 'src/renderers/css/rendering/**' - '.github/workflows/deploy.yml' + - 'tools/prepare-*.mts' + - 'tools/setup*.mts' permissions: contents: read @@ -86,7 +91,7 @@ jobs: asset-origin-build: # Exercises the one path that has broken two real deploys and that no other CI job builds at all: a full - # `pnpm build` with `ASSET_ORIGIN` set, the same as .github/workflows/deploy.yml's production path. The static + # `pnpm build:deploy` with `ASSET_ORIGIN` set, the same as .github/workflows/deploy.yml's production path. The static # scan catches emitted same-origin addresses; the production browser test also exercises the client loader, # whose descriptor path can otherwise discard an origin that is present in the emitted page. name: Build with ASSET_ORIGIN and exercise the client loader @@ -123,7 +128,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=6144 CSSEARTH_ALLOW_MISSING_ASSETS: "1" ASSET_ORIGIN: https://asset-origin-ci-check.example - run: pnpm build + run: pnpm build:deploy - name: Check no page references a same-origin /scenes/ address run: node tools/check-asset-origin-scenes.mts dist - name: Check production navigation loads content-addressed assets diff --git a/astro.config.mts b/astro.config.mts index 4cf4aba104..d3a48dfc0d 100644 --- a/astro.config.mts +++ b/astro.config.mts @@ -35,7 +35,10 @@ export default defineConfig({ // context package that setup:assets deliberately left missing after a 404 from R2, instead of failing the // whole build over one object. CI and local builds never set this flag and stay strict. const allowMissing = process.env.CSSEARTH_ALLOW_MISSING_ASSETS === '1'; - const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing }); + // An asset-origin build deliberately leaves public/scenes absent. Its tracked manifest is the local, + // content-addressed contract for previews already published to R2; all prepared package bytes stay strict. + const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing, + publicAssets: assetOrigin() ? 'manifest' : 'local' }); updateConfig({ vite: { define: { __CSSEARTH_CONTEXT_AVAILABILITY__: JSON.stringify(availability) } } }); if (failures.length) logger.warn(`Some 3D views are unavailable in this installation:\n${failures.join('\n')}\nPrepare their packages and restart the server to enable them.`); }, diff --git a/package.json b/package.json index a7a6866c94..cb0a157f21 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "predev": "pnpm build:tools && pnpm prepare:object-json && pnpm prepare:environment-images && pnpm prepare:minimap && pnpm prepare:galaxy-field", "prepare:catalog": "node tools/prepare-catalog.mts", "prepare:checkout": "node tools/restore-source-inputs.mts && pnpm prepare:planets", - "prepare:deploy": "pnpm build:tools && pnpm setup:assets && pnpm prepare:object-json && pnpm prepare:environment-images && node tools/nebula/prepare.mts --if-missing && pnpm prepare:minimap && pnpm prepare:galaxy-field:data", + "prepare:deploy": "pnpm build:tools && pnpm setup:assets && pnpm prepare:object-json && pnpm prepare:environment-images && node tools/nebula/prepare.mts --if-missing && pnpm prepare:minimap && pnpm prepare:galaxy-field:data && pnpm prepare:deploy-catalogues", + "prepare:deploy-catalogues": "node tools/prepare-facilities.mts --catalog-only", "prepare:earth-global": "node tools/objects/geographic-pages/operations/prepare-global-wmts.mts --object=earth && node tools/objects/geographic-pages/operations/integrate-global-wmts.mts --object=earth --latest", "prepare:environment-images": "node tools/objects/dist/restore-environment-images.js", "prepare:factsheets": "node tools/prepare-factsheets.mts", @@ -109,8 +110,9 @@ "publish:earth-global": "node tools/objects/geographic-pages/operations/publish-global-wmts.mts --object=earth", "publish:runtime-assets": "node tools/publish-runtime-assets.mts", "refresh:earth-enso": "node tools/objects/paged-ellipsoid/refresh-earth-enso.mts && node tools/objects/dist/prepare-authored.js earth --write && node tools/prepare-text.mts earth", - "setup:assets": "pnpm prepare:shell && node tools/setup.mts && node tools/setup-prepared.mts", + "setup:assets": "pnpm prepare:shell && node tools/setup.mts && node tools/setup-prepared.mts && node tools/setup-volume-metadata.mts", "setup:prepared": "node tools/setup-prepared.mts", + "setup:volume-metadata": "node tools/setup-volume-metadata.mts", "status:earth-city-global": "node tools/objects/geographic-pages/operations/status-global-city.mts --object=earth", "telescope:publish-map": "node tools/run-typed-module.mjs tools/objects/body-map-publication.mts", "telescope:query": "node tools/run-typed-module.mjs tools/objects/telescopes/query.mts", diff --git a/site/test/navigation-production-browser.mts b/site/test/navigation-production-browser.mts index 80e5a0bc47..85df413194 100644 --- a/site/test/navigation-production-browser.mts +++ b/site/test/navigation-production-browser.mts @@ -7,6 +7,7 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { inventoriedAssets, inventoriedObjectIds } from '../../tools/runtime-assets.mts'; import { contentType } from '../../tools/publish-runtime-assets.mts'; +import { sha256 } from '../../src/platform/sha256.mts'; const origin = process.env.CSSEARTH_TEST_ORIGIN ?? 'http://127.0.0.1:4212'; const channel = process.env.PLAYWRIGHT_CHANNEL ?? 'chrome'; @@ -36,7 +37,19 @@ try { const url = new URL(route.request().url()), asset = assets.get(url.pathname); if (!asset) return route.fulfill({ status: 404, body: `Uninventoried asset: ${url.pathname}` }); assetRequests.push(url.href); - return route.fulfill({ body: await readFile(asset.file), contentType: contentType(asset.key), + let body = await readFile(asset.file).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined; + throw error; + }); + // A deploy intentionally keeps large context datasets on R2. The fake ASSET_ORIGIN route still verifies + // their real content-addressed bytes instead of requiring the CI checkout to download every inventory. + if (!body) { + const response = await fetch(asset.url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok) throw new Error(`Published asset unavailable: ${asset.url} (HTTP ${response.status}).`); + body = Buffer.from(await response.arrayBuffer()); + } + if (body.length !== asset.bytes || sha256(body) !== asset.sha256) throw new Error(`Published asset identity changed: ${asset.key}.`); + return route.fulfill({ body, contentType: contentType(asset.key), headers: { 'access-control-allow-origin': '*' } }); }); } diff --git a/tests/fixtures/context-package.mts b/tests/fixtures/context-package.mts index c98535f217..3319451138 100644 --- a/tests/fixtures/context-package.mts +++ b/tests/fixtures/context-package.mts @@ -38,6 +38,10 @@ export async function writeContextPackage(root: string, id: string) { const files: [string, string | Uint8Array][] = [ [`${directory}/object.json`, JSON.stringify(descriptor)], [`${directory}/prepared/lenses.json`, bankBytes], [`${directory}/prepared/provenance.json`, JSON.stringify(provenance)], [`${directory}/prepared/presentation.json`, JSON.stringify(presentation)], + [`${directory}/runtime-assets.json`, JSON.stringify({ schema: `css${id}-runtime-assets@1`, resourceRoot: 'prepared', + assets: [{ filename: 'preview.webp', location: 'public', bytes: image.length, sha256: digest }] })], + [`${directory}/source/presentation.json`, JSON.stringify({ schema: 'cssearth-volume-presentation-source@1', objectId: id, + name: `${id} fixture`, defaultLens: 'optical', lenses: [{ id: 'optical' }] })], [`${directory}/prepared/slice.webp`, image], [`public${preview}`, image], ]; for (const [path, bytes] of files) { const file = resolve(root, path); await mkdir(dirname(file), { recursive: true }); await writeFile(file, bytes); } diff --git a/tools/check-ci.test.mts b/tools/check-ci.test.mts index 9795d87dd8..0a41038c7f 100644 --- a/tools/check-ci.test.mts +++ b/tools/check-ci.test.mts @@ -26,17 +26,26 @@ test('local CI reads the actual workflow jobs in order, including strict TypeScr const ownership=universe.find(step=>step.name.includes('runtime ownership')); assert.equal(ownership?.env.RUNTIME_OWNERSHIP_ARGS,'--all'); }); -test('the deploy consumes installed assets and rejects generated metadata or uninventoried output',async()=>{ +test('the deploy consumes installed assets, rebuilds only catalogues and rejects uninventoried output',async()=>{ const workflow=await readFile(new URL('../.github/workflows/deploy.yml',import.meta.url),'utf8'); const packageFile=JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')) as {scripts:Record}; assert.match(workflow,/pnpm build:deploy/); assert.match(workflow,/pnpm check:deploy-assets/); - assert.match(workflow,/git diff --quiet -- src\/objects site\/prepared-facilities\.json site\/prepared-sources\.json/); + assert.match(workflow,/git diff --quiet -- src\/objects/); assert.doesNotMatch(workflow,/ASSET_ORIGIN=https:\/\/earth-assets\.lowpoly\.cc pnpm build(?:\s|$)/); assert.match(packageFile.scripts['prepare:deploy']??'',/node tools\/nebula\/prepare\.mts --if-missing/); assert.match(packageFile.scripts['prepare:deploy']??'',/pnpm prepare:galaxy-field:data/); + assert.match(packageFile.scripts['prepare:deploy']??'',/pnpm prepare:deploy-catalogues/); + assert.equal(packageFile.scripts['prepare:deploy-catalogues'],'node tools/prepare-facilities.mts --catalog-only'); + assert.match(packageFile.scripts['setup:assets']??'',/node tools\/setup-volume-metadata\.mts/); assert.doesNotMatch(packageFile.scripts['prepare:deploy']??'',/prepare:(?:facilities|provenance|nebulae)(?:\s|$)/); }); +test('the PR asset-origin check exercises the exact deploy build path',async()=>{ + const workflow=await readFile(new URL('../.github/workflows/nightly.yml',import.meta.url),'utf8'); + const steps=readCiSteps(workflow,'asset-origin-build'); + const build=steps.find(step=>step.name==='Build the site with ASSET_ORIGIN set to a test origin'); + assert.equal(build?.run.trim(),'pnpm build:deploy'); +}); test('--quick skips only the network and documentation steps, and refuses a job without them',async()=>{ const lint=readCiSteps(await readFile(new URL('../.github/workflows/universe.yml',import.meta.url),'utf8'),'lint'); const quick=quickSteps(lint); diff --git a/tools/check-deploy-assets.mts b/tools/check-deploy-assets.mts index 97b94f02e8..66f5b041c5 100644 --- a/tools/check-deploy-assets.mts +++ b/tools/check-deploy-assets.mts @@ -29,7 +29,7 @@ export function unknownRuntimeAssetUrls(referenced: readonly string[], inventori } export async function checkDeployAssets(root = resolve(import.meta.dirname, '..')): Promise<{ files: number; urls: number }> { - const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--', 'src/objects', 'site/prepared-facilities.json', 'site/prepared-sources.json'], { cwd: root }); + const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--', 'src/objects'], { cwd: root }); const drift = stdout.split('\n').map(path => path.trim()).filter(Boolean); if (drift.length) throw new Error(`The deploy preparation changed committed object metadata:\n${drift.join('\n')}\nPrepare and publish those assets explicitly before deploying.`); const files = await textFiles(resolve(root, 'dist')); diff --git a/tools/prepare-context-availability.mts b/tools/prepare-context-availability.mts index e532d6e2bb..3c7ef33fa3 100644 --- a/tools/prepare-context-availability.mts +++ b/tools/prepare-context-availability.mts @@ -9,11 +9,15 @@ import type { ContextAvailability } from '../src/platform/context-availability.m import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts'; import { readContextObjects } from './prepare-catalog.mts'; import { hasErrorCode } from './source-values.mts'; +import { requireRuntimeAssetManifest } from '../src/platform/runtime-asset-closure.mts'; const root = resolve(import.meta.dirname, '..'); +type PublicAssetAvailability = 'local' | 'manifest'; /** Verify complete volume packages once before serving; no source processing or downloads. */ -export async function inspectContextAvailability(projectRoot = root): Promise { +export async function inspectContextAvailability(projectRoot = root, { publicAssets = 'local' }: { + publicAssets?: PublicAssetAvailability; +} = {}): Promise { const contexts = await readContextObjects(resolve(projectRoot, 'src/objects')); const entries = await Promise.all(contexts.filter(object => object.type === 'volume-lens-bank').map(async ({ id }) => { const directory = resolve(projectRoot, 'src/objects', id); @@ -50,11 +54,20 @@ export async function inspectContextAvailability(projectRoot = root): Promise output.url === bankUrl); if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`); await verify(projectRoot, bankUrl, bankPin); + const published = publicAssets === 'manifest' + ? requireRuntimeAssetManifest(id, JSON.parse((await read(directory, 'runtime-assets.json')).toString())) + : null; for (const lens of presentation.controls) for (const url of new Set([lens.thumbnailUrl, lens.texture?.url])) { if (!url?.startsWith(`/scenes/${id}/`)) throw new TypeError(`Invalid dataset preview URL: ${url}.`); const pin = outputs.find(output => output.url === url); if (!pin) throw new TypeError(`Unpinned dataset preview: ${url}.`); - await verify(resolve(projectRoot, 'public'), url.slice(1), pin); + if (published) { + const filename = url.slice(`/scenes/${id}/`.length); + const asset = published.assets.find(candidate => candidate.filename === filename && + (published.resourceRoot !== 'prepared' || candidate.location === 'public')); + if (!asset || asset.sha256 !== pin.sha256 || asset.bytes !== pin.bytes) + throw new TypeError(`Unpublished dataset preview: ${url}.`); + } else await verify(resolve(projectRoot, 'public'), url.slice(1), pin); } return [id, { available: true }] as const; } catch (error) { @@ -64,8 +77,10 @@ export async function inspectContextAvailability(projectRoot = root): Promise state.available ? [] : [`${id}: ${state.reason}`]); if (strict && failures.length) throw new Error(`Prepared context packages unavailable:\n${failures.join('\n')}`); return { availability, failures }; diff --git a/tools/prepare-context-availability.test.mts b/tools/prepare-context-availability.test.mts index 0e17dd6136..151bcf145d 100644 --- a/tools/prepare-context-availability.test.mts +++ b/tools/prepare-context-availability.test.mts @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { inspectContextAvailability, prepareContextAvailability } from './prepare-context-availability.mts'; import { parseContextAvailability } from '../src/platform/context-availability.mts'; import { writeContextPackage } from '../tests/fixtures/context-package.mts'; +import { readPreparedVolumeProvenance } from './prepare-volume-provenance.mts'; test('a missing bank isolates one object; restoring it admits the complete package on the next startup', async t => { const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -53,3 +54,38 @@ test('invalid presentation and provenance cannot become available merely because assert.throws(() => parseContextAvailability({ helix: { available: 'true' } })); assert.throws(() => parseContextAvailability({ helix: { available: false } })); }); + +test('an asset-origin build verifies a missing local preview against its published manifest', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true })); + const f = await writeContextPackage(root, 'helix'); + await rm(resolve(root, 'public/scenes/helix/preview.webp')); + assert.equal((await inspectContextAvailability(root)).helix.available, false); + assert.deepEqual(await inspectContextAvailability(root, { publicAssets: 'manifest' }), { helix: { available: true } }); + const manifestPath = resolve(f.directory, 'runtime-assets.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.assets[0].sha256 = '0'.repeat(64); + await writeFile(manifestPath, JSON.stringify(manifest)); + const unavailable = (await inspectContextAvailability(root, { publicAssets: 'manifest' })).helix; + if (unavailable.available) assert.fail('Changed preview identity must make the package unavailable.'); + assert.match(unavailable.reason, /Unpublished dataset preview/); +}); + +test('deploy catalogue input reads the installed prepared volume instead of regenerating it', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-prepared-volume-')); t.after(() => rm(root, { recursive: true, force: true })); + const fixture = await writeContextPackage(root, 'helix'); + const volumes = await readPreparedVolumeProvenance({ root }); + assert.equal(volumes.length, 1); + assert.equal(volumes[0]?.id, 'helix'); + assert.equal(volumes[0]?.name, 'helix fixture'); + assert.deepEqual(volumes[0]?.controls.map(control => control.id), ['optical']); + assert.deepEqual(volumes[0]?.provenance, fixture.provenance); + assert.deepEqual(volumes[0]?.outputs, []); +}); + +test('deploy volume input ignores source-only catalogue contexts with no prepared lens metadata', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-source-context-')); t.after(() => rm(root, { recursive: true, force: true })); + const path = resolve(root, 'src/objects/galaxy-clusters/source/presentation.json'); + await mkdir(resolve(path, '..'), { recursive: true }); + await writeFile(path, JSON.stringify({ provenance: { products: [] } })); + assert.deepEqual(await readPreparedVolumeProvenance({ root }), []); +}); diff --git a/tools/prepare-facilities.mts b/tools/prepare-facilities.mts index b7d29d05db..377ee2095a 100644 --- a/tools/prepare-facilities.mts +++ b/tools/prepare-facilities.mts @@ -24,7 +24,7 @@ import type { ProvenanceDocument } from '../src/platform/object-provenance.mts'; import { writePreparedSet } from './write-prepared-set.mts'; import { restoreFactsheetEvidence } from './restore-factsheet-evidence.mts'; import type { FactsheetSourceTransport } from './restore-factsheet-evidence.mts'; -import { prepareVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts'; +import { prepareVolumeProvenance, readPreparedVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts'; import { RUNTIME_ASSET_ORIGIN } from './source-mirror.mts'; export const explorationCompilerClosure = [ 'tools/prepare-facilities.mts', 'tools/spatial-source-citations.mts', 'packages/catalog/src/spatial.ts', 'packages/catalog/src/spatial-relations.ts', 'packages/catalog/src/clusters.ts', 'src/platform/exploration-catalog.mts', 'src/platform/exploration-contributions.mts', @@ -44,7 +44,7 @@ export const explorationCompilerClosure = [ 'tools/objects/provenance.mts', 'tools/objects/provenance-records.mts', 'tools/objects/provenance-recipes.mts', 'tools/prepare-provenance.mts', ] as const; -interface Options { root?: string; publish?: boolean; provenance?: ReadonlyMap; sourceTransport?: FactsheetSourceTransport; +interface Options { root?: string; publish?: boolean | 'catalogues'; provenance?: ReadonlyMap; sourceTransport?: FactsheetSourceTransport; /** Opt-in (default null/off) content-addressed mirror for volume previews; a production caller names * RUNTIME_ASSET_ORIGIN explicitly. Left off by default so a test never makes a surprise real request. */ mirrorOrigin?: string | null; } @@ -139,7 +139,12 @@ export async function prepareFacilities({ root = resolve(import.meta.dirname, '. inventory.push(...sourceInventory(manifest, `${base}/source/manifest.json`, sources, new Set(document.sources.map(source => source.path)))); objects.push({ id: object.id, name: object.name, route: object.route, base, controls: lenses, provenance: document }); } - const volumes = [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })]; + // Deploys consume the exact prepared package restored from R2. Authoring preparation still rebuilds provenance + // and previews from their sources, but catalog-only publication must never invent a second package identity. + const volumes = publish === 'catalogues' + ? [...await readPreparedVolumeProvenance({ root, input }), + ...((await prepareContextProvenance({ root, input })).map(context => ({ ...context, outputs: [] })))] + : [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })]; for (const volume of volumes) { const document = validateObjectProvenance(volume.provenance, volume.id); const manifestPath = `${sourcePath(volume.base)}/${sourcePath(document.manifest.path)}`; @@ -166,14 +171,18 @@ export async function prepareFacilities({ root = resolve(import.meta.dirname, '. const prepared = parsePreparedExploration(payload,sources); const output = { path: resolve(root, 'site/prepared-facilities.json'), text: JSON.stringify(payload, null, 2) + '\n' }; const sourcesOutput = {path:resolve(root,'site/prepared-sources.json'),text:JSON.stringify(sourcePayload,null,2)+'\n'}; - const outputs = [...volumes.flatMap(volume => volume.outputs),sourcesOutput,output]; - if (publish) await writePreparedSet(outputs); - return { prepared, preparedSources, output, outputs, factsheets }; + const catalogueOutputs = [sourcesOutput,output]; + const outputs = [...volumes.flatMap(volume => volume.outputs),...catalogueOutputs]; + if (publish) await writePreparedSet(publish === 'catalogues' ? catalogueOutputs : outputs); + return { prepared, preparedSources, output, outputs, catalogueOutputs, factsheets }; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const args = process.argv.slice(2); + if (args.some(arg => arg !== '--catalog-only')) throw new TypeError('Usage: node tools/prepare-facilities.mts [--catalog-only]'); // The real CLI entry point: opts into the mirror explicitly (library code above defaults it off). - const { prepared, factsheets } = await prepareFacilities({ mirrorOrigin: RUNTIME_ASSET_ORIGIN }); + const { prepared, factsheets } = await prepareFacilities({ mirrorOrigin: RUNTIME_ASSET_ORIGIN, + publish: args.includes('--catalog-only') ? 'catalogues' : true }); console.log(`Prepared ${prepared.catalog.missions.length} missions, ${prepared.catalog.facilities.length} facilities and ${prepared.graph.datasets.length} dataset destinations.`); console.log(`Factsheets: ${factsheets.facts} facts, each with its own citation.`); } diff --git a/tools/prepare-volume-provenance.mts b/tools/prepare-volume-provenance.mts index bccba9c0b2..e02ad9c474 100644 --- a/tools/prepare-volume-provenance.mts +++ b/tools/prepare-volume-provenance.mts @@ -5,8 +5,10 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import sharp from 'sharp'; +import { parseObjectDescriptor } from '@cssearth/objects'; import type { Lens } from '../site/planet-shell-types.ts'; import { validateDatasetText } from '../site/dataset-content.mts'; +import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts'; import { parseCapture } from '../src/platform/exploration-catalog.mts'; import { validateObjectProvenance } from '../src/platform/object-provenance.mts'; import type { ProvenanceDocument, ProvenanceSource, ProvenanceJson } from '../src/platform/object-provenance.mts'; @@ -135,6 +137,43 @@ async function hostedDatasets(root: string, base: string, objectId: string, lens for (const lensId of lensIds) if (!datasets[lensId]) throw new TypeError(`No dataset of ${hostId} shows ${objectId}/${lensId}.`); return { objectId: hostId, name: sourceText(content.displayName), route: `/${hostId}/`, datasets }; } + +/** Read the prepared package that setup:assets installed. Deploy catalogue compilation must bind to these + * R2-backed bytes; rebuilding provenance from authoring inputs can describe a different package. */ +export async function readPreparedVolumeProvenance({ root = process.cwd(), input = path => readFile(resolve(root, path)) }: { + root?: string; input?: (path: string) => Promise; +} = {}): Promise { + const results: PreparedVolumeProvenance[] = []; + const folders = await readdir(resolve(root, 'src/objects'), { withFileTypes: true }); + for (const folder of folders.filter(folder => folder.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const id = folder.name, base = `src/objects/${id}`, sourcePresentationPath = `${base}/source/presentation.json`; + const exists = await readFile(resolve(root, sourcePresentationPath)).then(() => true, (error: unknown) => { + if (hasErrorCode(error, 'ENOENT')) return false; + throw error; + }); + if (!exists) continue; + const sourcePresentation = sourceObject(json(await input(sourcePresentationPath))); + // The three source-only catalogue contexts use source/presentation.json too, but are not prepared lens packages. + // prepareContextProvenance owns their in-memory catalogue records below; there are no R2 metadata files to read. + if (sourcePresentation.schema !== 'cssearth-volume-presentation-source@1') continue; + if (sourcePresentation.objectId !== id) throw new TypeError(`Mismatched volume presentation object: ${id}.`); + const descriptor = parseObjectDescriptor(json(await input(`${base}/object.json`))); + if (descriptor.id !== id || !descriptor.prepared || !['volume-lens-bank', 'image-layer-bank'].includes(descriptor.type)) + throw new TypeError(`Invalid prepared volume descriptor: ${id}.`); + const provenance = validateObjectProvenance(json(await input(`${base}/prepared/provenance.json`)), id); + const defaultLens = sourceId(sourcePresentation.defaultLens); + const lensIds = sourceArray(sourcePresentation.lenses, raw => sourceId(sourceObject(raw).id)); + const prepared = parsePreparedVolumePresentation(json(await input(`${base}/prepared/presentation.json`)), + { id, defaultLens, lenses: lensIds.map(lensId => ({ id: lensId })) }, provenance); + const bankUrl = `${base}/${descriptor.prepared!.url}`; + const bankPin = provenance.products.flatMap(product => product.outputs).find(output => output.url === bankUrl); + if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`); + const hostedBy = await hostedDatasets(root, base, id, prepared.controls.map(control => control.id), input); + results.push({ id, name: sourceText(sourcePresentation.name), route: hostedBy?.route ?? `/sun/?focus=${id}`, base, + controls: prepared.controls, defaultLens: prepared.defaultLens, provenance, outputs: [], ...(hostedBy ? { hostedBy } : {}) }); + } + return results; +} interface Options { root?: string; /** Repository-relative, tracked compiler inputs only. Downloads never enter source closure. */ diff --git a/tools/runtime-assets.mts b/tools/runtime-assets.mts index 108375980d..3bb3817155 100644 --- a/tools/runtime-assets.mts +++ b/tools/runtime-assets.mts @@ -66,14 +66,16 @@ async function locatedAssets(root: string, id: string, a return located; } -export async function runtimeAssets(root: string, objectIds: readonly string[]): Promise { +export async function runtimeAssets(root: string, objectIds: readonly string[], + { filenames }: { filenames?: readonly string[] } = {}): Promise { const assets: RuntimeAssetLocation[] = []; + const selectedFilenames = filenames ? new Set(filenames) : null; for (const id of objectIds) { if (!/^[a-z][a-z0-9-]*$/u.test(id)) throw new TypeError(`Unsafe runtime object identity: ${id}`); const base = resolve(root, `src/objects/${id}`); const bytes = await readFile(resolve(base, "runtime-assets.json")); const manifest = requireRuntimeAssetManifest(id, JSON.parse(bytes.toString("utf8"))); - for (const asset of manifest.assets) { + for (const asset of manifest.assets.filter(asset => !selectedFilenames || selectedFilenames.has(asset.filename))) { const assetRoot = manifest.resourceRoot === "prepared" && asset.location !== "public" ? resolve(base, "prepared") : resolve(root, `public/scenes/${id}`); assets.push(...await locatedAssets(root, id, assetRoot, [asset])); } @@ -82,14 +84,17 @@ export async function runtimeAssets(root: string, objectIds: readonly string[]): } /** Counterpart of `runtimeAssets` for `prepared-assets.json`: always resourceRoot `prepared`, never public. */ -export async function preparedAssets(root: string, objectIds: readonly string[]): Promise { +export async function preparedAssets(root: string, objectIds: readonly string[], + { filenames }: { filenames?: readonly string[] } = {}): Promise { const assets: RuntimeAssetLocation[] = []; + const selectedFilenames = filenames ? new Set(filenames) : null; for (const id of objectIds) { if (!/^[a-z][a-z0-9-]*$/u.test(id)) throw new TypeError(`Unsafe prepared object identity: ${id}`); const base = resolve(root, `src/objects/${id}`); const bytes = await readFile(resolve(base, "prepared-assets.json")); const manifest = requirePreparedAssetManifest(id, JSON.parse(bytes.toString("utf8"))); - assets.push(...await locatedAssets(root, id, resolve(base, "prepared"), manifest.assets)); + assets.push(...await locatedAssets(root, id, resolve(base, "prepared"), + manifest.assets.filter(asset => !selectedFilenames || selectedFilenames.has(asset.filename)))); } return assets; } diff --git a/tools/setup-volume-metadata.mts b/tools/setup-volume-metadata.mts new file mode 100644 index 0000000000..cbd3d670c7 --- /dev/null +++ b/tools/setup-volume-metadata.mts @@ -0,0 +1,53 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { sourceObject, sourceText } from '../src/platform/source-catalog.mts'; +import { preparedAssets, runtimeAssets } from './runtime-assets.mts'; +import { installRuntimeAssets } from './setup.mts'; +import { hasErrorCode } from './source-values.mts'; + +export const VOLUME_METADATA_FILENAMES = ['presentation.json', 'provenance.json'] as const; + +/** Select only the small R2-backed package metadata needed to compile deploy catalogues. Volume textures and + * slices stay remote; restoring the full runtime-assets closure here can be tens of gigabytes. */ +export async function preparedVolumeMetadataAssets(root = resolve(import.meta.dirname, '..')) { + const ids: string[] = []; + const folders = await readdir(resolve(root, 'src/objects'), { withFileTypes: true }); + for (const folder of folders.filter(folder => folder.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const path = resolve(root, 'src/objects', folder.name, 'source/presentation.json'); + const bytes = await readFile(path).catch((error: unknown) => { + if (hasErrorCode(error, 'ENOENT')) return undefined; + throw error; + }); + if (!bytes) continue; + const presentation = sourceObject(JSON.parse(bytes.toString('utf8'))); + if (presentation.schema !== 'cssearth-volume-presentation-source@1') continue; + if (sourceText(presentation.objectId) !== folder.name) throw new TypeError(`Mismatched volume presentation object: ${folder.name}.`); + ids.push(folder.name); + } + + const preparedIds = ids.filter(id => existsSync(resolve(root, 'src/objects', id, 'prepared-assets.json'))); + const assets = [ + ...await runtimeAssets(root, ids, { filenames: VOLUME_METADATA_FILENAMES }), + ...await preparedAssets(root, preparedIds, { filenames: VOLUME_METADATA_FILENAMES }), + ]; + for (const id of ids) for (const filename of VOLUME_METADATA_FILENAMES) { + const matches = assets.filter(asset => asset.id === id && asset.filename === filename); + if (matches.length !== 1 || matches[0]!.location === 'public' || + matches[0]!.file !== resolve(root, 'src/objects', id, 'prepared', filename)) { + throw new TypeError(`Volume ${id} must inventory exactly one prepared/${filename} runtime asset.`); + } + } + return { ids, assets }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const root = resolve(import.meta.dirname, '..'); + const { ids, assets } = await preparedVolumeMetadataAssets(root); + console.log(`Setting up catalogue metadata for ${ids.join(', ')}: ${assets.length} file(s); volume data stays on R2.`); + const result = await installRuntimeAssets(assets, { onProgress: ({ completed, total }) => { + if (completed % 10 === 0 || completed === total) console.log(`Volume metadata: ${completed}/${total}`); + } }); + console.log(`Volume metadata setup complete: ${result.installed} downloaded, ${result.reused} reused.`); +} diff --git a/tools/setup-volume-metadata.test.mts b/tools/setup-volume-metadata.test.mts new file mode 100644 index 0000000000..db6c55cdb9 --- /dev/null +++ b/tools/setup-volume-metadata.test.mts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; +import { installRuntimeAssets } from './setup.mts'; +import { preparedVolumeMetadataAssets } from './setup-volume-metadata.mts'; + +const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); + +test('catalogue bootstrap restores only prepared volume metadata and leaves dataset bytes on R2', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-volume-metadata-')); + t.after(() => rm(root, { recursive: true, force: true })); + const base = resolve(root, 'src/objects/m31'); + const files = new Map([ + ['presentation.json', Buffer.from('{"schema":"prepared-presentation"}')], + ['provenance.json', Buffer.from('{"schema":"prepared-provenance"}')], + ['datasets/large.webp', Buffer.from('dataset fixture that must remain remote')], + ]); + const sourcePresentation = resolve(base, 'source/presentation.json'); + await mkdir(dirname(sourcePresentation), { recursive: true }); + await writeFile(sourcePresentation, JSON.stringify({ schema: 'cssearth-volume-presentation-source@1', objectId: 'm31' })); + await writeFile(resolve(base, 'runtime-assets.json'), JSON.stringify({ + schema: 'cssm31-runtime-assets@1', resourceRoot: 'prepared', + assets: [...files].map(([filename, bytes]) => ({ filename, bytes: bytes.length, sha256: sha256(bytes) })), + })); + const preparedBase = resolve(root, 'src/objects/helix'); + const preparedSource = resolve(preparedBase, 'source/presentation.json'); + await mkdir(dirname(preparedSource), { recursive: true }); + await writeFile(preparedSource, JSON.stringify({ schema: 'cssearth-volume-presentation-source@1', objectId: 'helix' })); + await writeFile(resolve(preparedBase, 'runtime-assets.json'), JSON.stringify({ + schema: 'csshelix-runtime-assets@1', resourceRoot: 'prepared', + assets: [{ filename: 'datasets/large.webp', location: 'public', bytes: files.get('datasets/large.webp')!.length, + sha256: sha256(files.get('datasets/large.webp')!) }], + })); + await writeFile(resolve(preparedBase, 'prepared-assets.json'), JSON.stringify({ + schema: 'csshelix-prepared-assets@1', resourceRoot: 'prepared', + assets: ['presentation.json', 'provenance.json'].map(filename => ({ filename, bytes: files.get(filename)!.length, + sha256: sha256(files.get(filename)!) })), + })); + const sourceOnly = resolve(root, 'src/objects/local-group/source/presentation.json'); + await mkdir(dirname(sourceOnly), { recursive: true }); + await writeFile(sourceOnly, JSON.stringify({ provenance: { products: [] } })); + + const selected = await preparedVolumeMetadataAssets(root); + assert.deepEqual(selected.ids, ['helix', 'm31']); + assert.deepEqual(selected.assets.map(asset => `${asset.id}/${asset.filename}`).sort(), [ + 'helix/presentation.json', 'helix/provenance.json', 'm31/presentation.json', 'm31/provenance.json', + ]); + const requested: string[] = []; + assert.deepEqual(await installRuntimeAssets(selected.assets, { fetcher: async url => { + const asset = selected.assets.find(asset => asset.url === String(url)); + assert.ok(asset); + requested.push(asset.filename); + return new Response(files.get(asset.filename)); + } }), { installed: 4, reused: 0, skipped: 0 }); + assert.deepEqual(requested.sort(), ['presentation.json', 'presentation.json', 'provenance.json', 'provenance.json']); + assert.equal(await readFile(resolve(base, 'prepared/presentation.json'), 'utf8'), files.get('presentation.json')!.toString()); + assert.equal(await readFile(resolve(preparedBase, 'prepared/provenance.json'), 'utf8'), files.get('provenance.json')!.toString()); + await assert.rejects(readFile(resolve(base, 'prepared/datasets/large.webp')), { code: 'ENOENT' }); + await assert.rejects(readFile(resolve(root, 'public/scenes/helix/datasets/large.webp')), { code: 'ENOENT' }); +}); diff --git a/tools/source-catalogue.test.mts b/tools/source-catalogue.test.mts index 98e46da71c..155b689fbc 100644 --- a/tools/source-catalogue.test.mts +++ b/tools/source-catalogue.test.mts @@ -178,6 +178,7 @@ test('both catalogues prepare deterministically from the same input closure befo assert.ok(facts.some(edge => edge.objectId === 'abundantia' && edge.citationUrl?.includes('/4625'))); assert.ok(Object.hasOwn(result.preparedSources.closure, 'src/objects/earth/source/editorial/factsheet-review.json')); assert.ok(Object.hasOwn(result.preparedSources.closure, 'src/objects/abundantia/source/reference/damit-model.json')); + assert.deepEqual(result.catalogueOutputs, result.outputs.slice(-2), 'catalog-only publication excludes prepared volume and R2 outputs'); assert.deepEqual(sourceDatasetViews(prepared.usage, 'damit-models'), [], 'factsheet metadata is not a shape or imagery contribution'); for (const output of result.outputs) assert.deepEqual(typeof output.text === 'string' ? Buffer.from(output.text) : output.text,await readFile(output.path),output.path); assert.equal(result.prepared.sourceCatalogSha256,result.preparedSources.catalogSha256);