@ikon85/agent-workflow-kit 0.20.0 → 0.21.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.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: project-release
3
+ description: "Preview and prepare one coherent version change across a consumer's profiled packages without duplicating release logic or creating commits, tags, pushes, publishes, or merges."
4
+ ---
5
+
6
+ # Project Release
7
+
8
+ Prepare a consumer-owned multi-package release through the kit's shared
9
+ SemVer, preview, and transactional apply engine. This skill changes only the
10
+ profiled version files. It does not commit, tag, push, publish, or merge.
11
+
12
+ ## Profile contract
13
+
14
+ Read `docs/agents/workflow-capabilities.json`. The consumer owns this file and
15
+ its unknown keys. Project Release requires this versioned section:
16
+
17
+ ```json
18
+ {
19
+ "schemaVersion": 1,
20
+ "projectRelease": {
21
+ "versionFiles": [
22
+ "package.json",
23
+ "packages/example/package.json"
24
+ ],
25
+ "tagPrefix": "v"
26
+ }
27
+ }
28
+ ```
29
+
30
+ Every `versionFiles` path is repository-relative and must name a JSON file with
31
+ the same current SemVer. Never infer or silently add package files.
32
+
33
+ ## Workflow
34
+
35
+ 1. Run a byte-neutral preview for exactly one Patch, Minor, Major, or explicit
36
+ SemVer target:
37
+
38
+ ```sh
39
+ node scripts/project-release.mjs preview <patch|minor|major|version>
40
+ ```
41
+
42
+ 2. Report the structured result: exact current and target version, package
43
+ count, synchronized files, planned commit/tag actions, confirmation token,
44
+ and every blocker. Invalid or divergent versions, dirty profiled targets,
45
+ and an existing target tag block before the first write.
46
+
47
+ 3. Ask the user to confirm the exact target and preview confirmation token.
48
+ Do not treat a bump recommendation or a prior preview as confirmation.
49
+
50
+ 4. Apply only that still-current preview:
51
+
52
+ ```sh
53
+ node scripts/project-release.mjs apply <patch|minor|major|version> \
54
+ --confirm <confirmation-token>
55
+ ```
56
+
57
+ Apply re-reads repository facts and every target byte. A stale token,
58
+ changed target, blocked preview, partial write, or repeated apply fails
59
+ without a double bump. Partial writes roll back to the original bytes.
60
+
61
+ 5. Inspect the resulting version-file diff, then hand commit, tag, push,
62
+ publish, and merge to the repository's normal release/landing workflow.
63
+ Project Release does not commit, tag, push, publish, or merge.
64
+
65
+ ## Safety contract
66
+
67
+ - Preview is repeatable and byte-neutral.
68
+ - Apply writes only `projectRelease.versionFiles`.
69
+ - Commit and tag entries in preview output are plans, never side effects.
70
+ - Existing tags are never overwritten.
71
+ - No runtime dependency is required beyond Node.js and Git.
@@ -21,6 +21,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
21
21
  - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
22
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
23
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
24
+ - **Consumer project release:** use `project-release` to preview and transactionally prepare one coherent version across the package files named by the consumer-owned capability profile.
24
25
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
25
26
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
26
27
 
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: project-release
3
+ description: "Preview and prepare one coherent version change across a consumer's profiled packages without duplicating release logic or creating commits, tags, pushes, publishes, or merges."
4
+ ---
5
+
6
+ # Project Release
7
+
8
+ Prepare a consumer-owned multi-package release through the kit's shared
9
+ SemVer, preview, and transactional apply engine. This skill changes only the
10
+ profiled version files. It does not commit, tag, push, publish, or merge.
11
+
12
+ ## Profile contract
13
+
14
+ Read `docs/agents/workflow-capabilities.json`. The consumer owns this file and
15
+ its unknown keys. Project Release requires this versioned section:
16
+
17
+ ```json
18
+ {
19
+ "schemaVersion": 1,
20
+ "projectRelease": {
21
+ "versionFiles": [
22
+ "package.json",
23
+ "packages/example/package.json"
24
+ ],
25
+ "tagPrefix": "v"
26
+ }
27
+ }
28
+ ```
29
+
30
+ Every `versionFiles` path is repository-relative and must name a JSON file with
31
+ the same current SemVer. Never infer or silently add package files.
32
+
33
+ ## Workflow
34
+
35
+ 1. Run a byte-neutral preview for exactly one Patch, Minor, Major, or explicit
36
+ SemVer target:
37
+
38
+ ```sh
39
+ node scripts/project-release.mjs preview <patch|minor|major|version>
40
+ ```
41
+
42
+ 2. Report the structured result: exact current and target version, package
43
+ count, synchronized files, planned commit/tag actions, confirmation token,
44
+ and every blocker. Invalid or divergent versions, dirty profiled targets,
45
+ and an existing target tag block before the first write.
46
+
47
+ 3. Ask the user to confirm the exact target and preview confirmation token.
48
+ Do not treat a bump recommendation or a prior preview as confirmation.
49
+
50
+ 4. Apply only that still-current preview:
51
+
52
+ ```sh
53
+ node scripts/project-release.mjs apply <patch|minor|major|version> \
54
+ --confirm <confirmation-token>
55
+ ```
56
+
57
+ Apply re-reads repository facts and every target byte. A stale token,
58
+ changed target, blocked preview, partial write, or repeated apply fails
59
+ without a double bump. Partial writes roll back to the original bytes.
60
+
61
+ 5. Inspect the resulting version-file diff, then hand commit, tag, push,
62
+ publish, and merge to the repository's normal release/landing workflow.
63
+ Project Release does not commit, tag, push, publish, or merge.
64
+
65
+ ## Safety contract
66
+
67
+ - Preview is repeatable and byte-neutral.
68
+ - Apply writes only `projectRelease.versionFiles`.
69
+ - Commit and tag entries in preview output are plans, never side effects.
70
+ - Existing tags are never overwritten.
71
+ - No runtime dependency is required beyond Node.js and Git.
@@ -21,6 +21,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
21
21
  - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
22
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
23
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
24
+ - **Consumer project release:** use `project-release` to preview and transactionally prepare one coherent version across the package files named by the consumer-owned capability profile.
24
25
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
25
26
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
26
27
 
@@ -395,6 +395,16 @@
395
395
  ],
396
396
  "provenance": "own"
397
397
  },
398
+ "project-release": {
399
+ "class": "generic",
400
+ "publish": true,
401
+ "entryPoint": true,
402
+ "surfaces": [
403
+ "claude",
404
+ "codex"
405
+ ],
406
+ "provenance": "own"
407
+ },
398
408
  "kit-update": {
399
409
  "class": "generic",
400
410
  "publish": true,
package/PROVENANCE.md CHANGED
@@ -33,7 +33,7 @@ carries Chase's `THIRD-PARTY-NOTICES.md`.
33
33
 
34
34
  retro, wrapup, spec-self-critique, board-to-waves, verify-spike, decision-gate,
35
35
  codex-adapter-sync, setup-pre-commit, code-review, orchestrate-wave,
36
- census-update, memory-lifecycle — covered by the root LICENSE.
36
+ census-update, memory-lifecycle, project-release — covered by the root LICENSE.
37
37
  `setup-pre-commit` was rewritten from Matt Pocock's husky-based skill to a
38
38
  zero-dep native git `core.hooksPath` scaffold; nothing of the original
39
39
  mechanic remains (courtesy attribution).
package/README.md CHANGED
@@ -332,6 +332,16 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.21.0
336
+
337
+ - added: `.agents/skills/project-release/SKILL.md`
338
+ - added: `.claude/skills/project-release/SKILL.md`
339
+ - added: `scripts/project-release.mjs`
340
+ - added: `src/lib/release-apply.mjs`
341
+ - changed: `.agents/skills/setup-workflow/workflow-overview.md`
342
+ - changed: `.claude/skills/setup-workflow/workflow-overview.md`
343
+ - changed: `scripts/kit-release.mjs`
344
+
335
345
  ### 0.20.0
336
346
 
337
347
  - added: `.claude/hooks/branch-context.py`
@@ -652,12 +662,13 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
652
662
 
653
663
  ## What's in the box
654
664
 
655
- **41 skills** (Router: ask-matt — "which skill/flow fits?" · Plan: grill-me,
665
+ **42 skills** (Router: ask-matt — "which skill/flow fits?" · Plan: grill-me,
656
666
  grill-with-docs, to-prd, to-issues, board-to-waves, triage, spec-self-critique,
657
667
  verify-spike, decision-gate, scale-check, to-waves, wayfinder, research · Execute: tdd, prototype, implement, orchestrate-wave ·
658
668
  Design/diagnose/refactor streams: diagnose, zoom-out,
659
669
  improve-codebase-architecture, codebase-design, domain-modeling, security-audit · Land: wrapup,
660
- resolving-merge-conflicts, code-review, local-ci, git-worktree-recover, kit-release · Learn: retro, audit-skills, write-a-skill · Setup:
670
+ resolving-merge-conflicts, code-review, local-ci, git-worktree-recover, kit-release,
671
+ project-release · Learn: retro, audit-skills, write-a-skill · Setup:
661
672
  setup-workflow, git-guardrails, setup-pre-commit · Codex cross-model: grill-me-codex,
662
673
  grill-with-docs-codex, codex-review, codex-build),
663
674
  installed for both surfaces — `.claude/skills`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.20.0",
2
+ "kitVersion": "0.21.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -379,6 +379,15 @@
379
379
  "mode": 420,
380
380
  "origin": "kit"
381
381
  },
382
+ {
383
+ "path": ".agents/skills/project-release/SKILL.md",
384
+ "kind": "skill",
385
+ "ownerSkill": "project-release",
386
+ "surface": "codex",
387
+ "sha256": "5aa0b0827e8691334c30608df8506c756455ecebcc460efa140889297cf02ed5",
388
+ "mode": 420,
389
+ "origin": "kit"
390
+ },
382
391
  {
383
392
  "path": ".agents/skills/prototype/LOGIC.md",
384
393
  "kind": "skill",
@@ -609,7 +618,7 @@
609
618
  "kind": "skill",
610
619
  "ownerSkill": "setup-workflow",
611
620
  "surface": "codex",
612
- "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
621
+ "sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
613
622
  "mode": 420,
614
623
  "origin": "kit"
615
624
  },
@@ -1405,6 +1414,15 @@
1405
1414
  "mode": 420,
1406
1415
  "origin": "kit"
1407
1416
  },
1417
+ {
1418
+ "path": ".claude/skills/project-release/SKILL.md",
1419
+ "kind": "skill",
1420
+ "ownerSkill": "project-release",
1421
+ "surface": "claude",
1422
+ "sha256": "5aa0b0827e8691334c30608df8506c756455ecebcc460efa140889297cf02ed5",
1423
+ "mode": 420,
1424
+ "origin": "kit"
1425
+ },
1408
1426
  {
1409
1427
  "path": ".claude/skills/prototype/LOGIC.md",
1410
1428
  "kind": "skill",
@@ -1653,7 +1671,7 @@
1653
1671
  "kind": "skill",
1654
1672
  "ownerSkill": "setup-workflow",
1655
1673
  "surface": "claude",
1656
- "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
1674
+ "sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
1657
1675
  "mode": 420,
1658
1676
  "origin": "kit"
1659
1677
  },
@@ -2028,7 +2046,7 @@
2028
2046
  {
2029
2047
  "path": "scripts/kit-release.mjs",
2030
2048
  "kind": "script",
2031
- "sha256": "d3148aab63fa670c0a3407d2816d287b7cdeaa130588fb55b6075217da95d3d3",
2049
+ "sha256": "3c86eb5fc4b2071b423d87f023ba4c3f778ffe716a2603dcecdbe4e83ec1b8df",
2032
2050
  "mode": 420,
2033
2051
  "origin": "kit"
2034
2052
  },
@@ -2102,6 +2120,13 @@
2102
2120
  "mode": 420,
2103
2121
  "origin": "kit"
2104
2122
  },
2123
+ {
2124
+ "path": "scripts/project-release.mjs",
2125
+ "kind": "script",
2126
+ "sha256": "6c08cf16f97fdbc0172041c64109e859b1f073862e9f8edc2accbafd97b33843",
2127
+ "mode": 493,
2128
+ "origin": "kit"
2129
+ },
2105
2130
  {
2106
2131
  "path": "scripts/release-delta-guard.mjs",
2107
2132
  "kind": "script",
@@ -2165,6 +2190,13 @@
2165
2190
  "mode": 493,
2166
2191
  "origin": "kit"
2167
2192
  },
2193
+ {
2194
+ "path": "src/lib/release-apply.mjs",
2195
+ "kind": "script",
2196
+ "sha256": "d9b3bd07c88629c570d8a58f1d91d1cdac1457470cee973f99e186d9630efb03",
2197
+ "mode": 420,
2198
+ "origin": "kit"
2199
+ },
2168
2200
  {
2169
2201
  "path": "src/lib/release-preview.mjs",
2170
2202
  "kind": "script",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,19 +5,15 @@ import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { applyProjectRelease } from '../src/lib/release-apply.mjs';
9
+ import { previewProjectRelease } from '../src/lib/release-preview.mjs';
10
+ import { nextVersion } from '../src/lib/semver.mjs';
8
11
  import { buildKit } from './build-kit.mjs';
9
12
  import { checkReleaseDelta } from './release-delta-guard.mjs';
10
13
 
11
14
  const exec = promisify(execFile);
12
15
 
13
- export function nextVersion(version, bump) {
14
- const parts = version.split('.').map(Number);
15
- if (parts.length !== 3 || parts.some(Number.isNaN)) throw new Error(`invalid semver: ${version}`);
16
- if (bump === 'major') return `${parts[0] + 1}.0.0`;
17
- if (bump === 'minor') return `${parts[0]}.${parts[1] + 1}.0`;
18
- if (bump === 'patch') return `${parts[0]}.${parts[1]}.${parts[2] + 1}`;
19
- throw new Error(`invalid bump: ${bump}`);
20
- }
16
+ export { nextVersion };
21
17
 
22
18
  function note(version, delta) {
23
19
  const lines = ['added', 'removed', 'changed'].flatMap((kind) =>
@@ -30,8 +26,17 @@ async function updateMetadata(repoRoot, targetVersion, delta) {
30
26
  const pkg = JSON.parse(await readFile(packagePath, 'utf8'));
31
27
  const resumed = pkg.version === targetVersion;
32
28
  if (!resumed) {
33
- pkg.version = targetVersion;
34
- await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
29
+ const preview = await previewProjectRelease({
30
+ consumerRoot: repoRoot,
31
+ profile: { versionFiles: ['package.json'], tagPrefix: 'v' },
32
+ requestedVersion: targetVersion,
33
+ repositoryFacts: { dirtyPaths: [], existingTags: [] },
34
+ });
35
+ await applyProjectRelease({
36
+ consumerRoot: repoRoot,
37
+ preview,
38
+ confirmation: preview.confirmation,
39
+ });
35
40
  }
36
41
  const readmePath = join(repoRoot, 'README.md');
37
42
  const readme = await readFile(readmePath, 'utf8');
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { applyProjectRelease, assertSafeReleaseTargets } from '../src/lib/release-apply.mjs';
6
+ import { loadProjectReleaseProfile, previewProjectRelease } from '../src/lib/release-preview.mjs';
7
+
8
+ function gitOutput(consumerRoot, args) {
9
+ return execFileSync('git', args, { cwd: consumerRoot, encoding: 'utf8' });
10
+ }
11
+
12
+ export function readRepositoryFacts(consumerRoot, run = gitOutput) {
13
+ const records = run(
14
+ consumerRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
15
+ ).split('\0').filter(Boolean);
16
+ const dirtyPaths = [];
17
+ for (let index = 0; index < records.length; index += 1) {
18
+ const record = records[index];
19
+ dirtyPaths.push(record.slice(3));
20
+ if (record[0] === 'R' || record[0] === 'C'
21
+ || record[1] === 'R' || record[1] === 'C') index += 1;
22
+ }
23
+ const existingTags = run(consumerRoot, ['tag', '--list'])
24
+ .split('\n').filter(Boolean);
25
+ return { dirtyPaths, existingTags };
26
+ }
27
+
28
+ function argument(args, name) {
29
+ const index = args.indexOf(name);
30
+ return index < 0 ? null : args[index + 1];
31
+ }
32
+
33
+ export async function runProjectRelease(options) {
34
+ const {
35
+ consumerRoot, args,
36
+ repositoryFacts = readRepositoryFacts(consumerRoot),
37
+ output = console.log,
38
+ } = options;
39
+ const [command, requestedVersion] = args;
40
+ if (!['preview', 'apply'].includes(command) || !requestedVersion) {
41
+ throw new Error('usage: project-release <preview|apply> <patch|minor|major|version> [--confirm <token>]');
42
+ }
43
+ const profile = await loadProjectReleaseProfile(consumerRoot);
44
+ await assertSafeReleaseTargets(consumerRoot, profile.versionFiles);
45
+ const preview = await previewProjectRelease({
46
+ consumerRoot, profile, requestedVersion, repositoryFacts,
47
+ });
48
+ if (command === 'preview') {
49
+ output(JSON.stringify(preview));
50
+ return preview;
51
+ }
52
+ const result = await applyProjectRelease({
53
+ consumerRoot,
54
+ preview,
55
+ confirmation: argument(args, '--confirm'),
56
+ });
57
+ output(JSON.stringify(result));
58
+ return result;
59
+ }
60
+
61
+ async function main() {
62
+ await runProjectRelease({ consumerRoot: process.cwd(), args: process.argv.slice(2) });
63
+ }
64
+
65
+ if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
66
+ main().catch((error) => {
67
+ console.error(`project-release: ${error.message}`);
68
+ process.exitCode = 1;
69
+ });
70
+ }
@@ -15,8 +15,9 @@ Deny classes (each with a negative fixture below):
15
15
  - residual issue refs (`#NNN`) / hard-rule refs (`HRn`)
16
16
  - unresolvable provenance cross-refs (`ADR-####` / `Welle N` / `Slice N`),
17
17
  with a documented fixture/example allowlist (PROVENANCE_FIXTURE_SUFFIXES)
18
- - `../` cross-skill reaches (skills/scripts/docs — NOT the CLI src, which
19
- legitimately imports `../lib/…`)
18
+ - `../` cross-skill reaches (skills/scripts/docs — except a static top-level
19
+ script import into the shipped `src/lib` deep-module seam, and CLI `src`
20
+ imports which legitimately use `../lib/…`)
20
21
  - bare owner/maintainer names OUTSIDE the generated credit files
21
22
  - high-entropy secrets (after exempting the manifest's own sha256 file-hashes)
22
23
 
@@ -92,18 +93,27 @@ BARE_PRIVATE = [
92
93
  ("maintainer name", re.compile(r"\bNiko\b")),
93
94
  ]
94
95
  PARENT_REF = re.compile(r"\.\./")
96
+ SHIPPED_SRC_IMPORT = re.compile(
97
+ r"^\s*import\b.*\bfrom\s+['\"]\.\./src/lib/[a-z0-9-]+\.mjs['\"];\s*$"
98
+ )
95
99
  LONG_HEX = re.compile(r"\b[0-9a-f]{40,}\b")
96
100
  GH_TOKEN = re.compile(r"\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}")
97
101
 
98
102
 
99
103
  def _parent_ref_scan_text(rel: str, text: str) -> str:
100
- if rel != "scripts/loc_offender_core.py":
101
- return text
102
- # This is the portable path-traversal guard itself, not a cross-skill reach.
103
- return "\n".join(
104
- line for line in text.splitlines()
105
- if 'p.startswith("../")' not in line and '"/../" in p' not in line
106
- )
104
+ lines = text.splitlines()
105
+ if rel == "scripts/loc_offender_core.py":
106
+ # This is the portable path-traversal guard itself, not a cross-skill reach.
107
+ lines = [
108
+ line for line in lines
109
+ if 'p.startswith("../")' not in line and '"/../" in p' not in line
110
+ ]
111
+ if rel.startswith("scripts/") and rel.endswith(".mjs"):
112
+ # Top-level shipped scripts may consume the kit's shipped deep modules.
113
+ # The exact static-import shape stays inside the bundle root; every other
114
+ # parent reach remains visible to the deny rule below.
115
+ lines = [line for line in lines if not SHIPPED_SRC_IMPORT.match(line)]
116
+ return "\n".join(lines)
107
117
 
108
118
 
109
119
  def _known_hashes(root: Path) -> set:
@@ -230,6 +240,15 @@ class AuditCatchesEachClass(unittest.TestCase):
230
240
  def test_parent_ref(self):
231
241
  self.assertTrue(any("parent" in v for v in self._body("[x](../../other/SKILL.md)")))
232
242
 
243
+ def test_static_script_import_into_shipped_src_lib_is_allowed(self):
244
+ script = self.dir / "scripts/release.mjs"
245
+ script.parent.mkdir()
246
+ script.write_text(
247
+ "import { run } from '../src/lib/release-core.mjs';\nrun();\n",
248
+ encoding="utf-8",
249
+ )
250
+ self.assertEqual(audit_dir(self.dir), [])
251
+
233
252
  def test_bare_owner_in_body(self):
234
253
  self.assertTrue(any("iKon85" in v for v in self._body("authored by iKon85")))
235
254
 
@@ -48,9 +48,12 @@ export const HELPER_FILES = [
48
48
  { path: 'scripts/release-parity.mjs', kind: 'script', mode: 0o644 },
49
49
  { path: 'scripts/release-state.mjs', kind: 'script', mode: 0o644 },
50
50
  // Consumer-owned project release profiles use these read-only shared
51
- // primitives before any apply/commit/tag action is allowed.
51
+ // primitives before any apply/commit/tag action is allowed. The thin CLI
52
+ // owns only repository-fact collection and delegates to the same engine.
52
53
  { path: 'src/lib/semver.mjs', kind: 'script', mode: 0o644 },
53
54
  { path: 'src/lib/release-preview.mjs', kind: 'script', mode: 0o644 },
55
+ { path: 'src/lib/release-apply.mjs', kind: 'script', mode: 0o644 },
56
+ { path: 'scripts/project-release.mjs', kind: 'script', mode: 0o755 },
54
57
  // GitHub-consumer automation: invokes the existing update command, then owns
55
58
  // only the stable tested branch/pull-request upsert.
56
59
  { path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { writeAtomic } from './atomicWrite.mjs';
5
+
6
+ const sha256 = (body) => createHash('sha256').update(body).digest('hex');
7
+
8
+ async function readSnapshot(consumerRoot, snapshot) {
9
+ return Promise.all(snapshot.map(async ({ path }) => {
10
+ const body = await readFile(join(consumerRoot, path), 'utf8');
11
+ return { path, body, version: JSON.parse(body).version, sha256: sha256(body) };
12
+ }));
13
+ }
14
+
15
+ const escapes = (root, target) => {
16
+ const path = relative(root, target);
17
+ return path === '..' || path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
18
+ || isAbsolute(path);
19
+ };
20
+
21
+ export async function assertSafeReleaseTargets(consumerRoot, paths) {
22
+ const canonicalRoot = await realpath(consumerRoot);
23
+ for (const path of paths) {
24
+ const target = resolve(consumerRoot, path);
25
+ if (isAbsolute(path) || escapes(resolve(consumerRoot), target)) {
26
+ throw new Error(`release target is outside consumer root: ${path}`);
27
+ }
28
+ if ((await lstat(target)).isSymbolicLink()) {
29
+ throw new Error(`symlinked release target is not allowed: ${path}`);
30
+ }
31
+ if (escapes(canonicalRoot, await realpath(target))) {
32
+ throw new Error(`release target resolves outside consumer root: ${path}`);
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function applyProjectRelease(options) {
38
+ const { consumerRoot, preview, confirmation } = options;
39
+ const write = options.write ?? writeAtomic;
40
+ if (preview.status !== 'ready') throw new Error('release preview is blocked');
41
+ if (confirmation !== preview.confirmation) {
42
+ throw new Error('release confirmation does not match preview');
43
+ }
44
+ await assertSafeReleaseTargets(
45
+ consumerRoot, preview.snapshot.map(({ path }) => path),
46
+ );
47
+ const current = await readSnapshot(consumerRoot, preview.snapshot);
48
+ if (current.every(({ version }) => version === preview.summary.targetVersion)) {
49
+ throw new Error(`release already prepared at ${preview.summary.targetVersion}`);
50
+ }
51
+ for (const expected of preview.snapshot) {
52
+ const actual = current.find(({ path }) => path === expected.path);
53
+ if (actual.sha256 !== expected.sha256) {
54
+ throw new Error(`release target changed after preview: ${expected.path}`);
55
+ }
56
+ }
57
+ const version = preview.summary.targetVersion;
58
+ const candidates = current.map((file) => {
59
+ const body = JSON.parse(file.body);
60
+ body.version = version;
61
+ return { ...file, next: `${JSON.stringify(body, null, 2)}\n` };
62
+ });
63
+ const written = [];
64
+ try {
65
+ for (const file of candidates) {
66
+ await write(join(consumerRoot, file.path), file.next);
67
+ written.push(file);
68
+ }
69
+ } catch (error) {
70
+ for (const file of written.reverse()) {
71
+ await writeAtomic(join(consumerRoot, file.path), file.body);
72
+ }
73
+ throw error;
74
+ }
75
+ return {
76
+ status: 'prepared',
77
+ version,
78
+ updated: candidates.map(({ path }) => path),
79
+ plannedTag: preview.actions.find(({ type }) => type === 'tag').name,
80
+ };
81
+ }