tlc-claude-code 2.5.0 → 2.6.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.
Files changed (76) hide show
  1. package/.claude/commands/tlc/autofix.md +34 -1
  2. package/.claude/commands/tlc/build.md +89 -6
  3. package/.claude/commands/tlc/ci.md +178 -414
  4. package/.claude/commands/tlc/coverage.md +34 -0
  5. package/.claude/commands/tlc/deploy.md +19 -6
  6. package/.claude/commands/tlc/discuss.md +34 -0
  7. package/.claude/commands/tlc/docs.md +35 -1
  8. package/.claude/commands/tlc/e2e.md +300 -0
  9. package/.claude/commands/tlc/edge-cases.md +35 -1
  10. package/.claude/commands/tlc/init.md +38 -8
  11. package/.claude/commands/tlc/new-project.md +46 -4
  12. package/.claude/commands/tlc/plan.md +33 -0
  13. package/.claude/commands/tlc/quick.md +33 -0
  14. package/.claude/commands/tlc/release.md +85 -135
  15. package/.claude/commands/tlc/restore.md +14 -0
  16. package/.claude/commands/tlc/review.md +76 -1
  17. package/.claude/commands/tlc/tlc.md +134 -0
  18. package/.claude/commands/tlc/verify.md +64 -65
  19. package/.claude/commands/tlc/watchci.md +10 -0
  20. package/.claude/hooks/tlc-block-tools.sh +13 -0
  21. package/.claude/hooks/tlc-session-init.sh +9 -0
  22. package/CODING-STANDARDS.md +35 -10
  23. package/package.json +1 -1
  24. package/server/lib/block-tools-hook.js +23 -0
  25. package/server/lib/e2e/acceptance-parser.js +132 -0
  26. package/server/lib/e2e/acceptance-parser.test.js +110 -0
  27. package/server/lib/e2e/framework-detector.js +47 -0
  28. package/server/lib/e2e/framework-detector.test.js +94 -0
  29. package/server/lib/e2e/log-assertions.js +107 -0
  30. package/server/lib/e2e/log-assertions.test.js +68 -0
  31. package/server/lib/e2e/test-generator.js +159 -0
  32. package/server/lib/e2e/test-generator.test.js +121 -0
  33. package/server/lib/e2e/verify-runner.js +191 -0
  34. package/server/lib/e2e/verify-runner.test.js +167 -0
  35. package/server/lib/hooks/block-tools-hook.test.js +54 -0
  36. package/server/lib/orchestration/cli-dispatch.js +16 -1
  37. package/server/lib/orchestration/cli-dispatch.test.js +94 -8
  38. package/server/lib/orchestration/completion-checker.js +101 -0
  39. package/server/lib/orchestration/completion-checker.test.js +177 -0
  40. package/server/lib/orchestration/result-verifier.js +143 -0
  41. package/server/lib/orchestration/result-verifier.test.js +291 -0
  42. package/server/lib/orchestration/session-dispatcher.js +99 -0
  43. package/server/lib/orchestration/session-dispatcher.test.js +215 -0
  44. package/server/lib/orchestration/session-status.js +147 -0
  45. package/server/lib/orchestration/session-status.test.js +130 -0
  46. package/server/lib/release/agent-runner-updates.js +24 -0
  47. package/server/lib/release/agent-runner-updates.test.js +22 -0
  48. package/server/lib/release/changelog-generator.js +142 -0
  49. package/server/lib/release/changelog-generator.test.js +113 -0
  50. package/server/lib/release/ci-watcher.js +83 -0
  51. package/server/lib/release/ci-watcher.test.js +81 -0
  52. package/server/lib/release/health-checker.js +111 -0
  53. package/server/lib/release/health-checker.test.js +121 -0
  54. package/server/lib/release/release-pipeline.js +187 -0
  55. package/server/lib/release/release-pipeline.test.js +262 -0
  56. package/server/lib/release/version-bumper.js +183 -0
  57. package/server/lib/release/version-bumper.test.js +142 -0
  58. package/server/lib/routing-preamble.integration.test.js +12 -0
  59. package/server/lib/routing-preamble.js +13 -2
  60. package/server/lib/routing-preamble.test.js +49 -0
  61. package/server/lib/scaffolding/ci-detector.js +139 -0
  62. package/server/lib/scaffolding/ci-detector.test.js +198 -0
  63. package/server/lib/scaffolding/ci-scaffolder.js +347 -0
  64. package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
  65. package/server/lib/scaffolding/deploy-detector.js +135 -0
  66. package/server/lib/scaffolding/deploy-detector.test.js +106 -0
  67. package/server/lib/scaffolding/health-scaffold.js +374 -0
  68. package/server/lib/scaffolding/health-scaffold.test.js +99 -0
  69. package/server/lib/scaffolding/logger-scaffold.js +196 -0
  70. package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
  71. package/server/lib/scaffolding/migration-detector.js +78 -0
  72. package/server/lib/scaffolding/migration-detector.test.js +127 -0
  73. package/server/lib/scaffolding/snapshot-manager.js +142 -0
  74. package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
  75. package/server/lib/task-router-config.js +50 -20
  76. package/server/lib/task-router-config.test.js +29 -15
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import changelogGenerator from './changelog-generator.js';
3
+
4
+ const {
5
+ generateChangelog,
6
+ prependToChangelog,
7
+ } = changelogGenerator;
8
+
9
+ describe('changelog-generator', () => {
10
+ describe('generateChangelog', () => {
11
+ it('returns grouped markdown for conventional commits', () => {
12
+ const markdown = generateChangelog({
13
+ version: 'v1.2.3',
14
+ date: '2026-03-31',
15
+ commits: [
16
+ { sha: '1111111aaaa', message: 'fix: handle empty API payloads' },
17
+ { sha: '2222222bbbb', message: 'feat(auth): add SSO login flow' },
18
+ { sha: '3333333cccc', message: 'docs: update release runbook' },
19
+ { sha: '4444444dddd', message: 'cleanup old release notes' },
20
+ ],
21
+ });
22
+
23
+ expect(markdown).toContain('## v1.2.3 - 2026-03-31');
24
+ expect(markdown).toContain('### Features');
25
+ expect(markdown).toContain('- add SSO login flow (`2222222`)');
26
+ expect(markdown).toContain('### Fixes');
27
+ expect(markdown).toContain('- handle empty API payloads (`1111111`)');
28
+ expect(markdown).toContain('### Documentation');
29
+ expect(markdown).toContain('- update release runbook (`3333333`)');
30
+ expect(markdown).toContain('### Other');
31
+ expect(markdown).toContain('- cleanup old release notes (`4444444`)');
32
+ });
33
+
34
+ it('returns a fallback message when there are no commits', () => {
35
+ const markdown = generateChangelog({
36
+ version: 'v1.2.4',
37
+ date: '2026-03-31',
38
+ commits: [],
39
+ });
40
+
41
+ expect(markdown).toBe('## v1.2.4 - 2026-03-31\n\n- No changes recorded');
42
+ });
43
+
44
+ it('accepts string commits and omits missing shas', () => {
45
+ const markdown = generateChangelog({
46
+ version: 'v1.2.5',
47
+ commits: ['feat: add changelog endpoint', 'misc cleanup'],
48
+ });
49
+
50
+ expect(markdown).toContain('## v1.2.5');
51
+ expect(markdown).toContain('- add changelog endpoint');
52
+ expect(markdown).toContain('- misc cleanup');
53
+ expect(markdown).not.toContain('(`');
54
+ });
55
+ });
56
+
57
+ describe('prependToChangelog', () => {
58
+ it('prepends content below the changelog title when file already exists', () => {
59
+ const fs = {
60
+ readFileSync: vi.fn(() => '# Changelog\n\n## v1.2.2 - 2026-03-30\n\n- Previous entry\n'),
61
+ writeFileSync: vi.fn(),
62
+ };
63
+
64
+ const result = prependToChangelog({
65
+ changelogPath: '/tmp/CHANGELOG.md',
66
+ content: '## v1.2.3 - 2026-03-31\n\n- New entry',
67
+ fs,
68
+ });
69
+
70
+ expect(result).toBe(
71
+ '# Changelog\n\n## v1.2.3 - 2026-03-31\n\n- New entry\n\n## v1.2.2 - 2026-03-30\n\n- Previous entry\n'
72
+ );
73
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/CHANGELOG.md', result, 'utf8');
74
+ });
75
+
76
+ it('creates a new changelog file when one does not exist', () => {
77
+ const fs = {
78
+ readFileSync: vi.fn(() => {
79
+ const error = new Error('missing');
80
+ error.code = 'ENOENT';
81
+ throw error;
82
+ }),
83
+ writeFileSync: vi.fn(),
84
+ };
85
+
86
+ const result = prependToChangelog({
87
+ changelogPath: '/tmp/CHANGELOG.md',
88
+ content: '## v1.2.3 - 2026-03-31\n\n- Initial entry',
89
+ fs,
90
+ });
91
+
92
+ expect(result).toBe('# Changelog\n\n## v1.2.3 - 2026-03-31\n\n- Initial entry\n');
93
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/CHANGELOG.md', result, 'utf8');
94
+ });
95
+
96
+ it('prepends directly when the existing file has no top-level changelog title', () => {
97
+ const fs = {
98
+ readFileSync: vi.fn(() => '## v1.2.2 - 2026-03-30\n\n- Previous entry\n'),
99
+ writeFileSync: vi.fn(),
100
+ };
101
+
102
+ const result = prependToChangelog({
103
+ changelogPath: '/tmp/CHANGELOG.md',
104
+ content: '## v1.2.3 - 2026-03-31\n\n- New entry',
105
+ fs,
106
+ });
107
+
108
+ expect(result).toBe(
109
+ '## v1.2.3 - 2026-03-31\n\n- New entry\n\n## v1.2.2 - 2026-03-30\n\n- Previous entry\n'
110
+ );
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Poll GitHub Actions runs for a branch until CI passes, fails, or no run is found.
3
+ *
4
+ * @param {Object} options
5
+ * @param {string} options.branch
6
+ * @param {number} [options.maxAttempts=3]
7
+ * @param {(command: string) => Promise<{stdout?: string}>} options.exec
8
+ * @returns {Promise<'green'|'failed'|'no_run'>}
9
+ */
10
+ async function watchCi({ branch, maxAttempts = 3, exec }) {
11
+ if (!branch || typeof branch !== 'string') {
12
+ throw new Error('branch is required');
13
+ }
14
+
15
+ if (typeof exec !== 'function') {
16
+ throw new Error('exec is required');
17
+ }
18
+
19
+ const attempts = Number.isInteger(maxAttempts) && maxAttempts > 0 ? maxAttempts : 3;
20
+ let sawRun = false;
21
+
22
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
23
+ const listResult = await exec(
24
+ `gh run list --branch ${shellQuote(branch)} --limit 1 --json databaseId,status,conclusion`,
25
+ );
26
+ const runs = parseJson(listResult && listResult.stdout, []);
27
+
28
+ if (!Array.isArray(runs) || runs.length === 0) {
29
+ continue;
30
+ }
31
+
32
+ sawRun = true;
33
+ const latestRun = runs[0];
34
+ const runId = latestRun.databaseId;
35
+
36
+ const viewResult = await exec(
37
+ `gh run view ${runId} --json status,conclusion`,
38
+ );
39
+ const run = parseJson(viewResult && viewResult.stdout, {});
40
+
41
+ if (run.conclusion === 'success') {
42
+ return 'green';
43
+ }
44
+
45
+ if (isFailedConclusion(run.conclusion)) {
46
+ return 'failed';
47
+ }
48
+
49
+ if (run.status === 'completed') {
50
+ return 'failed';
51
+ }
52
+ }
53
+
54
+ return sawRun ? 'failed' : 'no_run';
55
+ }
56
+
57
+ function parseJson(input, fallback) {
58
+ if (typeof input !== 'string' || input.trim() === '') {
59
+ return fallback;
60
+ }
61
+
62
+ return JSON.parse(input);
63
+ }
64
+
65
+ function isFailedConclusion(conclusion) {
66
+ return [
67
+ 'failure',
68
+ 'cancelled',
69
+ 'timed_out',
70
+ 'action_required',
71
+ 'startup_failure',
72
+ 'stale',
73
+ 'skipped',
74
+ ].includes(conclusion);
75
+ }
76
+
77
+ function shellQuote(value) {
78
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
79
+ }
80
+
81
+ module.exports = {
82
+ watchCi,
83
+ };
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ const { watchCi } = require('./ci-watcher.js');
4
+
5
+ describe('ci-watcher', () => {
6
+ it('returns green when the latest run completes successfully', async () => {
7
+ const exec = vi.fn()
8
+ .mockResolvedValueOnce({
9
+ stdout: JSON.stringify([
10
+ { databaseId: 101, status: 'completed', conclusion: 'success' },
11
+ ]),
12
+ })
13
+ .mockResolvedValueOnce({
14
+ stdout: JSON.stringify({ status: 'completed', conclusion: 'success' }),
15
+ });
16
+
17
+ const result = await watchCi({ branch: 'main', maxAttempts: 2, exec });
18
+
19
+ expect(result).toBe('green');
20
+ expect(exec).toHaveBeenNthCalledWith(
21
+ 1,
22
+ "gh run list --branch 'main' --limit 1 --json databaseId,status,conclusion",
23
+ );
24
+ expect(exec).toHaveBeenNthCalledWith(
25
+ 2,
26
+ 'gh run view 101 --json status,conclusion',
27
+ );
28
+ });
29
+
30
+ it('returns failed when the latest run completes unsuccessfully', async () => {
31
+ const exec = vi.fn()
32
+ .mockResolvedValueOnce({
33
+ stdout: JSON.stringify([
34
+ { databaseId: 202, status: 'completed', conclusion: 'failure' },
35
+ ]),
36
+ })
37
+ .mockResolvedValueOnce({
38
+ stdout: JSON.stringify({ status: 'completed', conclusion: 'failure' }),
39
+ });
40
+
41
+ const result = await watchCi({ branch: 'release/1.2.3', maxAttempts: 3, exec });
42
+
43
+ expect(result).toBe('failed');
44
+ });
45
+
46
+ it('returns no_run when no workflow runs are found after all attempts', async () => {
47
+ const exec = vi.fn().mockResolvedValue({
48
+ stdout: JSON.stringify([]),
49
+ });
50
+
51
+ const result = await watchCi({ branch: 'feature/missing-ci', maxAttempts: 2, exec });
52
+
53
+ expect(result).toBe('no_run');
54
+ expect(exec).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it('retries pending runs until they turn green', async () => {
58
+ const exec = vi.fn()
59
+ .mockResolvedValueOnce({
60
+ stdout: JSON.stringify([
61
+ { databaseId: 303, status: 'in_progress', conclusion: '' },
62
+ ]),
63
+ })
64
+ .mockResolvedValueOnce({
65
+ stdout: JSON.stringify({ status: 'in_progress', conclusion: '' }),
66
+ })
67
+ .mockResolvedValueOnce({
68
+ stdout: JSON.stringify([
69
+ { databaseId: 303, status: 'completed', conclusion: 'success' },
70
+ ]),
71
+ })
72
+ .mockResolvedValueOnce({
73
+ stdout: JSON.stringify({ status: 'completed', conclusion: 'success' }),
74
+ });
75
+
76
+ const result = await watchCi({ branch: 'develop', maxAttempts: 3, exec });
77
+
78
+ expect(result).toBe('green');
79
+ expect(exec).toHaveBeenCalledTimes(4);
80
+ });
81
+ });
@@ -0,0 +1,111 @@
1
+ const DEFAULT_TIMEOUT_MS = 5000;
2
+ const DEFAULT_MAX_RETRIES = 3;
3
+
4
+ function toErrorMessage(error) {
5
+ if (!error) {
6
+ return 'Unknown health check failure';
7
+ }
8
+
9
+ if (typeof error === 'string') {
10
+ return error;
11
+ }
12
+
13
+ if (error.message) {
14
+ return error.message;
15
+ }
16
+
17
+ return 'Unknown health check failure';
18
+ }
19
+
20
+ async function checkHealth({
21
+ healthUrl,
22
+ timeout = DEFAULT_TIMEOUT_MS,
23
+ maxRetries = DEFAULT_MAX_RETRIES,
24
+ fetch,
25
+ } = {}) {
26
+ if (!healthUrl) {
27
+ return {
28
+ ok: false,
29
+ attempts: 0,
30
+ status: null,
31
+ error: 'healthUrl is required',
32
+ };
33
+ }
34
+
35
+ if (typeof fetch !== 'function') {
36
+ throw new TypeError('fetch must be provided');
37
+ }
38
+
39
+ const maxAttempts = Math.max(1, Number(maxRetries) || 0);
40
+ let lastError = null;
41
+
42
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
43
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
44
+ let timeoutId = null;
45
+
46
+ try {
47
+ if (controller && Number.isFinite(timeout) && timeout >= 0) {
48
+ timeoutId = setTimeout(() => {
49
+ const timeoutError = new Error(`Health check timed out after ${timeout}ms`);
50
+ timeoutError.name = 'AbortError';
51
+ controller.abort(timeoutError);
52
+ }, timeout);
53
+ }
54
+
55
+ const response = await fetch(healthUrl, controller ? { signal: controller.signal } : {});
56
+ const status = response && typeof response.status === 'number' ? response.status : null;
57
+
58
+ if (response && response.ok) {
59
+ return {
60
+ ok: true,
61
+ attempts: attempt,
62
+ status,
63
+ error: null,
64
+ };
65
+ }
66
+
67
+ lastError = new Error(`Health check failed with status ${status}`);
68
+ } catch (error) {
69
+ lastError = error;
70
+ } finally {
71
+ if (timeoutId) {
72
+ clearTimeout(timeoutId);
73
+ }
74
+ }
75
+ }
76
+
77
+ return {
78
+ ok: false,
79
+ attempts: maxAttempts,
80
+ status: null,
81
+ error: toErrorMessage(lastError),
82
+ };
83
+ }
84
+
85
+ async function rollback({ branch, previousSha, exec } = {}) {
86
+ if (!branch) {
87
+ throw new Error('branch is required');
88
+ }
89
+
90
+ if (!previousSha) {
91
+ throw new Error('previousSha is required');
92
+ }
93
+
94
+ if (typeof exec !== 'function') {
95
+ throw new TypeError('exec must be provided');
96
+ }
97
+
98
+ await exec(`git checkout ${branch}`);
99
+ await exec(`git reset --hard ${previousSha}`);
100
+
101
+ return {
102
+ ok: true,
103
+ branch,
104
+ previousSha,
105
+ };
106
+ }
107
+
108
+ module.exports = {
109
+ checkHealth,
110
+ rollback,
111
+ };
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createRequire } from 'module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const { checkHealth, rollback } = require('./health-checker.js');
6
+
7
+ describe('release/health-checker', () => {
8
+ describe('checkHealth', () => {
9
+ it('returns an error result when healthUrl is missing', async () => {
10
+ const fetch = vi.fn();
11
+
12
+ const result = await checkHealth({ fetch });
13
+
14
+ expect(result).toEqual({
15
+ ok: false,
16
+ attempts: 0,
17
+ status: null,
18
+ error: 'healthUrl is required',
19
+ });
20
+ expect(fetch).not.toHaveBeenCalled();
21
+ });
22
+
23
+ it('retries failed responses until a later attempt succeeds', async () => {
24
+ const fetch = vi
25
+ .fn()
26
+ .mockResolvedValueOnce({ ok: false, status: 503 })
27
+ .mockResolvedValueOnce({ ok: false, status: 502 })
28
+ .mockResolvedValueOnce({ ok: true, status: 200 });
29
+
30
+ const result = await checkHealth({
31
+ healthUrl: 'https://release.example.com/health',
32
+ timeout: 100,
33
+ maxRetries: 3,
34
+ fetch,
35
+ });
36
+
37
+ expect(fetch).toHaveBeenCalledTimes(3);
38
+ expect(result).toEqual({
39
+ ok: true,
40
+ attempts: 3,
41
+ status: 200,
42
+ error: null,
43
+ });
44
+ });
45
+
46
+ it('retries thrown fetch errors and returns the final error message', async () => {
47
+ const fetch = vi.fn().mockRejectedValue(new Error('connection refused'));
48
+
49
+ const result = await checkHealth({
50
+ healthUrl: 'https://release.example.com/health',
51
+ timeout: 100,
52
+ maxRetries: 2,
53
+ fetch,
54
+ });
55
+
56
+ expect(fetch).toHaveBeenCalledTimes(2);
57
+ expect(result).toEqual({
58
+ ok: false,
59
+ attempts: 2,
60
+ status: null,
61
+ error: 'connection refused',
62
+ });
63
+ });
64
+
65
+ it('aborts a hanging request when the timeout is reached', async () => {
66
+ vi.useFakeTimers();
67
+
68
+ const fetch = vi.fn((_url, options = {}) => new Promise((_, reject) => {
69
+ options.signal.addEventListener('abort', () => {
70
+ reject(options.signal.reason || new Error('aborted'));
71
+ });
72
+ }));
73
+
74
+ const healthPromise = checkHealth({
75
+ healthUrl: 'https://release.example.com/health',
76
+ timeout: 50,
77
+ maxRetries: 1,
78
+ fetch,
79
+ });
80
+
81
+ await vi.advanceTimersByTimeAsync(50);
82
+ const result = await healthPromise;
83
+
84
+ expect(fetch).toHaveBeenCalledTimes(1);
85
+ expect(result.ok).toBe(false);
86
+ expect(result.attempts).toBe(1);
87
+ expect(result.error).toContain('timed out');
88
+
89
+ vi.useRealTimers();
90
+ });
91
+ });
92
+
93
+ describe('rollback', () => {
94
+ it('runs git checkout and reset using the injected exec', async () => {
95
+ const exec = vi.fn().mockResolvedValue(undefined);
96
+
97
+ const result = await rollback({
98
+ branch: 'main',
99
+ previousSha: 'abc123def456',
100
+ exec,
101
+ });
102
+
103
+ expect(exec).toHaveBeenNthCalledWith(1, 'git checkout main');
104
+ expect(exec).toHaveBeenNthCalledWith(2, 'git reset --hard abc123def456');
105
+ expect(result).toEqual({
106
+ ok: true,
107
+ branch: 'main',
108
+ previousSha: 'abc123def456',
109
+ });
110
+ });
111
+
112
+ it('throws when required rollback arguments are missing', async () => {
113
+ await expect(rollback({ previousSha: 'abc123', exec: vi.fn() })).rejects.toThrow(
114
+ 'branch is required'
115
+ );
116
+ await expect(rollback({ branch: 'main', exec: vi.fn() })).rejects.toThrow(
117
+ 'previousSha is required'
118
+ );
119
+ });
120
+ });
121
+ });
@@ -0,0 +1,187 @@
1
+ const fsp = require('node:fs/promises');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ const { watchCi } = require('./ci-watcher.js');
6
+ const { determineNextVersion, applyVersionBump } = require('./version-bumper.js');
7
+ const { generateChangelog, prependToChangelog } = require('./changelog-generator.js');
8
+ const { checkHealth, rollback } = require('./health-checker.js');
9
+
10
+ const COMPLETIONS_PATH = path.resolve(process.cwd(), '.tlc', '.agent-completions.json');
11
+
12
+ async function runRelease({ branch, config, exec, fetch }) {
13
+ validateInputs({ branch, config, exec, fetch });
14
+
15
+ const packageJsonPath = path.resolve(process.cwd(), 'package.json');
16
+ const changelogPath = path.resolve(process.cwd(), 'CHANGELOG.md');
17
+ const previousSha = await getHeadSha(exec);
18
+ const ciStatus = await watchCi({ branch, exec });
19
+
20
+ if (ciStatus !== 'green') {
21
+ const result = {
22
+ ok: false,
23
+ status: 'stuck',
24
+ branch,
25
+ ciStatus,
26
+ summary: `Release halted because CI is ${ciStatus}`,
27
+ };
28
+ await writeCompletion(result);
29
+ return result;
30
+ }
31
+
32
+ const currentVersion = await readCurrentVersion(packageJsonPath);
33
+ const commits = await readCommits(exec);
34
+ const nextVersion = determineNextVersion({
35
+ currentVersion,
36
+ commits,
37
+ });
38
+ const changelog = generateChangelog({
39
+ version: `v${nextVersion}`,
40
+ date: new Date().toISOString().slice(0, 10),
41
+ commits,
42
+ });
43
+
44
+ await applyVersionBump({
45
+ version: nextVersion,
46
+ packageJsonPath,
47
+ fs: fsp,
48
+ });
49
+ prependToChangelog({
50
+ changelogPath,
51
+ content: changelog,
52
+ fs,
53
+ });
54
+
55
+ await exec(`git add ${shellQuote(packageJsonPath)} ${shellQuote(changelogPath)}`);
56
+ await exec(`git commit -m ${shellQuote(`chore(release): v${nextVersion}`)}`);
57
+ await exec(`git tag v${nextVersion}`);
58
+ await exec(`git push origin ${shellQuote(branch)}`);
59
+ await exec(`git push origin v${nextVersion}`);
60
+
61
+ let published = false;
62
+ if (config.publish && config.publish.npm) {
63
+ await exec('npm publish');
64
+ published = true;
65
+ }
66
+
67
+ const health = await checkHealth({
68
+ healthUrl: config.deploy.healthUrl,
69
+ fetch,
70
+ });
71
+
72
+ if (!health.ok) {
73
+ const rollbackResult = await rollback({
74
+ branch,
75
+ previousSha,
76
+ exec,
77
+ });
78
+ const result = {
79
+ ok: false,
80
+ status: 'rolled_back',
81
+ branch,
82
+ version: nextVersion,
83
+ ciStatus,
84
+ published,
85
+ health,
86
+ rollback: rollbackResult,
87
+ summary: `Release rolled back after failing health checks for v${nextVersion}`,
88
+ };
89
+ await writeCompletion(result);
90
+ return result;
91
+ }
92
+
93
+ const result = {
94
+ ok: true,
95
+ status: 'released',
96
+ branch,
97
+ version: nextVersion,
98
+ ciStatus,
99
+ published,
100
+ health,
101
+ summary: `Release completed for v${nextVersion}`,
102
+ };
103
+ await writeCompletion(result);
104
+ return result;
105
+ }
106
+
107
+ function validateInputs({ branch, config, exec, fetch }) {
108
+ if (!branch || typeof branch !== 'string') {
109
+ throw new Error('branch is required');
110
+ }
111
+
112
+ if (!config || typeof config !== 'object') {
113
+ throw new Error('config is required');
114
+ }
115
+
116
+ if (!config.deploy || !config.deploy.healthUrl) {
117
+ throw new Error('config.deploy.healthUrl is required');
118
+ }
119
+
120
+ if (typeof exec !== 'function') {
121
+ throw new TypeError('exec is required');
122
+ }
123
+
124
+ if (typeof fetch !== 'function') {
125
+ throw new TypeError('fetch is required');
126
+ }
127
+ }
128
+
129
+ async function getHeadSha(exec) {
130
+ const result = await exec('git rev-parse HEAD');
131
+ return String((result && result.stdout) || '').trim();
132
+ }
133
+
134
+ async function readCurrentVersion(packageJsonPath) {
135
+ const packageJson = await fsp.readFile(packageJsonPath, 'utf8');
136
+ const parsed = JSON.parse(packageJson);
137
+
138
+ if (!parsed.version || typeof parsed.version !== 'string') {
139
+ throw new Error('package.json version is required');
140
+ }
141
+
142
+ return parsed.version;
143
+ }
144
+
145
+ async function readCommits(exec) {
146
+ const result = await exec('git log -20 --pretty=format:%H%x09%s');
147
+ return String((result && result.stdout) || '')
148
+ .split('\n')
149
+ .map((line) => line.trim())
150
+ .filter(Boolean)
151
+ .map((line) => {
152
+ const [sha = '', message = ''] = line.split('\t');
153
+ return { sha, message };
154
+ });
155
+ }
156
+
157
+ async function writeCompletion(result) {
158
+ await fsp.mkdir(path.dirname(COMPLETIONS_PATH), { recursive: true });
159
+
160
+ let completions = [];
161
+ try {
162
+ const existing = await fsp.readFile(COMPLETIONS_PATH, 'utf8');
163
+ const parsed = JSON.parse(existing);
164
+ if (Array.isArray(parsed)) {
165
+ completions = parsed;
166
+ }
167
+ } catch (error) {
168
+ if (!error || error.code !== 'ENOENT') {
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ completions.push({
174
+ ...result,
175
+ completedAt: new Date().toISOString(),
176
+ });
177
+
178
+ await fsp.writeFile(COMPLETIONS_PATH, `${JSON.stringify(completions, null, 2)}\n`, 'utf8');
179
+ }
180
+
181
+ function shellQuote(value) {
182
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
183
+ }
184
+
185
+ module.exports = {
186
+ runRelease,
187
+ };