diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 1aba7e9..8140f7b 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -10,6 +10,7 @@ import { addBoundariesEntry, emitTemplateTree, } from "./lib/core-package-utils.js"; +import { registerFeatureInReleasePlease } from "./lib/release-please-utils.js"; /** * Turbo generator: `feature` @@ -306,6 +307,30 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { templateFile: "templates/feature/src/ui/index.ts.hbs", }, + // CHANGELOG (release-please will append future versions; v0.1.0 seed) + { + type: "add", + path: "packages/{{kebabCase name}}/CHANGELOG.md", + templateFile: "templates/feature/CHANGELOG.md.hbs", + }, + + // Register the new feature in release-please tracking (ADR-021). + // Adds an entry to .release-please-manifest.json and a per-package + // block to release-please-config.json. Idempotent + sorted so reruns + // are stable. Throws if either file is missing. + function registerInReleasePlease( + answers: Record, + ): string { + const a = answers as { name: string }; + const repoRoot = process.cwd(); + const { manifestChanged, configChanged } = + registerFeatureInReleasePlease(repoRoot, a.name); + if (manifestChanged || configChanged) { + return `Registered @repo/${a.name} in release-please tracking (manifest + config)`; + } + return `@repo/${a.name} already tracked by release-please (no changes)`; + }, + // Final manual-wiring instructions printed to the user function printNextSteps(answers: Record): string { const a = answers as { diff --git a/turbo/generators/lib/release-please-utils.test.mjs b/turbo/generators/lib/release-please-utils.test.mjs new file mode 100644 index 0000000..85b00cd --- /dev/null +++ b/turbo/generators/lib/release-please-utils.test.mjs @@ -0,0 +1,146 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { registerFeatureInReleasePlease } from "./release-please-utils.ts"; + +function setupRepo() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rp-utils-")); + fs.writeFileSync( + path.join(tmp, ".release-please-manifest.json"), + JSON.stringify( + { + ".": "0.1.0", + "packages/auth": "0.1.0", + "packages/blog": "0.1.0", + }, + null, + 2, + ) + "\n", + ); + fs.writeFileSync( + path.join(tmp, "release-please-config.json"), + JSON.stringify( + { + "release-type": "node", + "include-component-in-tag": true, + packages: { + ".": { + "package-name": "template-vertical", + component: "template", + "changelog-path": "CHANGELOG.md", + }, + "packages/auth": { + "package-name": "@repo/auth", + component: "auth", + "changelog-path": "CHANGELOG.md", + }, + }, + }, + null, + 2, + ) + "\n", + ); + return tmp; +} + +describe("registerFeatureInReleasePlease", () => { + test("adds a new package to both files", () => { + const tmp = setupRepo(); + try { + const { manifestChanged, configChanged } = registerFeatureInReleasePlease( + tmp, + "comments", + ); + assert.equal(manifestChanged, true); + assert.equal(configChanged, true); + + const manifest = JSON.parse( + fs.readFileSync( + path.join(tmp, ".release-please-manifest.json"), + "utf8", + ), + ); + assert.equal(manifest["packages/comments"], "0.1.0"); + + const config = JSON.parse( + fs.readFileSync(path.join(tmp, "release-please-config.json"), "utf8"), + ); + assert.deepEqual(config.packages["packages/comments"], { + "package-name": "@repo/comments", + component: "comments", + "changelog-path": "CHANGELOG.md", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("is idempotent — re-running on an already-tracked feature is a no-op", () => { + const tmp = setupRepo(); + try { + const first = registerFeatureInReleasePlease(tmp, "comments"); + assert.equal(first.manifestChanged, true); + const second = registerFeatureInReleasePlease(tmp, "comments"); + assert.equal(second.manifestChanged, false); + assert.equal(second.configChanged, false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("keeps root ('.') entry first and sorts the rest alphabetically", () => { + const tmp = setupRepo(); + try { + registerFeatureInReleasePlease(tmp, "alphabetically-first-comments"); + + const manifestText = fs.readFileSync( + path.join(tmp, ".release-please-manifest.json"), + "utf8", + ); + const keys = Object.keys(JSON.parse(manifestText)); + assert.equal(keys[0], ".", "root should always be first"); + // The rest are alphabetical + const rest = keys.slice(1); + const sorted = [...rest].sort(); + assert.deepEqual(rest, sorted); + + const configText = fs.readFileSync( + path.join(tmp, "release-please-config.json"), + "utf8", + ); + const packageKeys = Object.keys(JSON.parse(configText).packages); + assert.equal(packageKeys[0], "."); + assert.deepEqual(packageKeys.slice(1), [...packageKeys.slice(1)].sort()); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("throws if .release-please-manifest.json is missing", () => { + const tmp = setupRepo(); + try { + fs.unlinkSync(path.join(tmp, ".release-please-manifest.json")); + assert.throws( + () => registerFeatureInReleasePlease(tmp, "x"), + /\.release-please-manifest\.json is missing/, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("throws if release-please-config.json is missing", () => { + const tmp = setupRepo(); + try { + fs.unlinkSync(path.join(tmp, "release-please-config.json")); + assert.throws( + () => registerFeatureInReleasePlease(tmp, "x"), + /release-please-config\.json is missing/, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/turbo/generators/lib/release-please-utils.ts b/turbo/generators/lib/release-please-utils.ts new file mode 100644 index 0000000..d06394d --- /dev/null +++ b/turbo/generators/lib/release-please-utils.ts @@ -0,0 +1,101 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const MANIFEST_FILE = ".release-please-manifest.json"; +const CONFIG_FILE = "release-please-config.json"; +const INITIAL_VERSION = "0.1.0"; + +/** + * Register a new feature package in release-please tracking (ADR-021). + * + * Mutates two files at the repo root: + * - `.release-please-manifest.json` — adds `"packages/": "0.1.0"` + * - `release-please-config.json` — adds the per-package config block + * + * Idempotent: if the package is already tracked, both writes are no-ops. + * + * Throws if either file is missing — release-please MUST be set up before + * a feature generator can register against it (ADR-021 land-down sequence). + */ +export function registerFeatureInReleasePlease( + repoRoot: string, + featureKebab: string, +): { manifestChanged: boolean; configChanged: boolean } { + const manifestPath = join(repoRoot, MANIFEST_FILE); + const configPath = join(repoRoot, CONFIG_FILE); + + if (!existsSync(manifestPath)) { + throw new Error( + `Cannot register feature in release-please: ${MANIFEST_FILE} is missing. ` + + `release-please must be set up before scaffolding features that participate in tracking.`, + ); + } + if (!existsSync(configPath)) { + throw new Error( + `Cannot register feature in release-please: ${CONFIG_FILE} is missing.`, + ); + } + + const packagePath = `packages/${featureKebab}`; + const manifestChanged = addToManifest(manifestPath, packagePath); + const configChanged = addToConfig(configPath, packagePath, featureKebab); + + return { manifestChanged, configChanged }; +} + +function addToManifest(manifestPath: string, packagePath: string): boolean { + const text = readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(text) as Record; + if (manifest[packagePath]) return false; // already tracked + manifest[packagePath] = INITIAL_VERSION; + // Sort keys: root (".") stays first if present; the rest alphabetically by + // path so insertions are deterministic + future diffs stay minimal. + const sorted: Record = {}; + if (manifest["."] !== undefined) sorted["."] = manifest["."]; + for (const key of Object.keys(manifest).sort()) { + if (key === ".") continue; + sorted[key] = manifest[key]; + } + writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + "\n"); + return true; +} + +function addToConfig( + configPath: string, + packagePath: string, + featureKebab: string, +): boolean { + const text = readFileSync(configPath, "utf8"); + const config = JSON.parse(text) as { + packages?: Record< + string, + { + "package-name": string; + component: string; + "changelog-path"?: string; + } + >; + [k: string]: unknown; + }; + if (!config.packages) { + throw new Error( + `release-please-config.json has no "packages" key — config is malformed.`, + ); + } + if (config.packages[packagePath]) return false; // already tracked + config.packages[packagePath] = { + "package-name": `@repo/${featureKebab}`, + component: featureKebab, + "changelog-path": "CHANGELOG.md", + }; + // Sort packages the same way as the manifest: root first, rest alphabetical. + const sortedPackages: typeof config.packages = {}; + if (config.packages["."]) sortedPackages["."] = config.packages["."]; + for (const key of Object.keys(config.packages).sort()) { + if (key === ".") continue; + sortedPackages[key] = config.packages[key]; + } + config.packages = sortedPackages; + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); + return true; +} diff --git a/turbo/generators/templates/feature/CHANGELOG.md.hbs b/turbo/generators/templates/feature/CHANGELOG.md.hbs new file mode 100644 index 0000000..ce4f01f --- /dev/null +++ b/turbo/generators/templates/feature/CHANGELOG.md.hbs @@ -0,0 +1,9 @@ +# Changelog — @repo/{{kebabCase name}} + +All notable changes to the `{{kebabCase name}}` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md). + +## 0.1.0 (Initial) + +### Initial baseline + +The `{{kebabCase name}}` feature scaffolded via `pnpm turbo gen feature {{kebabCase name}}`. Tracked at v0.1.0 in the release-please manifest; future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/{{kebabCase name}}/**`. diff --git a/turbo/generators/templates/feature/package.json.hbs b/turbo/generators/templates/feature/package.json.hbs index a7e8784..b0f98f3 100644 --- a/turbo/generators/templates/feature/package.json.hbs +++ b/turbo/generators/templates/feature/package.json.hbs @@ -1,7 +1,7 @@ { "name": "@repo/{{kebabCase name}}", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.ts",