Add a feature catalog build artifact derived from the experimental settings schema (#10055)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Instances expose ~23 experimental feature settings, all declared in one shared zod schema and toggled per instance > - Deployment tooling and hosting control planes have no machine-readable list of those feature keys for a given release — the schema is only reachable from code that imports the package > - Any external system that references feature keys therefore does so as free text, and typos drift silently > - This pull request derives a versioned `feature-catalog.json` build artifact from the schema, with a compiler-checked metadata map so the schema stays the single source of truth > - The benefit is a stable contract external tooling can validate feature-key references against, with zero runtime behavior change ## Linked Issues or Issue Description No public issue exists; `feature_request` template fields: **Problem or motivation:** External deployment tooling cannot enumerate or validate an instance's feature keys per release; free-text references fail silently when keys are renamed or removed. **Proposed solution:** A metadata map keyed by the settings schema's own keys (compiler flags drift) plus a build step emitting `feature-catalog.json` (keys, tiers, defaults, `catalogVersion`) as a release artifact. **Alternatives considered:** A hand-maintained catalog file (drifts from the schema); serving the schema from a runtime API (requires a running instance at validation time — a build artifact works offline and pins to a release). **Roadmap alignment:** Supports the in-progress "Cloud deployments" milestone in `ROADMAP.md`. ## What Changed Adds a metadata map (title, description, tier, cloud/self-hosted defaults) keyed by the keys of `instanceExperimentalSettingsSchema`, so the schema stays the single source of truth and the compiler flags any drift. A new build step (`build:feature-catalog --version <v>`) emits `feature-catalog.json` — all 23 feature keys, their tiers, and a `catalogVersion` — as a release artifact that managed-hosting control planes can validate feature-flag writes against. No runtime behavior changes. - New `packages/shared/src/feature-catalog.ts`: per-flag metadata map keyed by a type derived from the settings schema (adding/removing/renaming a flag without updating the map is a compile error), plus `featureCatalogArtifactSchema` and `buildFeatureCatalogArtifact`/`renderFeatureCatalogArtifact` for the artifact - New `scripts/generate-feature-catalog.ts` wired as `pnpm build:feature-catalog --version <v>` - `scripts/create-github-release.sh` generates the artifact and uploads it as a GitHub Release asset (with a dry-run preview line) - Tests in `packages/shared/src/feature-catalog.test.ts` ## Verification - `vitest run packages/shared/src/feature-catalog.test.ts` — 9 tests: schema-key coverage, drift detection, artifact shape - `pnpm --filter @paperclipai/shared typecheck` - Artifact generation run end-to-end: `pnpm build:feature-catalog --version 0.0.0-test` emits 23 keys with `catalogVersion` ## Risks Low risk — no runtime behavior changes; the change is metadata, a build script, and a release-artifact emission step only. ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use; independently peer-reviewed by a second AI agent before push ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e55d702916
commit
ad74fb5450
|
|
@ -15,6 +15,7 @@
|
|||
"storybook": "pnpm --filter @paperclipai/ui storybook",
|
||||
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
|
||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||
"build:feature-catalog": "node cli/node_modules/tsx/dist/cli.mjs scripts/generate-feature-catalog.ts",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs",
|
||||
"test": "pnpm run test:run",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
FEATURE_TIERS,
|
||||
INSTANCE_FEATURE_CATALOG,
|
||||
INSTANCE_FEATURE_KEYS,
|
||||
buildFeatureCatalogArtifact,
|
||||
featureCatalogArtifactSchema,
|
||||
renderFeatureCatalogArtifact,
|
||||
} from "./feature-catalog.js";
|
||||
import { instanceExperimentalSettingsSchema } from "./validators/instance.js";
|
||||
|
||||
function schemaBooleanFlagKeys(): string[] {
|
||||
return Object.entries(instanceExperimentalSettingsSchema.shape)
|
||||
.filter(([, fieldSchema]) => {
|
||||
let current: z.ZodTypeAny = fieldSchema as z.ZodTypeAny;
|
||||
for (;;) {
|
||||
if (current instanceof z.ZodDefault) {
|
||||
current = current._def.innerType as z.ZodTypeAny;
|
||||
} else if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
|
||||
current = current.unwrap() as z.ZodTypeAny;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current instanceof z.ZodBoolean;
|
||||
})
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe("INSTANCE_FEATURE_CATALOG", () => {
|
||||
it("covers exactly the boolean flag keys of the experimental settings schema", () => {
|
||||
expect([...INSTANCE_FEATURE_KEYS]).toEqual(schemaBooleanFlagKeys());
|
||||
});
|
||||
|
||||
it("keeps selfHostedDefault in sync with the schema defaults", () => {
|
||||
const schemaDefaults = instanceExperimentalSettingsSchema.parse({});
|
||||
for (const key of INSTANCE_FEATURE_KEYS) {
|
||||
expect(INSTANCE_FEATURE_CATALOG[key].selfHostedDefault, key).toBe(schemaDefaults[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has a non-empty title, description, and valid tier for every flag", () => {
|
||||
for (const key of INSTANCE_FEATURE_KEYS) {
|
||||
const entry = INSTANCE_FEATURE_CATALOG[key];
|
||||
expect(entry.title.trim().length, key).toBeGreaterThan(0);
|
||||
expect(entry.description.trim().length, key).toBeGreaterThan(0);
|
||||
expect(FEATURE_TIERS, key).toContain(entry.tier);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureCatalogArtifact", () => {
|
||||
it("emits catalogVersion plus one tier entry per flag key", () => {
|
||||
const artifact = buildFeatureCatalogArtifact("2026.720.0");
|
||||
expect(artifact.catalogVersion).toBe("2026.720.0");
|
||||
expect(Object.keys(artifact.features)).toEqual([...INSTANCE_FEATURE_KEYS]);
|
||||
for (const key of INSTANCE_FEATURE_KEYS) {
|
||||
expect(artifact.features[key]).toEqual({ tier: INSTANCE_FEATURE_CATALOG[key].tier });
|
||||
}
|
||||
});
|
||||
|
||||
it("produces output that validates against featureCatalogArtifactSchema", () => {
|
||||
const artifact = buildFeatureCatalogArtifact("2026.720.0");
|
||||
expect(featureCatalogArtifactSchema.parse(artifact)).toEqual(artifact);
|
||||
});
|
||||
|
||||
it("rejects an empty catalogVersion", () => {
|
||||
expect(() => buildFeatureCatalogArtifact("")).toThrow(/catalogVersion/);
|
||||
expect(() => buildFeatureCatalogArtifact(" ")).toThrow(/catalogVersion/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("featureCatalogArtifactSchema", () => {
|
||||
it("rejects unknown top-level or per-feature properties", () => {
|
||||
const valid = buildFeatureCatalogArtifact("2026.720.0");
|
||||
expect(
|
||||
featureCatalogArtifactSchema.safeParse({ ...valid, extra: true }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
featureCatalogArtifactSchema.safeParse({
|
||||
catalogVersion: "2026.720.0",
|
||||
features: { enableApps: { tier: "managed", extra: true } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown tiers and a missing catalogVersion", () => {
|
||||
expect(
|
||||
featureCatalogArtifactSchema.safeParse({
|
||||
catalogVersion: "2026.720.0",
|
||||
features: { enableApps: { tier: "mystery" } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
featureCatalogArtifactSchema.safeParse({ features: {} }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFeatureCatalogArtifact", () => {
|
||||
it("is deterministic, sorted, and newline-terminated", () => {
|
||||
const rendered = renderFeatureCatalogArtifact("2026.720.0");
|
||||
expect(rendered).toBe(renderFeatureCatalogArtifact("2026.720.0"));
|
||||
expect(rendered.endsWith("\n")).toBe(true);
|
||||
const parsed = featureCatalogArtifactSchema.parse(JSON.parse(rendered));
|
||||
expect(Object.keys(parsed.features)).toEqual([...Object.keys(parsed.features)].sort());
|
||||
expect(parsed).toEqual(buildFeatureCatalogArtifact("2026.720.0"));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { z } from "zod";
|
||||
import { instanceExperimentalSettingsSchema } from "./validators/instance.js";
|
||||
|
||||
/**
|
||||
* Feature catalog for cloud-managed instances.
|
||||
*
|
||||
* The instance-settings zod schema is the feature manifest; this module adds
|
||||
* only metadata about the flags the schema already declares. Keys are derived
|
||||
* from the schema type, so adding, removing, or renaming a boolean flag in
|
||||
* `instanceExperimentalSettingsSchema` without updating the metadata map is a
|
||||
* compile error (and vice versa).
|
||||
*
|
||||
* Tiers:
|
||||
* - `preference`: tenant-controllable taste setting; the cloud harness does
|
||||
* not manage it.
|
||||
* - `managed`: the cloud harness may set this per fleet/stack via
|
||||
* `PAPERCLIP_MANAGED_CONFIG`.
|
||||
* - `floor`: pinned by code on managed instances; no flag value may widen it.
|
||||
*/
|
||||
export const FEATURE_TIERS = ["preference", "managed", "floor"] as const;
|
||||
|
||||
export type FeatureTier = (typeof FEATURE_TIERS)[number];
|
||||
|
||||
type ExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
|
||||
|
||||
/**
|
||||
* The boolean flag keys of the experimental settings schema. Non-flag keys
|
||||
* (activation timestamps, numeric tuning values) are excluded.
|
||||
*/
|
||||
export type InstanceFeatureKey = {
|
||||
[K in keyof ExperimentalSettings]: ExperimentalSettings[K] extends boolean ? K : never;
|
||||
}[keyof ExperimentalSettings];
|
||||
|
||||
export interface FeatureCatalogEntry {
|
||||
title: string;
|
||||
description: string;
|
||||
tier: FeatureTier;
|
||||
/** Desired default on cloud-managed instances. */
|
||||
cloudDefault: boolean;
|
||||
/** Must match the schema default; enforced by test. */
|
||||
selfHostedDefault: boolean;
|
||||
}
|
||||
|
||||
export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalogEntry> = {
|
||||
enableEnvironments: {
|
||||
title: "Environments",
|
||||
description:
|
||||
"Show environment management in company settings and allow project and agent environment assignment controls.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIsolatedWorkspaces: {
|
||||
title: "Isolated Workspaces",
|
||||
description:
|
||||
"Show execution workspace controls in project configuration and allow isolated workspace behavior for task runs.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableStreamlinedLeftNavigation: {
|
||||
title: "Streamlined Left Navigation",
|
||||
description: "Use the streamlined main sidebar navigation layout.",
|
||||
tier: "preference",
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enableApps: {
|
||||
title: "Apps",
|
||||
description:
|
||||
"Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enablePipelines: {
|
||||
title: "Pipelines",
|
||||
description: "Enable pipeline definitions and pipeline-driven case production surfaces.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableCases: {
|
||||
title: "Cases",
|
||||
description:
|
||||
"Durable work products that tasks create and iterate on. Adds the Cases tab and the agent case API.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableConferenceRoomChat: {
|
||||
title: "Conference Room Chat",
|
||||
description:
|
||||
"Add the Conference Room team chat, the live activity feed, and the redesigned onboarding; restyles task threads as chat bubbles.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableTaskWatchdogs: {
|
||||
title: "Task Watchdogs",
|
||||
description:
|
||||
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIssuePlanDecompositions: {
|
||||
title: "Task Plan Decomposition Panel",
|
||||
description: "Show accepted-plan decomposition history on task detail pages.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableExperimentalFileViewer: {
|
||||
title: "Experimental File Viewer",
|
||||
description:
|
||||
"Show task detail controls for browsing and previewing workspace files relative to a task.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableCloudSync: {
|
||||
title: "Cloud Sync",
|
||||
description:
|
||||
"Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableExternalObjects: {
|
||||
title: "External Objects",
|
||||
description:
|
||||
"Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced work objects.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableSmokeLab: {
|
||||
title: "Smoke Lab",
|
||||
description:
|
||||
"Add the Smoke Lab tab and dashboard card for exercising integration paths against deterministic local fixtures. Private deployments only.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableBuiltInAgents: {
|
||||
title: "Built-in Agents",
|
||||
description:
|
||||
"Show Paperclip-managed built-in agent surfaces, including roster badges, the Built-in agents tab, and setup controls.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableSummaries: {
|
||||
title: "Summaries",
|
||||
description:
|
||||
"Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableDecisions: {
|
||||
title: "Decisions",
|
||||
description:
|
||||
"Show the Decisions item in the main sidebar — the attention home that surfaces tasks awaiting input.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableGoalsSidebarLink: {
|
||||
title: "Goals Sidebar Link",
|
||||
description: "Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableServerInfoDebugView: {
|
||||
title: "Server Info Debug View",
|
||||
description:
|
||||
"Show a Server section in the account drawer with the current server restart time and running commit.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
autoRestartDevServerWhenIdle: {
|
||||
title: "Auto-Restart Dev Server When Idle",
|
||||
description:
|
||||
"In local development, wait for queued and running agent runs to finish, then restart the server automatically when backend changes make the current boot stale.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIssueGraphLivenessAutoRecovery: {
|
||||
title: "Auto-Create Recovery Tasks",
|
||||
description:
|
||||
"Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the configured lookback window.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableWorkspaceBranchReconcileForward: {
|
||||
title: "Workspace Branch Reconcile Forward",
|
||||
description:
|
||||
"Let execution workspaces reconcile a diverged recorded branch forward instead of failing branch containment.",
|
||||
tier: "managed",
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enableWorkspaceDirtyQuarantineRepair: {
|
||||
title: "Workspace Dirty Quarantine Repair",
|
||||
description:
|
||||
"Let workspace runtime recovery quarantine and repair dirty execution workspaces before runs.",
|
||||
tier: "managed",
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enableWorktreeRunExecution: {
|
||||
title: "Worktree Run Execution",
|
||||
description:
|
||||
"Let the scheduler execute runs inside an isolated git-worktree preview instance for tasks created after activation.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const INSTANCE_FEATURE_KEYS = Object.keys(INSTANCE_FEATURE_CATALOG).sort() as InstanceFeatureKey[];
|
||||
|
||||
/**
|
||||
* Shape of the `feature-catalog.json` release artifact the cloud harness
|
||||
* imports per app release and validates feature writes against.
|
||||
*/
|
||||
export const featureCatalogArtifactSchema = z
|
||||
.object({
|
||||
catalogVersion: z.string().min(1),
|
||||
features: z.record(
|
||||
z.string().min(1),
|
||||
z.object({ tier: z.enum(FEATURE_TIERS) }).strict(),
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type FeatureCatalogArtifact = z.infer<typeof featureCatalogArtifactSchema>;
|
||||
|
||||
export function buildFeatureCatalogArtifact(catalogVersion: string): FeatureCatalogArtifact {
|
||||
if (catalogVersion.trim().length === 0) {
|
||||
throw new Error("catalogVersion must be a non-empty string");
|
||||
}
|
||||
const features: FeatureCatalogArtifact["features"] = {};
|
||||
for (const key of INSTANCE_FEATURE_KEYS) {
|
||||
features[key] = { tier: INSTANCE_FEATURE_CATALOG[key].tier };
|
||||
}
|
||||
return { catalogVersion, features };
|
||||
}
|
||||
|
||||
/** Deterministic serialization (sorted keys, trailing newline) for the artifact file. */
|
||||
export function renderFeatureCatalogArtifact(catalogVersion: string): string {
|
||||
return `${JSON.stringify(buildFeatureCatalogArtifact(catalogVersion), null, 2)}\n`;
|
||||
}
|
||||
|
|
@ -2219,3 +2219,15 @@ export {
|
|||
type EnvironmentCustomImageTerminalSessionToken,
|
||||
} from "./validators/environment-custom-images.js";
|
||||
export * from "./validators/skill-policy.js";
|
||||
export {
|
||||
FEATURE_TIERS,
|
||||
INSTANCE_FEATURE_CATALOG,
|
||||
INSTANCE_FEATURE_KEYS,
|
||||
buildFeatureCatalogArtifact,
|
||||
featureCatalogArtifactSchema,
|
||||
renderFeatureCatalogArtifact,
|
||||
type FeatureCatalogArtifact,
|
||||
type FeatureCatalogEntry,
|
||||
type FeatureTier,
|
||||
type InstanceFeatureKey,
|
||||
} from "./feature-catalog.js";
|
||||
|
|
|
|||
|
|
@ -80,8 +80,30 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# The catalog is derived from the checked-out sources, so it must be generated
|
||||
# from the exact commit the release tag points at, with no local edits.
|
||||
tag_commit="$(git -C "$REPO_ROOT" rev-parse "$tag^{commit}")"
|
||||
head_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
if [ "$head_commit" != "$tag_commit" ]; then
|
||||
echo "Error: HEAD ($head_commit) does not match tag $tag ($tag_commit). Check out the release tag before generating the feature catalog." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Error: working tree has uncommitted changes. The feature catalog must be generated from the pristine release commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
catalog_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$catalog_dir"' EXIT
|
||||
catalog_file="$catalog_dir/feature-catalog.json"
|
||||
node "$REPO_ROOT/cli/node_modules/tsx/dist/cli.mjs" \
|
||||
"$REPO_ROOT/scripts/generate-feature-catalog.ts" \
|
||||
--version "$version" \
|
||||
--out "$catalog_file"
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
|
||||
echo "[dry-run] gh release upload $tag -R $GITHUB_REPO --clobber $catalog_file"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -97,3 +119,6 @@ else
|
|||
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
|
||||
echo "Created GitHub Release $tag"
|
||||
fi
|
||||
|
||||
gh release upload "$tag" -R "$GITHUB_REPO" --clobber "$catalog_file"
|
||||
echo "Uploaded feature-catalog.json to GitHub Release $tag"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
// Emits the feature-catalog.json release artifact the cloud harness imports
|
||||
// per app release and validates feature writes against. The catalog content
|
||||
// is derived from the instance-settings schema metadata in
|
||||
// packages/shared/src/feature-catalog.ts.
|
||||
//
|
||||
// Usage:
|
||||
// tsx scripts/generate-feature-catalog.ts --version 2026.720.0 [--out path/to/feature-catalog.json]
|
||||
//
|
||||
// Without --out, the artifact is written to stdout.
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { renderFeatureCatalogArtifact } from "../packages/shared/src/feature-catalog.js";
|
||||
|
||||
function usage(): never {
|
||||
console.error(
|
||||
"Usage: tsx scripts/generate-feature-catalog.ts --version <catalogVersion> [--out <file>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let version = "";
|
||||
let outPath = "";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
switch (args[i]) {
|
||||
case "--version":
|
||||
version = args[++i] ?? "";
|
||||
break;
|
||||
case "--out":
|
||||
outPath = args[++i] ?? "";
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
usage();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown argument: ${args[i]}`);
|
||||
usage();
|
||||
}
|
||||
}
|
||||
|
||||
if (version.trim().length === 0) {
|
||||
console.error("Error: --version is required and must be non-empty.");
|
||||
usage();
|
||||
}
|
||||
|
||||
const rendered = renderFeatureCatalogArtifact(version);
|
||||
|
||||
if (outPath) {
|
||||
const target = resolve(outPath);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, rendered, "utf8");
|
||||
console.error(`Wrote feature catalog for version ${version} to ${target}`);
|
||||
} else {
|
||||
process.stdout.write(rendered);
|
||||
}
|
||||
Loading…
Reference in New Issue