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
94 changes: 0 additions & 94 deletions .github/workflows/tutorial-release.yml

This file was deleted.

3 changes: 2 additions & 1 deletion shared/tools/repository-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"pnpm": ">=10.17.1 <11"
},
"scripts": {
"check": "node --test changed-projects.test.mjs tutorial-release.test.mjs && node tutorial-release.mjs validate-all --repository ../../.."
"check": "node --test changed-projects.test.mjs tutorial-contract.test.mjs && pnpm validate:tutorials",
"validate:tutorials": "node validate-tutorials.mjs --repository ../../.."
},
"devDependencies": {
"dompurify": "3.4.16",
Expand Down
17 changes: 2 additions & 15 deletions shared/tools/repository-automation/tutorial-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ import { fromMarkdown } from "mdast-util-from-markdown";
import sharp from "sharp";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import { tutorialProjects } from "./changed-projects.mjs";

export const tutorialLimits = Object.freeze({
const tutorialLimits = Object.freeze({
assetBytes: 2 * 1024 * 1024,
assetPixels: 25_000_000,
assetTotalBytes: 10 * 1024 * 1024,
assets: 32,
bundleBytes: 12 * 1024 * 1024,
bundleEntries: 40,
markdownBytes: 1024 * 1024,
});

Expand Down Expand Up @@ -45,7 +44,6 @@ const metadataSchema = z
})
.strict();
const safeAssetPathPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
const bundleBaseEntries = 2;
const mebibyte = 1024 * 1024;
const dom = new JSDOM("");
const purifier = createDOMPurify(dom.window);
Expand Down Expand Up @@ -287,7 +285,6 @@ function validateSvg(bytes, path) {
export async function validateTutorialProject(
repositoryRoot,
project,
tutorialProjects,
) {
if (!tutorialProjects.includes(project)) {
fail(`unknown tutorial project: ${project}`);
Expand Down Expand Up @@ -343,20 +340,10 @@ export async function validateTutorialProject(
fail(`${sourcePath}: missing asset ./assets/${reference}`);
}
}
if (bundleBaseEntries + assets.length > tutorialLimits.bundleEntries) {
fail(
`${project}: tutorial bundle exceeds ${tutorialLimits.bundleEntries} files`,
);
}
if (assetBytes > tutorialLimits.assetTotalBytes) {
fail(
`${project}: tutorial assets exceed ${mebibytes(tutorialLimits.assetTotalBytes)}`,
);
}
if (tutorialStats.size + assetBytes > tutorialLimits.bundleBytes) {
fail(
`${project}: tutorial bundle exceeds ${mebibytes(tutorialLimits.bundleBytes)} expanded`,
);
}
return { assets, markdown };
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
// Verifies tutorial validation, staging, and release metadata behavior.
// Verifies tutorial source validation and the portfolio validation command.

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
import { fileURLToPath } from "node:url";
import sharp from "sharp";

import {
finalizeRelease,
parseReleaseTag,
prepareRelease,
validateTutorialProject,
} from "./tutorial-release.mjs";
import { validateTutorialProject } from "./tutorial-contract.mjs";
import { tutorialProjects } from "./changed-projects.mjs";
import { tutorialLimits } from "./tutorial-contract.mjs";

const project = "p1-task-manager";
const tag = `${project}-v1.0.0`;
const sourceCommit = "a".repeat(40);
const releaseScript = fileURLToPath(
new URL("./tutorial-release.mjs", import.meta.url),
const validationScript = fileURLToPath(
new URL("./validate-tutorials.mjs", import.meta.url),
);
const socialCard = await sharp({
create: {
Expand All @@ -37,7 +28,7 @@ const socialCard = await sharp({
.toBuffer();

async function fixture() {
const root = await mkdtemp(join(tmpdir(), "cedarling-tutorial-release-"));
const root = await mkdtemp(join(tmpdir(), "cedarling-tutorial-validation-"));
const assetRoot = join(root, project, "docs", "assets");
await mkdir(assetRoot, { recursive: true });
await writeFile(
Expand Down Expand Up @@ -77,42 +68,12 @@ async function portfolioFixture(withTutorial = false) {
return root;
}

test("validates and stages the current tutorial source contract", async (context) => {
test("validates the current tutorial source contract", async (context) => {
const root = await fixture();
context.after(() => rm(root, { force: true, recursive: true }));

const validated = await validateTutorialProject(root, project);
assert.deepEqual(validated.assets, ["boundary.svg", "social-card.webp"]);

const prepared = spawnSync(
process.execPath,
[releaseScript, "prepare", "--tag", tag, "--source-commit", sourceCommit],
{ cwd: root, encoding: "utf8" },
);
assert.equal(prepared.status, 0, prepared.stderr);
assert.deepEqual(
JSON.parse(
await readFile(join(root, ".release", "stage", "manifest.json"), "utf8"),
),
{ schemaVersion: 1, project, releaseTag: tag, sourceCommit },
);
assert.equal(
await readFile(
join(root, ".release", "stage", "docs", "assets", "boundary.svg"),
"utf8",
),
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M0 0h10v10z"/></svg>',
);
assert.deepEqual(
await readFile(
join(root, ".release", "stage", "docs", "assets", "social-card.webp"),
),
socialCard,
);
assert.equal(
await readFile(join(root, ".release", "release.env"), "utf8"),
`ARCHIVE=${project}-tutorial.zip\n`,
);
assert.equal(validated.markdown, await readFile(join(root, project, "docs", "tutorials.md"), "utf8"));
});

test("rejects a tutorial whose referenced asset is unavailable", async (context) => {
Expand Down Expand Up @@ -350,113 +311,39 @@ test("rejects SVG content changed by sanitization", async (context) => {
}
});

test("writes the archive checksum and Cedarling.dev registry entry", async (context) => {
const root = await fixture();
context.after(() => rm(root, { force: true, recursive: true }));
const output = join(root, ".release");
await mkdir(output, { recursive: true });
const archive = `${project}-tutorial.zip`;
const bytes = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x66, 0x69, 0x78]);
await writeFile(join(output, archive), bytes);

const finalized = spawnSync(
process.execPath,
[releaseScript, "finalize", "--tag", tag, "--source-commit", sourceCommit],
{ cwd: root, encoding: "utf8" },
);
assert.equal(finalized.status, 0, finalized.stderr);
const digest = createHash("sha256").update(bytes).digest("hex");
assert.equal(
await readFile(join(output, `${archive}.sha256`), "utf8"),
`${digest} ${archive}\n`,
);
assert.deepEqual(
JSON.parse(await readFile(join(output, "registry-entry.json"), "utf8")),
{
project,
releaseTag: tag,
sourceCommit,
archiveSha256: digest,
},
);
});

test("accepts only explicit project semantic-version tags", () => {
assert.equal(parseReleaseTag(tag), project);
assert.throws(() => parseReleaseTag(`${project}-latest`), /vX\.Y\.Z/);
assert.throws(() => parseReleaseTag(`${project}-v01.0.0`), /vX\.Y\.Z/);
assert.throws(() => parseReleaseTag("unknown-v1.0.0"), /known project/);
});

test("rejects invalid release inputs before writing output", async (context) => {
const root = await fixture();
context.after(() => rm(root, { force: true, recursive: true }));

await assert.rejects(
prepareRelease({ repositoryRoot: root, sourceCommit: "invalid", tag }),
/40-character lowercase SHA/,
);

const output = join(root, ".release");
await mkdir(output, { recursive: true });
await writeFile(
join(output, `${project}-tutorial.zip`),
Buffer.alloc(tutorialLimits.bundleBytes + 1),
);
await assert.rejects(
finalizeRelease({ repositoryRoot: root, sourceCommit, tag }),
new RegExp(
`exceeds ${tutorialLimits.bundleBytes / (1024 * 1024)} MiB compressed`,
),
);
});

test("rejects a release artifact without a ZIP header", async (context) => {
const root = await fixture();
context.after(() => rm(root, { force: true, recursive: true }));
const output = join(root, ".release");
await mkdir(output, { recursive: true });
await writeFile(join(output, `${project}-tutorial.zip`), "not a zip");

await assert.rejects(
finalizeRelease({ repositoryRoot: root, sourceCommit, tag }),
/does not have a ZIP header/,
);
});

test("validate-all accepts a portfolio with no published tutorials", async (context) => {
test("validation accepts a portfolio with no published tutorials", async (context) => {
const root = await portfolioFixture();
context.after(() => rm(root, { force: true, recursive: true }));

const result = spawnSync(
process.execPath,
[releaseScript, "validate-all", "--repository", root],
[validationScript, "--repository", root],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "No published tutorial sources found.\n");
});

test("validate-all validates the available tutorial sources", async (context) => {
test("validation validates the available tutorial sources", async (context) => {
const root = await portfolioFixture(true);
context.after(() => rm(root, { force: true, recursive: true }));

const result = spawnSync(
process.execPath,
[releaseScript, "validate-all", "--repository", root],
[validationScript, "--repository", root],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "Validated 1 tutorial source.\n");
});

test("validate-all rejects a path outside the tutorial repository", async (context) => {
test("validation rejects a path outside the tutorial repository", async (context) => {
const root = await mkdtemp(join(tmpdir(), "not-a-tutorial-repository-"));
context.after(() => rm(root, { force: true, recursive: true }));

const result = spawnSync(
process.execPath,
[releaseScript, "validate-all", "--repository", root],
[validationScript, "--repository", root],
{ encoding: "utf8" },
);
assert.notEqual(result.status, 0);
Expand All @@ -466,15 +353,15 @@ test("validate-all rejects a path outside the tutorial repository", async (conte
test("reports missing values and unknown CLI options", () => {
const missingValue = spawnSync(
process.execPath,
[releaseScript, "prepare", "--tag"],
[validationScript, "--repository"],
{ encoding: "utf8" },
);
assert.notEqual(missingValue.status, 0);
assert.match(missingValue.stderr, /argument missing/);

const unknownOption = spawnSync(
process.execPath,
[releaseScript, "validate-all", "--unknown"],
[validationScript, "--unknown"],
{ encoding: "utf8" },
);
assert.notEqual(unknownOption.status, 0);
Expand Down
Loading
Loading