Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions packages/core/scripts/atomic-write-file.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
22 changes: 22 additions & 0 deletions packages/core/scripts/atomic-write-file.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 6 additions & 5 deletions packages/core/scripts/build-hyperframes-runtime-artifact.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions scripts/publish-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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@"));
Expand Down Expand Up @@ -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'");
Expand Down
Loading