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
10 changes: 5 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion astro.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
},
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion site/test/navigation-production-browser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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': '*' } });
});
}
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/context-package.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
13 changes: 11 additions & 2 deletions tools/check-ci.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>};
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);
Expand Down
2 changes: 1 addition & 1 deletion tools/check-deploy-assets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
23 changes: 19 additions & 4 deletions tools/prepare-context-availability.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContextAvailability> {
export async function inspectContextAvailability(projectRoot = root, { publicAssets = 'local' }: {
publicAssets?: PublicAssetAvailability;
} = {}): Promise<ContextAvailability> {
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);
Expand Down Expand Up @@ -50,11 +54,20 @@ export async function inspectContextAvailability(projectRoot = root): Promise<Co
const bankPin = outputs.find(output => 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) {
Expand All @@ -64,8 +77,10 @@ export async function inspectContextAvailability(projectRoot = root): Promise<Co
return Object.fromEntries(entries);
}

export async function prepareContextAvailability({ projectRoot = root, strict = false } = {}) {
const availability = await inspectContextAvailability(projectRoot);
export async function prepareContextAvailability({ projectRoot = root, strict = false, publicAssets = 'local' }: {
projectRoot?: string; strict?: boolean; publicAssets?: PublicAssetAvailability;
} = {}) {
const availability = await inspectContextAvailability(projectRoot, { publicAssets });
const failures = Object.entries(availability).flatMap(([id, state]) => state.available ? [] : [`${id}: ${state.reason}`]);
if (strict && failures.length) throw new Error(`Prepared context packages unavailable:\n${failures.join('\n')}`);
return { availability, failures };
Expand Down
Loading
Loading