@celilo/cli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -48,6 +48,7 @@ import { handleMachineRemove } from './commands/machine-remove';
48
48
  import { handleMachineStatus } from './commands/machine-status';
49
49
  import { moduleAudit } from './commands/module-audit';
50
50
  import { handleModuleBuild } from './commands/module-build';
51
+ import { handleModuleChangeset } from './commands/module-changeset';
51
52
  import { handleModuleCheck } from './commands/module-check';
52
53
  import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-config';
53
54
  import { handleModuleDeploy } from './commands/module-deploy';
@@ -66,9 +67,8 @@ import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/mo
66
67
  import { handleModuleUpdate } from './commands/module-update';
67
68
  import { handleModuleUpgrade } from './commands/module-upgrade';
68
69
  import { moduleVerify } from './commands/module-verify';
70
+ import { handleModuleVersion } from './commands/module-version';
69
71
  import { handlePackage } from './commands/package';
70
- import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
71
- import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
72
72
  import { handleProxmoxNodeList } from './commands/proxmox-node-list';
73
73
  import { main as runPublish } from './commands/publish';
74
74
  import { handleSecretList } from './commands/secret-list';
@@ -1272,6 +1272,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1272
1272
  return handleModuleUpdate(parsed.args, parsed.flags);
1273
1273
  case 'upgrade':
1274
1274
  return handleModuleUpgrade(parsed.args, parsed.flags);
1275
+ case 'version':
1276
+ return handleModuleVersion(parsed.args, parsed.flags);
1277
+ case 'changeset':
1278
+ return handleModuleChangeset(parsed.args, parsed.flags);
1275
1279
  case 'audit':
1276
1280
  return moduleAudit(parsed.args);
1277
1281
  case 'verify':
@@ -1818,19 +1822,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1818
1822
  error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
1819
1823
  };
1820
1824
  }
1821
- if (parsed.subcommand === 'vm' || parsed.subcommand === 'ct') {
1822
- const action = parsed.args[0];
1823
- if (action === 'list') {
1824
- return handleProxmoxInstanceList(parsed.subcommand, parsed.args.slice(1));
1825
- }
1826
- if (action === 'resize') {
1827
- return handleProxmoxInstanceResize(parsed.subcommand, parsed.args.slice(1), parsed.flags);
1828
- }
1829
- return {
1830
- success: false,
1831
- error: `Proxmox ${parsed.subcommand} action required (list, resize)\n\nRun "celilo proxmox --help" for usage`,
1832
- };
1833
- }
1834
1825
  return {
1835
1826
  success: false,
1836
1827
  error: `Unknown proxmox subcommand: ${parsed.subcommand}\n\nRun "celilo proxmox --help" for usage`,
package/src/db/schema.ts CHANGED
@@ -392,17 +392,6 @@ export const moduleSystems = sqliteTable(
392
392
  serviceId: text('service_id').references(() => containerServices.id),
393
393
  /** Proxmox VMID — set only for proxmox containers. */
394
394
  vmid: integer('vmid'),
395
- // Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
396
- // VM/LXC instances only (null for machine-pool systems celilo doesn't size).
397
- // Seeded from the module's `requires.system` at first provision, then owned
398
- // by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
399
- // never the live size. See CLAUDE.md "requires.system is the MINIMUM".
400
- /** vCPU cores. */
401
- cpu: integer('cpu'),
402
- /** RAM in MB. */
403
- memory: integer('memory'),
404
- /** Root disk in GB. */
405
- disk: integer('disk'),
406
395
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
407
396
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
408
397
  },
@@ -443,6 +443,30 @@ export const ModuleManifestSchema = z
443
443
  version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'),
444
444
  description: z.string().optional(),
445
445
 
446
+ /**
447
+ * How `manifest.yml#version` (the PAYLOAD version) is determined — see
448
+ * v2/MODULE_VERSIONING.md / ISS-0151. The capability *contract* version
449
+ * lives in `provides.capabilities[].version` and is unrelated to this.
450
+ *
451
+ * - `changeset` — first-party apps/content (lunacycle): the version is
452
+ * authored via `.changeset/*.md` keyed by the module id; `celilo module
453
+ * version` stamps it here. Ordered by the `+N` revision; semver is a
454
+ * blast-radius/posture signal, not a contract.
455
+ * - `pin` — wrapper modules (caddy, forgejo): `resolver` returns the
456
+ * upstream version actually installed; `module check` fails on drift.
457
+ * - `recipe` — payload-less modules (iptables): no software version; `+N`
458
+ * orders. The default when this block is absent, preserving today's
459
+ * behaviour for unmigrated modules.
460
+ */
461
+ version_source: z
462
+ .object({
463
+ kind: z.enum(['pin', 'changeset', 'recipe']),
464
+ /** For `kind: pin`: path (module-relative) to a script printing the upstream version. */
465
+ resolver: z.string().optional(),
466
+ })
467
+ .strict()
468
+ .optional(),
469
+
446
470
  requires: z
447
471
  .object({
448
472
  capabilities: z.array(CapabilityRequirementSchema).default([]),
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ type BumpType,
4
+ type ModuleVersionPlan,
5
+ applyBump,
6
+ maxBump,
7
+ parseChangeset,
8
+ planModuleVersion,
9
+ renderChangelogSection,
10
+ } from './changeset-version';
11
+
12
+ describe('parseChangeset', () => {
13
+ it('parses quoted id + bump and the summary body', () => {
14
+ const r = parseChangeset('---\n"lunacycle": minor\n---\n\nAdded a thing.\n');
15
+ expect(r.bumps).toEqual({ lunacycle: 'minor' });
16
+ expect(r.summary).toBe('Added a thing.');
17
+ });
18
+
19
+ it('parses unquoted ids and multiple modules', () => {
20
+ const r = parseChangeset('---\nlunacycle: patch\nnigredo: major\n---\nfix\n');
21
+ expect(r.bumps).toEqual({ lunacycle: 'patch', nigredo: 'major' });
22
+ });
23
+
24
+ it('throws on a missing frontmatter fence', () => {
25
+ expect(() => parseChangeset('just a summary, no frontmatter')).toThrow(/frontmatter/);
26
+ });
27
+
28
+ it('throws on an invalid bump keyword', () => {
29
+ expect(() => parseChangeset('---\nlunacycle: huge\n---\n')).toThrow(/major\|minor\|patch/);
30
+ });
31
+
32
+ it('throws on a frontmatter line without a colon', () => {
33
+ expect(() => parseChangeset('---\nlunacycle minor\n---\n')).toThrow(/not "id: bump"/);
34
+ });
35
+ });
36
+
37
+ describe('maxBump', () => {
38
+ it('ranks major > minor > patch', () => {
39
+ expect(maxBump(['patch', 'major', 'minor'])).toBe('major');
40
+ expect(maxBump(['patch', 'minor'])).toBe('minor');
41
+ expect(maxBump(['patch'])).toBe('patch');
42
+ });
43
+ it('returns null for an empty list', () => {
44
+ expect(maxBump([])).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('applyBump', () => {
49
+ it('bumps each level and zeroes lower segments', () => {
50
+ expect(applyBump('1.2.3', 'major')).toBe('2.0.0');
51
+ expect(applyBump('1.2.3', 'minor')).toBe('1.3.0');
52
+ expect(applyBump('1.2.3', 'patch')).toBe('1.2.4');
53
+ });
54
+ it('throws on a non x.y.z version', () => {
55
+ expect(() => applyBump('1.2', 'patch')).toThrow(/x\.y\.z/);
56
+ expect(() => applyBump('1.0.0+4', 'patch')).toThrow(/x\.y\.z/);
57
+ });
58
+ });
59
+
60
+ describe('planModuleVersion', () => {
61
+ const cs = (id: string, bump: BumpType, summary = '') =>
62
+ parseChangeset(`---\n"${id}": ${bump}\n---\n${summary}\n`);
63
+
64
+ it('returns null when no changeset targets the module', () => {
65
+ expect(planModuleVersion('1.0.0', 'lunacycle', [])).toBeNull();
66
+ expect(planModuleVersion('1.0.0', 'lunacycle', [cs('nigredo', 'minor')])).toBeNull();
67
+ });
68
+
69
+ it('takes the max bump across relevant changesets', () => {
70
+ const plan = planModuleVersion('1.2.3', 'lunacycle', [
71
+ cs('lunacycle', 'patch', 'a'),
72
+ cs('lunacycle', 'minor', 'b'),
73
+ cs('nigredo', 'major', 'unrelated'),
74
+ ]);
75
+ expect(plan).not.toBeNull();
76
+ expect(plan?.bump).toBe('minor');
77
+ expect(plan?.next).toBe('1.3.0');
78
+ expect(plan?.entries).toEqual(['a', 'b']);
79
+ });
80
+
81
+ it('omits empty summaries from entries', () => {
82
+ const plan = planModuleVersion('1.0.0', 'lunacycle', [
83
+ cs('lunacycle', 'patch', ''),
84
+ cs('lunacycle', 'patch', 'real'),
85
+ ]);
86
+ expect(plan?.entries).toEqual(['real']);
87
+ });
88
+ });
89
+
90
+ describe('renderChangelogSection', () => {
91
+ it('renders a newest-on-top section with the bump heading', () => {
92
+ const plan = planModuleVersion('1.0.0', 'lunacycle', [
93
+ parseChangeset('---\nlunacycle: minor\n---\nAdded tasks view.\n'),
94
+ ]);
95
+ expect(plan).not.toBeNull();
96
+ const md = renderChangelogSection(plan as ModuleVersionPlan);
97
+ expect(md).toContain('## 1.1.0');
98
+ expect(md).toContain('### Minor Changes');
99
+ expect(md).toContain('- Added tasks view.');
100
+ });
101
+
102
+ it('falls back to a generic bullet when there are no summaries', () => {
103
+ const plan = planModuleVersion('1.0.0', 'lunacycle', [
104
+ parseChangeset('---\nlunacycle: patch\n---\n'),
105
+ ]);
106
+ expect(renderChangelogSection(plan as ModuleVersionPlan)).toContain('- Version bump.');
107
+ });
108
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Changeset-driven module versioning (ISS-0151 / v2/MODULE_VERSIONING.md).
3
+ *
4
+ * For `version_source: { kind: changeset }` modules, the payload version is
5
+ * authored via `.changeset/*.md` files — the same on-disk format the changesets
6
+ * CLI uses, but keyed by the MODULE id (Option A) rather than an npm package, so
7
+ * a celilo module needs no package.json. `celilo module version` reads those
8
+ * files, computes the next `manifest.yml#version`, and writes a CHANGELOG.
9
+ *
10
+ * This file is the pure core: parsing + semver math, no filesystem. The command
11
+ * (module-version.ts) does the I/O.
12
+ */
13
+
14
+ export type BumpType = 'major' | 'minor' | 'patch';
15
+
16
+ const BUMP_RANK: Record<BumpType, number> = { patch: 1, minor: 2, major: 3 };
17
+
18
+ export function isBumpType(s: string): s is BumpType {
19
+ return s === 'major' || s === 'minor' || s === 'patch';
20
+ }
21
+
22
+ export interface ParsedChangeset {
23
+ /** module id → declared bump, from the `---` frontmatter. */
24
+ bumps: Record<string, BumpType>;
25
+ /** the markdown body after the frontmatter, trimmed. */
26
+ summary: string;
27
+ }
28
+
29
+ /**
30
+ * Parse one changeset `.md`: a YAML-ish frontmatter block fenced by `---`
31
+ * mapping `"<module-id>": <bump>` (quotes optional), followed by a prose body.
32
+ *
33
+ * Throws on a malformed frontmatter fence or an invalid bump keyword — a
34
+ * changeset that can't be understood must fail loudly, not silently no-op.
35
+ */
36
+ export function parseChangeset(content: string): ParsedChangeset {
37
+ const normalized = content.replace(/\r\n/g, '\n');
38
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
39
+ if (!match) {
40
+ throw new Error('changeset has no `---` frontmatter block');
41
+ }
42
+ const [, frontmatter, body] = match;
43
+
44
+ const bumps: Record<string, BumpType> = {};
45
+ for (const rawLine of frontmatter.split('\n')) {
46
+ const line = rawLine.trim();
47
+ if (!line) continue;
48
+ const sep = line.indexOf(':');
49
+ if (sep === -1) {
50
+ throw new Error(`changeset frontmatter line is not "id: bump": ${line}`);
51
+ }
52
+ const id = line
53
+ .slice(0, sep)
54
+ .trim()
55
+ .replace(/^['"]|['"]$/g, '');
56
+ const bump = line
57
+ .slice(sep + 1)
58
+ .trim()
59
+ .replace(/^['"]|['"]$/g, '');
60
+ if (!id) throw new Error(`changeset frontmatter has an empty module id: ${line}`);
61
+ if (!isBumpType(bump)) {
62
+ throw new Error(`changeset bump for "${id}" must be major|minor|patch, got "${bump}"`);
63
+ }
64
+ bumps[id] = bump;
65
+ }
66
+
67
+ return { bumps, summary: body.trim() };
68
+ }
69
+
70
+ /** Highest-ranked bump in the list, or null if empty. */
71
+ export function maxBump(bumps: BumpType[]): BumpType | null {
72
+ let best: BumpType | null = null;
73
+ for (const b of bumps) {
74
+ if (best === null || BUMP_RANK[b] > BUMP_RANK[best]) best = b;
75
+ }
76
+ return best;
77
+ }
78
+
79
+ /** Apply a semver bump to an `x.y.z` string. Throws if `version` isn't `x.y.z`. */
80
+ export function applyBump(version: string, bump: BumpType): string {
81
+ const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
82
+ if (!m) throw new Error(`version must be x.y.z, got "${version}"`);
83
+ const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
84
+ switch (bump) {
85
+ case 'major':
86
+ return `${major + 1}.0.0`;
87
+ case 'minor':
88
+ return `${major}.${minor + 1}.0`;
89
+ case 'patch':
90
+ return `${major}.${minor}.${patch + 1}`;
91
+ }
92
+ }
93
+
94
+ export interface ModuleVersionPlan {
95
+ current: string;
96
+ next: string;
97
+ bump: BumpType;
98
+ /** summaries of the changesets that target this module, for the CHANGELOG. */
99
+ entries: string[];
100
+ }
101
+
102
+ /**
103
+ * Compute the next version for `moduleId` from the parsed changesets. Returns
104
+ * null when no changeset targets this module — "nothing to version" is a clean
105
+ * no-op, not an error (e.g. a docs-only PR added a changeset for another module,
106
+ * or none at all).
107
+ */
108
+ export function planModuleVersion(
109
+ current: string,
110
+ moduleId: string,
111
+ changesets: ParsedChangeset[],
112
+ ): ModuleVersionPlan | null {
113
+ const relevant = changesets.filter((c) => moduleId in c.bumps);
114
+ if (relevant.length === 0) return null;
115
+
116
+ const bump = maxBump(relevant.map((c) => c.bumps[moduleId]));
117
+ // relevant is non-empty, so maxBump is non-null.
118
+ if (bump === null) return null;
119
+
120
+ const entries = relevant.map((c) => c.summary).filter((s) => s.length > 0);
121
+ return { current, next: applyBump(current, bump), bump, entries };
122
+ }
123
+
124
+ /**
125
+ * Render a CHANGELOG section for a computed plan (newest-on-top convention).
126
+ * The command prepends this below the file-level `# <module-id>` header.
127
+ */
128
+ export function renderChangelogSection(plan: ModuleVersionPlan): string {
129
+ const heading: Record<BumpType, string> = {
130
+ major: 'Major Changes',
131
+ minor: 'Minor Changes',
132
+ patch: 'Patch Changes',
133
+ };
134
+ const lines = [`## ${plan.next}`, '', `### ${heading[plan.bump]}`, ''];
135
+ const bullets = plan.entries.length > 0 ? plan.entries : ['Version bump.'];
136
+ for (const e of bullets) lines.push(`- ${e.replace(/\n+/g, ' ').trim()}`);
137
+ lines.push('');
138
+ return lines.join('\n');
139
+ }
@@ -1,7 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { rm } from 'node:fs/promises';
4
- import { and, eq } from 'drizzle-orm';
5
4
  import { type DbClient, createDbClient } from '../db/client';
6
5
  import {
7
6
  containerServices,
@@ -11,7 +10,7 @@ import {
11
10
  moduleSystems,
12
11
  modules,
13
12
  } from '../db/schema';
14
- import { backfillModuleSystems, getModuleSystems, upsertDeployedSystem } from './deployed-systems';
13
+ import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
15
14
 
16
15
  const TEST_DB_PATH = './test-deployed-systems.db';
17
16
 
@@ -234,74 +233,3 @@ describe('backfillModuleSystems', () => {
234
233
  expect(getModuleSystems('namecheap', db)).toHaveLength(0);
235
234
  });
236
235
  });
237
-
238
- /**
239
- * Canonical instance sizing (ISS-0150): sizing is seeded onto module_systems
240
- * once at first provision and then OWNED by `celilo proxmox … resize` — a routine
241
- * re-deploy must never reset a resized instance back to its manifest minimum.
242
- */
243
- describe('upsertDeployedSystem sizing — seed-once (ISS-0150)', () => {
244
- let db: DbClient;
245
-
246
- beforeEach(() => {
247
- db = createDbClient({ path: TEST_DB_PATH });
248
- db.insert(modules)
249
- .values({
250
- id: 'm1',
251
- name: 'm1',
252
- version: '1.0.0',
253
- manifestData: {},
254
- sourcePath: '/tmp/m1',
255
- state: 'VERIFIED',
256
- })
257
- .run();
258
- });
259
-
260
- afterEach(async () => {
261
- db.$client.close();
262
- for (const suffix of ['', '-shm', '-wal']) {
263
- const p = `${TEST_DB_PATH}${suffix}`;
264
- if (existsSync(p)) await rm(p);
265
- }
266
- });
267
-
268
- const sizeArgs = (memory: number) => ({
269
- name: 'main',
270
- hostname: 'h',
271
- ipv4Address: '10.0.0.5/24',
272
- zone: 'app' as const,
273
- infraType: 'container_service' as const,
274
- vmid: 200,
275
- cpu: 4,
276
- memory,
277
- disk: 80,
278
- });
279
-
280
- const row = () =>
281
- db
282
- .select()
283
- .from(moduleSystems)
284
- .where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
285
- .get();
286
-
287
- test('seeds sizing on first insert', () => {
288
- upsertDeployedSystem(db, 'm1', sizeArgs(8192));
289
- const r = row();
290
- expect(r?.cpu).toBe(4);
291
- expect(r?.memory).toBe(8192);
292
- expect(r?.disk).toBe(80);
293
- });
294
-
295
- test('a re-deploy does NOT reset a resized instance to the manifest minimum', () => {
296
- upsertDeployedSystem(db, 'm1', sizeArgs(8192)); // first provision: seed 8 GB
297
- // Simulate `celilo proxmox vm resize` bumping the canonical size to 16 GB.
298
- db.update(moduleSystems)
299
- .set({ memory: 16384 })
300
- .where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
301
- .run();
302
- // Re-deploy passes the manifest minimum (8 GB) again — must be ignored.
303
- upsertDeployedSystem(db, 'm1', sizeArgs(8192));
304
- expect(row()?.memory).toBe(16384);
305
- expect(row()?.cpu).toBe(4);
306
- });
307
- });
@@ -52,52 +52,6 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
52
52
  return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
53
53
  }
54
54
 
55
- /**
56
- * A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal
57
- * shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox
58
- * vm/ct …` surface, which needs the sizing columns the capability type omits.
59
- */
60
- export interface ProvisionedSystem {
61
- moduleId: string;
62
- name: string;
63
- hostname: string;
64
- ipv4Address: string;
65
- zone: NetworkZone;
66
- serviceId: string | null;
67
- vmid: number | null;
68
- /** Canonical desired size (null until seeded / for non-Proxmox). */
69
- cpu: number | null;
70
- memory: number | null;
71
- disk: number | null;
72
- }
73
-
74
- /**
75
- * Every celilo-provisioned (container_service) system with a Proxmox vmid, across
76
- * all modules, including its canonical sizing — the read model behind
77
- * `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output.
78
- */
79
- export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
80
- return db
81
- .select()
82
- .from(moduleSystems)
83
- .where(eq(moduleSystems.infraType, 'container_service'))
84
- .all()
85
- .filter((r) => r.vmid != null)
86
- .map((r) => ({
87
- moduleId: r.moduleId,
88
- name: r.name,
89
- hostname: r.hostname,
90
- ipv4Address: r.ipv4Address,
91
- zone: r.zone,
92
- serviceId: r.serviceId,
93
- vmid: r.vmid,
94
- cpu: r.cpu,
95
- memory: r.memory,
96
- disk: r.disk,
97
- }))
98
- .sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
99
- }
100
-
101
55
  /**
102
56
  * All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
103
57
  * `zones`, across every module — the LXC complement to machine-pool's
@@ -139,15 +93,6 @@ export interface DeployedSystemInput {
139
93
  machineId?: string | null;
140
94
  serviceId?: string | null;
141
95
  vmid?: number | null;
142
- /**
143
- * Canonical deployed size (ISS-0150), seeded from the module's
144
- * `requires.system` at first provision. Seed-once: written on INSERT only and
145
- * preserved across re-deploys (omitted from the conflict update), so a later
146
- * `celilo proxmox … resize` is not reset back to the manifest minimum.
147
- */
148
- cpu?: number | null;
149
- memory?: number | null;
150
- disk?: number | null;
151
96
  }
152
97
 
153
98
  /**
@@ -172,11 +117,6 @@ export function upsertDeployedSystem(
172
117
  machineId: system.machineId ?? null,
173
118
  serviceId: system.serviceId ?? null,
174
119
  vmid: system.vmid ?? null,
175
- // Seed-once (ISS-0150): set on insert; deliberately omitted from the
176
- // conflict update below so a resize survives re-deploys.
177
- cpu: system.cpu ?? null,
178
- memory: system.memory ?? null,
179
- disk: system.disk ?? null,
180
120
  updatedAt: new Date(),
181
121
  })
182
122
  .onConflictDoUpdate({
@@ -189,9 +129,6 @@ export function upsertDeployedSystem(
189
129
  machineId: system.machineId ?? null,
190
130
  serviceId: system.serviceId ?? null,
191
131
  vmid: system.vmid ?? null,
192
- // NOTE: cpu/memory/disk intentionally NOT updated here — sizing is
193
- // canonical state owned by `celilo proxmox … resize`, not reset by a
194
- // routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md.
195
132
  updatedAt: new Date(),
196
133
  },
197
134
  })
@@ -270,11 +207,6 @@ export async function recordDeployedSystemForModule(
270
207
  machineId: infrastructure?.machineId ?? null,
271
208
  serviceId: infrastructure?.serviceId ?? null,
272
209
  vmid: Number.isNaN(vmid as number) ? null : vmid,
273
- // Seed canonical size from requires.system (seed-once; preserved across
274
- // re-deploys). Only meaningful for celilo-provisioned instances.
275
- cpu: decl.resources.cpu ?? null,
276
- memory: decl.resources.memory ?? null,
277
- disk: decl.resources.disk ?? null,
278
210
  });
279
211
 
280
212
  return getModuleSystems(moduleId, db);
@@ -347,10 +279,6 @@ export function backfillModuleSystems(db: DbClient): string[] {
347
279
  machineId: infra.machineId ?? null,
348
280
  serviceId: infra.serviceId ?? null,
349
281
  vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
350
- // Seed canonical size from requires.system for upgraded deployments.
351
- cpu: decl.resources.cpu ?? null,
352
- memory: decl.resources.memory ?? null,
353
- disk: decl.resources.disk ?? null,
354
282
  });
355
283
  backfilled.push(infra.moduleId);
356
284
  }
@@ -122,6 +122,41 @@ describe('checkGitHygiene', () => {
122
122
  }
123
123
  });
124
124
 
125
+ test('skips the stale gate for version_source: changeset (apps order by +N)', () => {
126
+ const repo = makeTempRepo();
127
+ try {
128
+ // Same stale shape that fails above — src committed after manifest.
129
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
130
+ repo.exec('git add . && git commit -q -m "manifest"');
131
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
132
+ repo.exec('git add . && git commit -q -m "src after manifest"');
133
+
134
+ const stale = checkGitHygiene(repo.dir, 'changeset').find(
135
+ (c) => c.name === 'stale-version drift',
136
+ );
137
+ expect(stale?.status).toBe('ok'); // gate N/A, not a fail
138
+ expect(stale?.message).toContain('changeset');
139
+ } finally {
140
+ repo.cleanup();
141
+ }
142
+ });
143
+
144
+ test('skips the stale gate for version_source: pin', () => {
145
+ const repo = makeTempRepo();
146
+ try {
147
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
148
+ repo.exec('git add . && git commit -q -m "manifest"');
149
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
150
+ repo.exec('git add . && git commit -q -m "src after manifest"');
151
+
152
+ const stale = checkGitHygiene(repo.dir, 'pin').find((c) => c.name === 'stale-version drift');
153
+ expect(stale?.status).toBe('ok');
154
+ expect(stale?.message).toContain('pin');
155
+ } finally {
156
+ repo.cleanup();
157
+ }
158
+ });
159
+
125
160
  test('warn on dirty working tree', () => {
126
161
  const repo = makeTempRepo();
127
162
  try {
@@ -85,30 +85,49 @@ export function checkModuleStale(moduleDir: string): StalenessIssue | null {
85
85
  * directory tarball, etc.) — git operations return null/empty and we
86
86
  * skip the check.
87
87
  */
88
- export function checkGitHygiene(modulePath: string): Check[] {
88
+ export function checkGitHygiene(modulePath: string, versionSourceKind?: string): Check[] {
89
89
  const checks: Check[] = [];
90
90
 
91
- const stale = checkModuleStale(modulePath);
92
- if (stale) {
93
- checks.push({
94
- category: 'git_hygiene',
95
- name: 'stale-version drift',
96
- status: 'fail',
97
- message: [
98
- 'src commits past last manifest.yml change',
99
- ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
100
- ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
101
- ' Bump manifest.yml#version (semver change), or touch it',
102
- ' (release-only — auto-revision picks the next +N), then commit.',
103
- ].join('\n'),
104
- });
105
- } else {
91
+ // The source-after-manifest stale gate guards a HAND-MAINTAINED module's
92
+ // version (the default — `recipe` kind / unset version_source). For
93
+ // changeset-versioned modules the version is authored via .changeset/ +
94
+ // `celilo module version` and ordered by the +N revision, so source moving
95
+ // past the manifest is the NORMAL case, not drift; for pin modules the version
96
+ // is checked against the upstream resolver, not git ancestry. Skip the gate for
97
+ // both (v2/MODULE_VERSIONING.md / ISS-0151).
98
+ if (versionSourceKind === 'changeset' || versionSourceKind === 'pin') {
106
99
  checks.push({
107
100
  category: 'git_hygiene',
108
101
  name: 'stale-version drift',
109
102
  status: 'ok',
110
- message: 'manifest.yml is current with respect to module src',
103
+ message:
104
+ versionSourceKind === 'changeset'
105
+ ? 'version_source: changeset — changeset-authored version, ordered by +N; source-after-manifest gate N/A'
106
+ : 'version_source: pin — version checked against the upstream resolver, not git ancestry',
111
107
  });
108
+ } else {
109
+ const stale = checkModuleStale(modulePath);
110
+ if (stale) {
111
+ checks.push({
112
+ category: 'git_hygiene',
113
+ name: 'stale-version drift',
114
+ status: 'fail',
115
+ message: [
116
+ 'src commits past last manifest.yml change',
117
+ ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
118
+ ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
119
+ ' Bump manifest.yml#version (semver change), or touch it',
120
+ ' (release-only — auto-revision picks the next +N), then commit.',
121
+ ].join('\n'),
122
+ });
123
+ } else {
124
+ checks.push({
125
+ category: 'git_hygiene',
126
+ name: 'stale-version drift',
127
+ status: 'ok',
128
+ message: 'manifest.yml is current with respect to module src',
129
+ });
130
+ }
112
131
  }
113
132
 
114
133
  try {
@@ -21,6 +21,7 @@ export { checkWorkspaceDeps, defaultFetchNpmMetadata } from './workspace-deps';
21
21
 
22
22
  interface RawManifest extends ManifestForCapabilityCheck {
23
23
  celilo_contract?: string;
24
+ version_source?: { kind?: string };
24
25
  }
25
26
 
26
27
  /**
@@ -67,7 +68,7 @@ export async function runChecks(
67
68
  }
68
69
 
69
70
  checks.push(...(await checkWorkspaceDeps(modulePath, fetcher)));
70
- checks.push(...checkGitHygiene(modulePath));
71
+ checks.push(...checkGitHygiene(modulePath, manifest?.version_source?.kind));
71
72
  checks.push(await checkTypeScriptBuild(modulePath, { noBuild: options.noBuild }));
72
73
 
73
74
  return checks;