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
87 changes: 12 additions & 75 deletions tools/playsrc/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { CLOUDFLARE_ASSET_ORIGIN, runWrangler, WRANGLER_CONFIG } from "./cloudfl
import { repositoryRoot } from "./config"
import { applicationBuildIdentity } from "./build-identity"
import { readTf2Release } from "./tf2-release"
import { parseResourceCatalogBytes, parseResourceGraphBytes, resourceChunkObject, selectCatalogTarget } from "@playsrc/asset-store/graph"
import type { ObjectDescriptor } from "@playsrc/asset-store"
import type { BrowserConfiguration } from "../../../apps/web/tf2/src/config"
import { assertStaticBundleGeneration, STATIC_GENERATION_BUNDLE_PREFIXES } from "../../../apps/web/tf2/generation-plugin"
Expand Down Expand Up @@ -89,76 +88,18 @@ async function verifyStaticTree(configuration: BrowserConfiguration): Promise<vo
}
}

async function verifyRemoteObjects(target: string | undefined): Promise<void> {
const release = await readTf2Release(target)
const deadline = Date.now() + READY_TIMEOUT_MILLISECONDS
let last = "asset origin did not respond"
while (Date.now() < deadline) {
try {
const readObject = async (descriptor: ObjectDescriptor): Promise<Uint8Array> => {
const response = await fetch(`${CLOUDFLARE_ASSET_ORIGIN}/objects/sha256/${descriptor.sha256}`, {
method: "GET",
headers: { origin: TF2_APPLICATION_ORIGIN },
redirect: "error",
})
if (response.status !== 200 || response.headers.get("content-length") !== descriptor.byteLength) {
throw new DeploymentError(`remote object ${descriptor.sha256} response differs`)
}
const bytes = new Uint8Array(await response.arrayBuffer())
if (String(bytes.byteLength) !== descriptor.byteLength || new Bun.CryptoHasher("sha256").update(bytes).digest("hex") !== descriptor.sha256) {
throw new DeploymentError(`remote object ${descriptor.sha256} bytes differ`)
}
return bytes
}
const approvedWasm = await readObject(release.objects.wasm)
const compiledWasm = await readFile(path.join(repositoryRoot, "games/tf2/browser/src/wasm-generated/tf2_wasm_bg.wasm"))
assertReleaseWasmInterface(compiledWasm, approvedWasm)
const catalogBytes = await readObject(release.objects.catalog)
const catalog = parseResourceCatalogBytes(catalogBytes)
if (catalog.application !== "tf2" || catalog.entries.length !== release.targets.length) throw new DeploymentError("remote resource catalog target table differs")
const targetClosures = await Promise.all(release.targets.map(async (targetRelease) => {
const resources = selectCatalogTarget(catalog, targetRelease.target).resources
if (resources.sha256 !== targetRelease.objects.resources.sha256 || resources.byteLength !== targetRelease.objects.resources.byteLength) throw new DeploymentError(`remote ${targetRelease.target} catalog descriptor differs`)
const graph = parseResourceGraphBytes(await readObject(resources))
if (graph.target !== targetRelease.target || graph.contentBuild !== targetRelease.contentBuild) throw new DeploymentError(`remote ${targetRelease.target} resource graph identity differs`)
await Promise.all([readObject(targetRelease.objects.bsp), readObject(targetRelease.objects.dependencyLedger)])
return [targetRelease.objects.bsp, targetRelease.objects.dependencyLedger, resources, ...graph.chunks.map(resourceChunkObject)]
}))
const closure = [...Object.values(release.objects), ...targetClosures.flat()]
const unique = new Map(closure.map((descriptor) => [descriptor.sha256, descriptor]))
let ready = true
for (const descriptor of unique.values()) {
const response = await fetch(`${CLOUDFLARE_ASSET_ORIGIN}/objects/sha256/${descriptor.sha256}`, {
method: "HEAD",
headers: { origin: TF2_APPLICATION_ORIGIN },
redirect: "error",
})
if (response.status === 404) throw new DeploymentError(`remote object ${descriptor.sha256} is absent`)
if (
response.status === 200
&& (
response.headers.get("content-length") !== descriptor.byteLength
|| response.headers.get("etag") === null
|| response.headers.get("access-control-allow-origin") !== TF2_APPLICATION_ORIGIN
)
) throw new DeploymentError(`remote object ${descriptor.sha256} metadata differs`)
if (response.status !== 200) {
ready = false
last = `remote object ${descriptor.sha256} returned malformed metadata`
break
}
}
if (ready) return
} catch (error) {
if (error instanceof DeploymentError) throw error
last = error instanceof Error ? error.message : "asset-origin probe failed"
}
await Bun.sleep(2_000)
}
throw new DeploymentError(`asset origin did not become ready within 600000 ms: ${last}`)
export async function readRemoteReleaseObject(descriptor: ObjectDescriptor, fetcher: typeof fetch = fetch): Promise<Uint8Array> {
const response = await fetcher(`${CLOUDFLARE_ASSET_ORIGIN}/objects/sha256/${descriptor.sha256}`, {
method: "GET", headers: { origin: TF2_APPLICATION_ORIGIN }, redirect: "error", signal: AbortSignal.timeout(120_000),
})
if (response.status !== 200 || response.headers.get("content-length") !== descriptor.byteLength) throw new DeploymentError(`remote object ${descriptor.sha256} response differs (HTTP ${response.status})`)
const bytes = new Uint8Array(await response.arrayBuffer())
if (String(bytes.byteLength) !== descriptor.byteLength || new Bun.CryptoHasher("sha256").update(bytes).digest("hex") !== descriptor.sha256) throw new DeploymentError(`remote object ${descriptor.sha256} bytes differ`)
return bytes
}

async function waitForDeployment(target: string | undefined, applicationBuild: string): Promise<void> {
async function waitForDeployment(release: Tf2Release, applicationBuild: string): Promise<void> {
const configuration = createDeployedBrowserConfiguration(release, applicationBuild)
const deadline = Date.now() + READY_TIMEOUT_MILLISECONDS
let last = "deployment did not respond"
while (Date.now() < deadline) {
Expand All @@ -171,10 +112,6 @@ async function waitForDeployment(target: string | undefined, applicationBuild: s
if (root.status !== 200 || tf2.status !== 200 || configurationResponse.status !== 200) {
last = `route statuses were ${root.status}, ${tf2.status}, ${configurationResponse.status}`
} else {
const configuration = createDeployedBrowserConfiguration(
parseTf2Release((await readTf2Release(target))),
applicationBuild,
)
if (JSON.stringify(await configurationResponse.json()) === JSON.stringify(configuration)) return
last = "deployed browser configuration differs"
}
Expand Down Expand Up @@ -212,13 +149,13 @@ export function assertPreparedReleaseIdentity(packaged: Pick<Awaited<ReturnType<
export async function deployCloudflare(target: string | undefined): Promise<void> {
const packaged = await verifyPreparedRelease(target)
const applicationBuild = packaged.configuration.applicationBuild
await verifyRemoteObjects(target)
// Complete immutable publication/readback is owned by the publisher, not repeated during delivery.
if ((await staticStartupPackage(DIST_DIRECTORY)).sha256 !== packaged.sha256) throw new DeploymentError("Static package changed after startup acceptance")
await applyCloudflareInfrastructure()
if ((await staticStartupPackage(DIST_DIRECTORY)).sha256 !== packaged.sha256) throw new DeploymentError("Static package changed before deployment")
const result = await runWrangler(["deploy", `--config=${WRANGLER_CONFIG}`])
if (result.code !== 0) throw new DeploymentError(`Wrangler deployment failed: ${result.stderr.trim()}`)
await waitForDeployment(target, applicationBuild)
await waitForDeployment(packaged.release, applicationBuild)
console.log(JSON.stringify({ target, applicationBuild, url: `${TF2_APPLICATION_ORIGIN}/tf2` }))
}

Expand Down
8 changes: 7 additions & 1 deletion tools/playsrc/src/prepare-release-package.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { buildStaticSite } from "./deploy"
import { assertReleaseWasmInterface, buildStaticSite, readRemoteReleaseObject } from "./deploy"
import { staticStartupPackage } from "../profile/static-startup-package"
import { repositoryRoot } from "./config"
import path from "node:path"
import { readFile } from "node:fs/promises"
import { readTf2Release } from "./tf2-release"

const release = await readTf2Release(undefined)
const compiled = await readFile(path.join(repositoryRoot, "games/tf2/browser/src/wasm-generated/tf2_wasm_bg.wasm"))
const approved = await readRemoteReleaseObject(release.objects.wasm)
assertReleaseWasmInterface(compiled, approved)
await buildStaticSite(undefined, { approved: true })
const packaged = await staticStartupPackage(path.join(repositoryRoot, "apps/web/tf2/dist/cloudflare"))
console.log(JSON.stringify({ packageSha256: packaged.sha256, applicationBuild: packaged.configuration.applicationBuild, files: packaged.files }))
70 changes: 70 additions & 0 deletions tools/playsrc/tests/release-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import releaseJson from "../../../apps/web/tf2/releases/current.json"

test("clean-artifact delivery needs no compiler tree or asset rescan, and local failures stop before production", async () => {
const root = await mkdtemp(path.join(tmpdir(), "playsrc-clean-delivery-"))
try {
const source = path.resolve(import.meta.dir, "../src"), profile = path.resolve(import.meta.dir, "../profile")
const script = path.join(root, "verify.ts")
// Isolated production-boundary mocks exercise real delivery control flow and static-tree checks.
// The mocked startup receipt is a unit fixture, never browser evidence or deployment approval.
await writeFile(script, `
import { mock, expect } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
const source = ${JSON.stringify(source)}, profile = ${JSON.stringify(profile)}, root = ${JSON.stringify(root)};
const config = await import(source + "/config.ts");
mock.module(source + "/config.ts", () => ({ ...config, repositoryRoot: root }));
const { parseTf2Release, createDeployedBrowserConfiguration } = await import(${JSON.stringify(path.resolve(import.meta.dir, "../../../apps/web/tf2/src/deployment.ts"))});
const release = parseTf2Release(JSON.parse(await Bun.stdin.text()));
const build = "a".repeat(64), sha = "b".repeat(64), configuration = createDeployedBrowserConfiguration(release, build);
const releaseModule = await import(source + "/tf2-release.ts");
mock.module(source + "/tf2-release.ts", () => ({ ...releaseModule, readTf2Release: async () => release }));
const identity = await import(source + "/build-identity.ts");
mock.module(source + "/build-identity.ts", () => ({ ...identity, applicationBuildIdentity: async () => build }));
let packageReads = 0, receiptChecks = 0, operations = [];
const packageModule = await import(profile + "/static-startup-package.ts");
mock.module(profile + "/static-startup-package.ts", () => ({ ...packageModule, staticStartupPackage: async () => { packageReads++; return { sha256: sha, release, configuration }; } }));
const gate = await import(profile + "/static-startup-gate.ts");
mock.module(profile + "/static-startup-gate.ts", () => ({ ...gate, assertStaticStartupReceipt: (receipt, expected) => {
receiptChecks++; expect(receipt.unitFixture).toBe(true); expect(expected).toEqual({ packageSha256: sha, wasmSha256: release.objects.wasm.sha256 });
} }));
const infra = await import(source + "/cloudflare-infra.ts");
mock.module(source + "/cloudflare-infra.ts", () => ({ ...infra, applyCloudflareInfrastructure: async () => { operations.push("apply"); } }));
const cloudflare = await import(source + "/cloudflare.ts");
mock.module(source + "/cloudflare.ts", () => ({ ...cloudflare, runWrangler: async args => { expect(args[0]).toBe("deploy"); operations.push("deploy"); return { code: 0 }; } }));
globalThis.fetch = async url => {
const requested = new URL(url);
if (requested.origin !== "https://playsrc.online" || !["/", "/tf2", "/tf2/playsrc-config.json"].includes(requested.pathname)) throw new Error("Unexpected asset scan or network request");
operations.push(requested.pathname);
return new Response(JSON.stringify(configuration));
};
const directory = root + "/apps/web/tf2/dist/cloudflare";
await mkdir(directory + "/tf2/assets", { recursive: true });
for (const file of ["index.html", "404.html", "_headers", "release.json", "tf2/index.html", "tf2/playsrc-config.json", "tf2/assets/style.css"]) await writeFile(directory + "/" + file, "unit fixture");
const generation = { applicationBuild: build, wasmSha256: configuration.wasm.sha256, resourceRoots: Object.fromEntries(configuration.targets.map(t => [t.target, t.objects.resources.sha256])) };
for (const prefix of ["index", "main", "gameplay-worker"]) await writeFile(directory + "/tf2/assets/" + prefix + "-test.js", "/*playsrc-generation:" + JSON.stringify(generation) + "*/");
process.env.PLAYSRC_RELEASE_VERSION = "0.1.0";
process.env.PLAYSRC_STATIC_STARTUP_RECEIPT = JSON.stringify({ unitFixture: true, packageSha256: sha, wasmSha256: release.objects.wasm.sha256 });
const { deployCloudflare } = await import(source + "/deploy.ts");
expect(existsSync(root + "/games")).toBe(false);
await deployCloudflare(undefined);
expect(packageReads).toBe(3); expect(receiptChecks).toBe(1);
expect(operations).toEqual(["apply", "deploy", "/", "/tf2", "/tf2/playsrc-config.json"]);
operations = [];
await rm(directory + "/_headers");
await expect(deployCloudflare(undefined)).rejects.toThrow("ENOENT");
expect(operations).toEqual([]);
`)
const child = Bun.spawn([process.execPath, script], { cwd: root, stdin: new Blob([JSON.stringify(releaseJson)]), stdout: "pipe", stderr: "pipe" })
const timer = setTimeout(() => child.kill(), 3_000)
try {
const [status, error] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(error).toBe("")
expect(status).toBe(0)
} finally { clearTimeout(timer) }
} finally { await rm(root, { recursive: true, force: true }) }
})