Files
agentic-dev/turbo/generators/lib/release-please-utils.ts
Danijel Martinek 769548c186 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>
2026-05-13 17:21:49 +02:00

102 lines
3.5 KiB
TypeScript

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;
}