Skip to content

Commit fcd5338

Browse files
chore(ci): keep CURRENT_STATE current from the release workflow (#152)
CURRENT_STATE.md went stale after three consecutive releases because the changesets release PR bumps package.json and CHANGELOG.md and never touched it. Folds the update into the changesets version step so it rides the existing "version packages" PR: scripts/update-current-state.mjs runs after `changeset version` via a new version:packages script the workflow now calls. It maintains the toolkit row in the Package Status Table and an auto-managed release log between markers, is idempotent, and uses only Node built-ins. Editorial prose stays hand-written. A unit test covers the parse-and-update logic, and an eslint override gives scripts/*.mjs its Node globals. Closes #151
1 parent 1993930 commit fcd5338

6 files changed

Lines changed: 234 additions & 1 deletion

File tree

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
uses: changesets/action@v1.9.0
4747
with:
4848
publish: pnpm changeset publish
49-
version: pnpm changeset version
49+
version: pnpm version:packages
5050
title: 'chore: version packages'
5151
commit: 'chore: version packages'
5252
env:

‎CURRENT_STATE.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ releases for scenario additions. `0.4.3` and `0.4.4` each carry a
1111
`REPEATED_BOOT_NOTIFICATION` (Issue #105, PR #114) in `0.3.1`, that is three
1212
external good-first contributions in total.
1313

14+
## Release Log
15+
16+
The version and the entries below are maintained automatically by the release
17+
workflow (`scripts/update-current-state.mjs`, run in the changesets version
18+
step). Do not edit between the markers by hand. Full history lives in
19+
[`packages/toolkit/CHANGELOG.md`](packages/toolkit/CHANGELOG.md) and the release
20+
sections further down.
21+
22+
<!-- RELEASE-LOG:START -->
23+
- `0.4.4` (2026-07-30): feat(scenarios): add firmware-update-failure scenario
24+
<!-- RELEASE-LOG:END -->
25+
1426
## Active Milestone
1527

1628
**v0.5.0 (OCPP 2.0.1), after a complete v0.4.x**

‎eslint.config.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ export default tseslint.config(
1515
'@typescript-eslint/consistent-type-imports': 'error',
1616
},
1717
},
18+
{
19+
// Node scripts run outside the TypeScript build, so give them Node globals.
20+
files: ['scripts/**/*.mjs'],
21+
languageOptions: {
22+
globals: {
23+
process: 'readonly',
24+
console: 'readonly',
25+
Buffer: 'readonly',
26+
URL: 'readonly',
27+
},
28+
},
29+
},
1830
{
1931
ignores: [
2032
'**/dist/**',

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"format": "prettier --write .",
1818
"format:check": "prettier --check .",
1919
"changeset": "changeset",
20+
"version:packages": "changeset version && node scripts/update-current-state.mjs",
2021
"clean": "turbo run clean && rm -rf node_modules",
2122
"test:external-fixture": "bash scripts/test-external-fixture.sh"
2223
},

‎scripts/update-current-state.mjs‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env node
2+
// Keep CURRENT_STATE.md current from the release workflow.
3+
//
4+
// Run right after `changeset version`, inside the changesets "version" step, so
5+
// the edit rides the existing "version packages" PR (which gets CI and a human
6+
// merge) rather than pushing directly to a protected main.
7+
//
8+
// It maintains two machine-owned spots in CURRENT_STATE.md and nothing else:
9+
// 1. the @ocpp-debugkit/toolkit row in the Package Status Table, and
10+
// 2. an auto-managed release log delimited by RELEASE-LOG markers.
11+
// Editorial prose stays hand-written.
12+
//
13+
// The script is idempotent: a second run for the same version is a no-op.
14+
// It uses only Node built-ins, so it adds no dependency.
15+
16+
import { readFileSync, writeFileSync } from 'node:fs';
17+
import { fileURLToPath } from 'node:url';
18+
import { dirname, join } from 'node:path';
19+
20+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
21+
const pkgPath = join(repoRoot, 'packages/toolkit/package.json');
22+
const changelogPath = join(repoRoot, 'packages/toolkit/CHANGELOG.md');
23+
const statePath = join(repoRoot, 'CURRENT_STATE.md');
24+
25+
const LOG_START = '<!-- RELEASE-LOG:START -->';
26+
const LOG_END = '<!-- RELEASE-LOG:END -->';
27+
const TABLE_ROW = /(\| `@ocpp-debugkit\/toolkit` \| published \| )([^|]+?)( \|)/;
28+
29+
/** The release date, injectable for tests; otherwise today in UTC. */
30+
function releaseDate() {
31+
const injected = process.env.RELEASE_DATE;
32+
if (injected) return injected;
33+
return new Date().toISOString().slice(0, 10);
34+
}
35+
36+
/** Pull the change descriptions for `version` out of the toolkit CHANGELOG. */
37+
export function changelogSummary(changelog, version) {
38+
const lines = changelog.split('\n');
39+
const start = lines.findIndex((l) => l.trim() === `## ${version}`);
40+
if (start === -1) return null;
41+
42+
const descriptions = [];
43+
for (let i = start + 1; i < lines.length; i++) {
44+
if (lines[i].startsWith('## ')) break; // next version section
45+
// Changeset bullets look like "- <sha>: <text>" or "- <text>".
46+
const bullet = lines[i].match(/^- (?:[0-9a-f]{7,40}: )?(.+)$/);
47+
if (bullet) descriptions.push(bullet[1].trim());
48+
}
49+
return descriptions.length ? descriptions.join('; ') : null;
50+
}
51+
52+
/** Insert (or leave alone, if already present) the release-log entry. */
53+
export function withReleaseLogEntry(state, version, entry) {
54+
const startIdx = state.indexOf(LOG_START);
55+
const endIdx = state.indexOf(LOG_END);
56+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
57+
throw new Error('CURRENT_STATE.md is missing the RELEASE-LOG markers');
58+
}
59+
// Idempotent: a line already naming this exact version stays as-is.
60+
const block = state.slice(startIdx, endIdx);
61+
if (block.includes(`\`${version}\``)) return state;
62+
63+
const insertAt = startIdx + LOG_START.length;
64+
return state.slice(0, insertAt) + '\n' + entry + state.slice(insertAt);
65+
}
66+
67+
/** Set the toolkit version in the Package Status Table row. */
68+
export function withTableVersion(state, version) {
69+
if (!TABLE_ROW.test(state)) {
70+
throw new Error('CURRENT_STATE.md is missing the @ocpp-debugkit/toolkit table row');
71+
}
72+
return state.replace(TABLE_ROW, `$1${version}$3`);
73+
}
74+
75+
/** Apply both edits to a CURRENT_STATE.md string. Pure, for testing. */
76+
export function updateState(state, { version, summary, date }) {
77+
const next = withTableVersion(state, version);
78+
const entry = `- \`${version}\` (${date}): ${summary}`;
79+
return withReleaseLogEntry(next, version, entry);
80+
}
81+
82+
function main() {
83+
const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
84+
if (!version) throw new Error('could not read the toolkit version');
85+
86+
const changelog = readFileSync(changelogPath, 'utf8');
87+
const summary = changelogSummary(changelog, version) ?? 'release';
88+
89+
const state = readFileSync(statePath, 'utf8');
90+
const next = updateState(state, { version, summary, date: releaseDate() });
91+
92+
writeFileSync(statePath, next);
93+
process.stdout.write(`CURRENT_STATE.md updated for ${version}\n`);
94+
}
95+
96+
// Run only when invoked directly, so tests can import the pure helpers.
97+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
98+
main();
99+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
changelogSummary,
4+
withReleaseLogEntry,
5+
withTableVersion,
6+
updateState,
7+
} from './update-current-state.mjs';
8+
9+
const CHANGELOG = `# @ocpp-debugkit/toolkit
10+
11+
## 0.4.5
12+
13+
### Patch Changes
14+
15+
- abc1234: feat(scenarios): add meter-value-zero scenario
16+
- def5678: fix(core): tidy a thing
17+
18+
## 0.4.4
19+
20+
### Patch Changes
21+
22+
- 8fe2185: feat(scenarios): add firmware-update-failure scenario
23+
`;
24+
25+
const STATE = `# CURRENT_STATE.md
26+
27+
## Release Log
28+
29+
<!-- RELEASE-LOG:START -->
30+
- \`0.4.4\` (2026-07-30): feat(scenarios): add firmware-update-failure scenario
31+
<!-- RELEASE-LOG:END -->
32+
33+
## Package Status Table
34+
35+
| Package | Status | Version |
36+
|---------|--------|---------|
37+
| \`@ocpp-debugkit/toolkit\` | published | 0.4.4 |
38+
| \`@ocpp-debugkit/core\` | deprecated | 0.1.1 |
39+
`;
40+
41+
describe('changelogSummary', () => {
42+
it('joins multiple change descriptions and strips the commit sha', () => {
43+
expect(changelogSummary(CHANGELOG, '0.4.5')).toBe(
44+
'feat(scenarios): add meter-value-zero scenario; fix(core): tidy a thing',
45+
);
46+
});
47+
48+
it('reads a single-change section', () => {
49+
expect(changelogSummary(CHANGELOG, '0.4.4')).toBe(
50+
'feat(scenarios): add firmware-update-failure scenario',
51+
);
52+
});
53+
54+
it('returns null for a version not in the changelog', () => {
55+
expect(changelogSummary(CHANGELOG, '9.9.9')).toBeNull();
56+
});
57+
});
58+
59+
describe('withTableVersion', () => {
60+
it('updates only the toolkit row', () => {
61+
const out = withTableVersion(STATE, '0.4.5');
62+
expect(out).toContain('| `@ocpp-debugkit/toolkit` | published | 0.4.5 |');
63+
expect(out).toContain('| `@ocpp-debugkit/core` | deprecated | 0.1.1 |');
64+
});
65+
66+
it('throws when the row is missing', () => {
67+
expect(() => withTableVersion('no table here', '0.4.5')).toThrow(/table row/);
68+
});
69+
});
70+
71+
describe('withReleaseLogEntry', () => {
72+
it('inserts the newest entry at the top of the block', () => {
73+
const entry = '- `0.4.5` (2026-08-01): feat: something';
74+
const out = withReleaseLogEntry(STATE, '0.4.5', entry);
75+
const start = out.indexOf('<!-- RELEASE-LOG:START -->');
76+
const newIdx = out.indexOf('0.4.5');
77+
const oldIdx = out.indexOf('0.4.4', start);
78+
expect(newIdx).toBeGreaterThan(start);
79+
expect(newIdx).toBeLessThan(oldIdx);
80+
});
81+
82+
it('is idempotent for a version already present', () => {
83+
const entry = '- `0.4.4` (2026-07-30): duplicate';
84+
expect(withReleaseLogEntry(STATE, '0.4.4', entry)).toBe(STATE);
85+
});
86+
87+
it('throws when the markers are missing', () => {
88+
expect(() => withReleaseLogEntry('no markers', '0.4.5', 'x')).toThrow(/markers/);
89+
});
90+
});
91+
92+
describe('updateState', () => {
93+
it('applies both edits and is idempotent on a second run', () => {
94+
const once = updateState(STATE, {
95+
version: '0.4.5',
96+
summary: 'feat: something',
97+
date: '2026-08-01',
98+
});
99+
expect(once).toContain('| `@ocpp-debugkit/toolkit` | published | 0.4.5 |');
100+
expect(once).toContain('- `0.4.5` (2026-08-01): feat: something');
101+
102+
const twice = updateState(once, {
103+
version: '0.4.5',
104+
summary: 'feat: something',
105+
date: '2026-08-01',
106+
});
107+
expect(twice).toBe(once);
108+
});
109+
});

0 commit comments

Comments
 (0)