diff --git a/package.json b/package.json index 25866d6189..523232b78b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", + "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{engine,producer,player,studio,shader-transitions,aws-lambda,sdk}' build && bun run --filter @hyperframes/gcp-cloud-run build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", @@ -48,7 +48,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/core/scripts/atomic-write-file.test.ts packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/core/scripts/atomic-write-file.test.ts b/packages/core/scripts/atomic-write-file.test.ts new file mode 100644 index 0000000000..33c1bd2bf2 --- /dev/null +++ b/packages/core/scripts/atomic-write-file.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { atomicWriteFileSync } from "./atomic-write-file.js"; + +type FileSystemError = Error & { syscall: string; path: string; dest: string }; + +function captureFileSystemError(operation: () => void): FileSystemError { + try { + operation(); + } catch (error) { + assert.ok(error instanceof Error); + return error as FileSystemError; + } + assert.fail("expected filesystem operation to fail"); +} + +test("atomicWriteFileSync replaces the destination with complete contents", () => { + const directory = mkdtempSync(join(tmpdir(), "hyperframes-atomic-write-")); + const destination = join(directory, "artifact.js"); + + try { + writeFileSync(destination, "old contents", "utf8"); + + atomicWriteFileSync(destination, "new contents", "utf8"); + + assert.equal(readFileSync(destination, "utf8"), "new contents"); + assert.deepEqual(readdirSync(directory), ["artifact.js"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("atomicWriteFileSync removes its temporary file when publication fails", () => { + const directory = mkdtempSync(join(tmpdir(), "hyperframes-atomic-write-failure-")); + const destination = join(directory, "artifact.js"); + + try { + mkdirSync(destination); + + const firstFailure = captureFileSystemError(() => + atomicWriteFileSync(destination, "first contents", "utf8"), + ); + const secondFailure = captureFileSystemError(() => + atomicWriteFileSync(destination, "second contents", "utf8"), + ); + + assert.equal(firstFailure.syscall, "rename"); + assert.equal(secondFailure.syscall, "rename"); + assert.equal(dirname(firstFailure.path), directory); + assert.equal(dirname(secondFailure.path), directory); + assert.equal(firstFailure.dest, destination); + assert.equal(secondFailure.dest, destination); + assert.notEqual(firstFailure.path, secondFailure.path); + assert.deepEqual(readdirSync(directory), ["artifact.js"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/core/scripts/atomic-write-file.ts b/packages/core/scripts/atomic-write-file.ts new file mode 100644 index 0000000000..1d21beea0b --- /dev/null +++ b/packages/core/scripts/atomic-write-file.ts @@ -0,0 +1,22 @@ +import { randomUUID } from "node:crypto"; +import { renameSync, rmSync, writeFileSync, type WriteFileOptions } from "node:fs"; +import { basename, dirname, join } from "node:path"; + +export function atomicWriteFileSync( + destination: string, + contents: string | NodeJS.ArrayBufferView, + options?: WriteFileOptions, +): void { + const temporaryPath = join( + dirname(destination), + `.${basename(destination)}.${process.pid}.${randomUUID()}.tmp`, + ); + + try { + writeFileSync(temporaryPath, contents, options); + renameSync(temporaryPath, destination); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index 3042e17706..4b1cd5570c 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -1,8 +1,9 @@ import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; +import { atomicWriteFileSync } from "./atomic-write-file"; import { HYPERFRAME_RUNTIME_ARTIFACTS, HYPERFRAME_RUNTIME_CONTRACT, @@ -47,9 +48,9 @@ const manifest = { }; mkdirSync(distDir, { recursive: true }); -writeFileSync(iifePath, runtimeSource, "utf8"); -writeFileSync(esmPath, esmSource, "utf8"); -writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +atomicWriteFileSync(iifePath, runtimeSource, "utf8"); +atomicWriteFileSync(esmPath, esmSource, "utf8"); +atomicWriteFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); // ── Generate src/generated/runtime-inline.ts ────────────────────────────── // This file is compiled by tsc into dist/ and provides the production-safe @@ -59,7 +60,7 @@ const generatedDir = resolve(thisDir, "../src/generated"); mkdirSync(generatedDir, { recursive: true }); const inlineModulePath = resolve(generatedDir, "runtime-inline.ts"); const escapedSource = JSON.stringify(runtimeSourceRaw); -writeFileSync( +atomicWriteFileSync( inlineModulePath, [ "// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit", diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 051543daff..040812b20a 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { parse } from "yaml"; const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); +const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const config = parse(workflow); const publish = config.jobs.publish; const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@")); @@ -66,6 +67,32 @@ test("the executable checkout guard cannot be conditionally disabled", () => { ); }); +test("the release build orders generated artifacts before their consumers", () => { + const packagesByStage = rootPackage.scripts.build.split(/\s*&&\s*/).map((stage) => { + const filter = stage.match(/@hyperframes\/(?:\{([^}]+)\}|([a-z0-9-]+))/); + const packages = filter?.[1]?.split(",") ?? [filter?.[2]].filter(Boolean); + return new Set(packages); + }); + const firstStageWith = (packageName) => + packagesByStage.findIndex((packages) => packages.has(packageName)); + + const coreIndex = firstStageWith("core"); + const producerIndex = firstStageWith("producer"); + const cloudRunIndex = firstStageWith("gcp-cloud-run"); + + assert.notEqual(coreIndex, -1, "root build must include Core"); + assert.notEqual(producerIndex, -1, "root build must include Producer"); + assert.notEqual(cloudRunIndex, -1, "root build must include GCP Cloud Run"); + assert.ok( + packagesByStage.slice(coreIndex + 1).every((packages) => !packages.has("core")), + "Core rewrites generated source and must not rebuild in a later parallel group", + ); + assert.ok( + producerIndex < cloudRunIndex, + "Producer declaration emit must finish before GCP Cloud Run consumes it", + ); +}); + test("stable release tag recovery is idempotent and immutable", () => { assert.ok(createReleaseTag); assert.equal(createReleaseTag.if, "github.event_name == 'pull_request'");