diff --git a/.github/workflows/tutorial-release.yml b/.github/workflows/tutorial-release.yml deleted file mode 100644 index 960e47f..0000000 --- a/.github/workflows/tutorial-release.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Tutorial release - -on: - push: - tags: - - "p*-v*" - -permissions: - contents: write - -concurrency: - group: tutorial-release-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: Publish tutorial source bundle - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7.0.1 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v6.1.0 - with: - version: 10.17.1 - run_install: false - - uses: actions/setup-node@v7.0.0 - with: - node-version: 24.21.0 - cache: pnpm - cache-dependency-path: shared/tools/repository-automation/pnpm-lock.yaml - - name: Install release tooling - working-directory: shared/tools/repository-automation - run: pnpm install --frozen-lockfile - - name: Test release tooling - working-directory: shared/tools/repository-automation - run: node --test tutorial-release.test.mjs - - id: source - name: Verify release source - env: - TAG: ${{ github.ref_name }} - run: | - git rev-parse --verify "$TAG^{tag}" >/dev/null - source_commit=$(git rev-parse "$TAG^{commit}") - git fetch origin main --no-tags - git merge-base --is-ancestor "$source_commit" origin/main - echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" - - name: Validate and stage tutorial - env: - SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} - TAG: ${{ github.ref_name }} - run: >- - node shared/tools/repository-automation/tutorial-release.mjs prepare - --tag "$TAG" - --source-commit "$SOURCE_COMMIT" - - name: Build deterministic archive - shell: bash - env: - TZ: UTC - run: | - source .release/release.env - find .release/stage -type f -exec chmod 0644 {} + - find .release/stage -type f -exec touch -t 198001010000 {} + - cd .release/stage - find . -type f -printf '%P\n' | LC_ALL=C sort | zip -X -q "../$ARCHIVE" -@ - - name: Finalize release metadata - env: - SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} - TAG: ${{ github.ref_name }} - run: >- - node shared/tools/repository-automation/tutorial-release.mjs finalize - --tag "$TAG" - --source-commit "$SOURCE_COMMIT" - - name: Publish versioned release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - shell: bash - run: | - source .release/release.env - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists and will not be replaced." >&2 - exit 1 - fi - gh release create "$TAG" \ - ".release/$ARCHIVE" \ - ".release/$ARCHIVE.sha256" \ - ".release/registry-entry.json" \ - --verify-tag \ - --title "$TAG" \ - --notes-file .release/release-notes.md - - name: Show Cedarling.dev registry entry - run: cat .release/registry-entry.json diff --git a/shared/tools/repository-automation/package.json b/shared/tools/repository-automation/package.json index 30d4de6..c42943a 100644 --- a/shared/tools/repository-automation/package.json +++ b/shared/tools/repository-automation/package.json @@ -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", diff --git a/shared/tools/repository-automation/tutorial-contract.mjs b/shared/tools/repository-automation/tutorial-contract.mjs index 59fb464..5e69035 100644 --- a/shared/tools/repository-automation/tutorial-contract.mjs +++ b/shared/tools/repository-automation/tutorial-contract.mjs @@ -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, }); @@ -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); @@ -287,7 +285,6 @@ function validateSvg(bytes, path) { export async function validateTutorialProject( repositoryRoot, project, - tutorialProjects, ) { if (!tutorialProjects.includes(project)) { fail(`unknown tutorial project: ${project}`); @@ -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 }; } diff --git a/shared/tools/repository-automation/tutorial-release.test.mjs b/shared/tools/repository-automation/tutorial-contract.test.mjs similarity index 69% rename from shared/tools/repository-automation/tutorial-release.test.mjs rename to shared/tools/repository-automation/tutorial-contract.test.mjs index b7fd001..130c839 100644 --- a/shared/tools/repository-automation/tutorial-release.test.mjs +++ b/shared/tools/repository-automation/tutorial-contract.test.mjs @@ -1,8 +1,7 @@ -// 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"; @@ -10,20 +9,12 @@ 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: { @@ -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( @@ -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", - ), - '', - ); - 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) => { @@ -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); @@ -466,7 +353,7 @@ 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); @@ -474,7 +361,7 @@ test("reports missing values and unknown CLI options", () => { const unknownOption = spawnSync( process.execPath, - [releaseScript, "validate-all", "--unknown"], + [validationScript, "--unknown"], { encoding: "utf8" }, ); assert.notEqual(unknownOption.status, 0); diff --git a/shared/tools/repository-automation/tutorial-release.mjs b/shared/tools/repository-automation/tutorial-release.mjs deleted file mode 100644 index d03ad19..0000000 --- a/shared/tools/repository-automation/tutorial-release.mjs +++ /dev/null @@ -1,218 +0,0 @@ -// Validates, stages, and finalizes versioned tutorial release bundles. - -import { createHash } from "node:crypto"; -import { cp, lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { parseArgs } from "node:util"; - -import { tutorialProjects } from "./changed-projects.mjs"; -import { - tutorialLimits, - validateTutorialProject as validateTutorialContract, -} from "./tutorial-contract.mjs"; - -const commitPattern = /^[0-9a-f]{40}$/; -const tagVersionPattern = - /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; -const zipHeader = Buffer.from([0x50, 0x4b, 0x03, 0x04]); -const mebibyte = 1024 * 1024; - -function fail(message) { - throw new Error(message); -} - -function mebibytes(bytes) { - return `${bytes / mebibyte} MiB`; -} - -async function regularFile(path, label) { - let stats; - try { - stats = await lstat(path); - } catch { - fail(`${label} is missing: ${path}`); - } - if (stats.isSymbolicLink() || !stats.isFile()) { - fail(`${label} must be a regular file: ${path}`); - } - return stats; -} - -export function parseReleaseTag(tag) { - const project = tutorialProjects.find((candidate) => - tag.startsWith(`${candidate}-`), - ); - if (project === undefined) - fail(`release tag must start with a known project name: ${tag}`); - if (!tagVersionPattern.test(tag.slice(project.length + 1))) { - fail(`release tag must use ${project}-vX.Y.Z`); - } - return project; -} - -export function validateTutorialProject(repositoryRoot, project) { - return validateTutorialContract(repositoryRoot, project, tutorialProjects); -} - -function releaseDirectory(repositoryRoot) { - return join(resolve(repositoryRoot), ".release"); -} - -export async function prepareRelease({ repositoryRoot, sourceCommit, tag }) { - if (!commitPattern.test(sourceCommit)) - fail("source commit must be a 40-character lowercase SHA"); - const project = parseReleaseTag(tag); - const tutorial = await validateTutorialProject(repositoryRoot, project); - const destination = releaseDirectory(repositoryRoot); - const stage = join(destination, "stage"); - await rm(destination, { force: true, recursive: true }); - await mkdir(join(stage, "docs"), { recursive: true }); - await writeFile( - join(stage, "manifest.json"), - `${JSON.stringify({ schemaVersion: 1, project, releaseTag: tag, sourceCommit }, null, 2)}\n`, - ); - await writeFile(join(stage, "docs", "tutorials.md"), tutorial.markdown); - if (tutorial.assets.length > 0) { - await mkdir(join(stage, "docs", "assets"), { recursive: true }); - for (const asset of tutorial.assets) { - const source = join( - repositoryRoot, - project, - "docs", - "assets", - ...asset.split("/"), - ); - const target = join(stage, "docs", "assets", ...asset.split("/")); - await mkdir(dirname(target), { recursive: true }); - await cp(source, target, { dereference: false, errorOnExist: true }); - } - } - const archive = `${project}-tutorial.zip`; - await writeFile( - join(destination, "release.env"), - `ARCHIVE=${archive}\n`, - ); -} - -export async function finalizeRelease({ repositoryRoot, sourceCommit, tag }) { - if (!commitPattern.test(sourceCommit)) - fail("source commit must be a 40-character lowercase SHA"); - const project = parseReleaseTag(tag); - const destination = releaseDirectory(repositoryRoot); - const archive = `${project}-tutorial.zip`; - const archivePath = join(destination, archive); - const stats = await regularFile(archivePath, "tutorial archive"); - if (stats.size > tutorialLimits.bundleBytes) - fail( - `${archive}: exceeds ${mebibytes(tutorialLimits.bundleBytes)} compressed`, - ); - const archiveBytes = await readFile(archivePath); - if (!archiveBytes.subarray(0, zipHeader.length).equals(zipHeader)) { - fail(`${archive}: does not have a ZIP header`); - } - const digest = createHash("sha256") - .update(archiveBytes) - .digest("hex"); - const registryEntry = { - project, - releaseTag: tag, - sourceCommit, - archiveSha256: digest, - }; - await writeFile( - join(destination, `${archive}.sha256`), - `${digest} ${archive}\n`, - ); - await writeFile( - join(destination, "registry-entry.json"), - `${JSON.stringify(registryEntry, null, 2)}\n`, - ); - await writeFile( - join(destination, "release-notes.md"), - `Tutorial source bundle for \`${project}\`.\n\nSource commit: \`${sourceCommit}\`\n\nCedarling.dev registry entry:\n\n\`\`\`json\n${JSON.stringify(registryEntry, null, 2)}\n\`\`\`\n`, - ); -} - -async function validateAll(repositoryRoot) { - const invalidProjects = []; - for (const project of tutorialProjects) { - try { - const stats = await lstat(join(repositoryRoot, project)); - if (stats.isSymbolicLink() || !stats.isDirectory()) { - invalidProjects.push(project); - } - } catch { - invalidProjects.push(project); - } - } - if (invalidProjects.length > 0) { - fail( - `repository root does not contain the tutorial portfolio: ${invalidProjects.join(", ")}`, - ); - } - - let count = 0; - for (const project of tutorialProjects) { - try { - await lstat(join(repositoryRoot, project, "docs", "tutorials.md")); - } catch (error) { - if (error?.code === "ENOENT") continue; - throw error; - } - await validateTutorialProject(repositoryRoot, project); - count += 1; - } - process.stdout.write( - count === 0 - ? "No published tutorial sources found.\n" - : `Validated ${count} tutorial source${count === 1 ? "" : "s"}.\n`, - ); -} - -async function main() { - const { positionals, values } = parseArgs({ - args: process.argv.slice(2), - allowPositionals: true, - strict: true, - options: { - repository: { type: "string" }, - tag: { type: "string" }, - "source-commit": { type: "string" }, - }, - }); - if (positionals.length !== 1) { - fail("command must be validate-all, prepare, or finalize"); - } - const [command] = positionals; - if (!["validate-all", "prepare", "finalize"].includes(command)) { - fail("command must be validate-all, prepare, or finalize"); - } - const repositoryRoot = resolve(values.repository ?? process.cwd()); - if (command === "validate-all") { - if (values.tag || values["source-commit"]) { - fail("validate-all accepts only --repository"); - } - await validateAll(repositoryRoot); - return; - } - const tag = values.tag; - const sourceCommit = values["source-commit"]; - if (!tag || !sourceCommit) fail("--tag and --source-commit are required"); - if (command === "prepare") - await prepareRelease({ repositoryRoot, sourceCommit, tag }); - else - await finalizeRelease({ repositoryRoot, sourceCommit, tag }); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - }); -} diff --git a/shared/tools/repository-automation/validate-tutorials.mjs b/shared/tools/repository-automation/validate-tutorials.mjs new file mode 100644 index 0000000..51fc819 --- /dev/null +++ b/shared/tools/repository-automation/validate-tutorials.mjs @@ -0,0 +1,37 @@ +// Validates every available project tutorial before its source is merged. +import { lstat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +import { tutorialProjects } from "./changed-projects.mjs"; +import { validateTutorialProject } from "./tutorial-contract.mjs"; + +async function main() { + const { values } = parseArgs({ options: { repository: { type: "string" } } }); + const root = resolve(values.repository ?? process.cwd()); + for (const project of tutorialProjects) { + const stat = await lstat(join(root, project)).catch(() => undefined); + if (!stat?.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`repository root does not contain the tutorial portfolio: ${project}`); + } + } + let count = 0; + for (const project of tutorialProjects) { + try { + await lstat(join(root, project, "docs", "tutorials.md")); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + await validateTutorialProject(root, project); + count += 1; + } + process.stdout.write(count === 0 + ? "No published tutorial sources found.\n" + : `Validated ${count} tutorial source${count === 1 ? "" : "s"}.\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +});