feat(turbo-generators): feature scaffold registers in release-please
Closes the user's gap: when `pnpm turbo gen feature <name>` scaffolds
a new feature, that feature must also be tracked by release-please —
otherwise it sits outside the versioning + changelog pipeline.
The generator now performs three release-please integrations:
1. **CHANGELOG.md seeded at v0.1.0** — new template at
templates/feature/CHANGELOG.md.hbs emits a baseline entry pointing
at ADR-021 + docs/guides/releasing.md so the consumer immediately
sees where future entries will appear.
2. **package.json version field bumped** — templates/feature/
package.json.hbs: "0.0.0" -> "0.1.0", matching the per-feature
baseline established when release-please was set up.
3. **Manifest + config registration via a new custom action** —
lib/release-please-utils.ts exports
registerFeatureInReleasePlease(repoRoot, name) which:
- Reads .release-please-manifest.json, adds
`"packages/<name>": "0.1.0"`, writes back with sorted keys
(root stays first, rest alphabetical) so diffs stay minimal
- Reads release-please-config.json, adds the per-package config
block (package-name, component, changelog-path), writes back
with the same sort
- Idempotent — re-running on an already-tracked feature is a
no-op
- Throws fast if either file is missing (ADR-021 requires
release-please to be set up BEFORE features can register)
The generator wires this in via a function action between the last
file `add` and the next-steps printout. Its return string surfaces
in the generator log so the user sees "Registered @repo/<name> in
release-please tracking".
Tested: 5/5 unit tests cover the happy path, idempotency, sort
order, and both missing-file error paths. Smoke-tested against the
real repo configs (adding a synthetic "demo" feature, then
restoring) — manifest entry appears in the correct sorted position;
config block has the right shape.
Future `pnpm turbo gen feature` invocations cannot leave a feature
untracked. Existing features (auth, blog, media, marketing-pages,
navigation) were registered manually when the release-please epic
landed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
addBoundariesEntry,
|
addBoundariesEntry,
|
||||||
emitTemplateTree,
|
emitTemplateTree,
|
||||||
} from "./lib/core-package-utils.js";
|
} from "./lib/core-package-utils.js";
|
||||||
|
import { registerFeatureInReleasePlease } from "./lib/release-please-utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turbo generator: `feature`
|
* Turbo generator: `feature`
|
||||||
@@ -306,6 +307,30 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
templateFile: "templates/feature/src/ui/index.ts.hbs",
|
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, unknown>,
|
||||||
|
): 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
|
// Final manual-wiring instructions printed to the user
|
||||||
function printNextSteps(answers: Record<string, unknown>): string {
|
function printNextSteps(answers: Record<string, unknown>): string {
|
||||||
const a = answers as {
|
const a = answers as {
|
||||||
|
|||||||
146
turbo/generators/lib/release-please-utils.test.mjs
Normal file
146
turbo/generators/lib/release-please-utils.test.mjs
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
101
turbo/generators/lib/release-please-utils.ts
Normal file
101
turbo/generators/lib/release-please-utils.ts
Normal file
@@ -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/<name>": "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<string, string>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
9
turbo/generators/templates/feature/CHANGELOG.md.hbs
Normal file
9
turbo/generators/templates/feature/CHANGELOG.md.hbs
Normal file
@@ -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}}/**`.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/{{kebabCase name}}",
|
"name": "@repo/{{kebabCase name}}",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user