diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-27 17:18:30 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-27 17:18:30 +0100 |
| commit | 7165c9eeccce44d4cd83194de5a200973e80c45a (patch) | |
| tree | 8cd17a397999c18c2008407e5eeff2acd1b2b9e3 /scripts/build-changelog.py | |
| parent | 4251edccc2e4fa587b26e908b301283cc94ae651 (diff) | |
feat(docs): generate the changelog from git and keep it current in maintenance
The page at spaces/agando/downloads/changelog.html was hand-built from VFS notes by the
in-page agent: 11 entries, huge honest holes ("v4.6.59 - v4.6.100 unknown"), and it had
stopped at v4.6.101 while the app was running v4.6.119. A docs page that lags the release
reads as current, which is worse than no page.
- scripts/build-changelog.py derives every entry from git: it walks commits touching
spaces/newos4, stamps each with the LATEST_VER that was live at that commit, and groups
them into builds. Kinds map onto the page's own vocabulary (feat->release, fix->fix,
test->review, docs->docs, chore/style/refactor->project), and a build's chip is its
dominant kind. Commits whose prefix carries `backticks` render as <code>, escaped first.
- The page is its own template: everything visible comes from the #changelog-data JSON
island, so regeneration swaps the island and refreshes the two prose blocks that
described the VFS-only source. No second copy of the CSS or the 296-line renderer.
- Every unshowable thing is stated, not smoothed: commits before the first version stamp
become one "before v4.6.10" bucket, build numbers that were bumped twice before a commit
are called out as absent, and the page says entries summarise subjects, not diffs.
- CHANGELOG.md is generated from the same data between markers (app-scoped; the root
CHANGELOG.md documents the platform's 0.2.x line and is left alone).
- newos4-maintain.py gains check_changelog(): FAIL when the page's build differs from
LATEST_VER, regenerate under --fix, PASS when current. So the daily 07:15 job keeps the
docs in step with the build instead of relying on someone remembering.
Verification:
- build: 68 entries, 158 commits, v4.6.10 - v4.6.119, page 63 327 bytes, CHANGELOG.md 15 840.
- live page renders 68 entries with kind chips, search, per-build anchors ("Copy a direct
link to v4.6.119"), "68 of 68".
- stale detection proved by bumping LATEST_VER to v4.6.120 without rebuilding:
"FAIL: changelog changelog stale: page says v4.6.119, build is v4.6.120"; --fix then
"PASS: changelog regenerated: ... v4.6.10 - v4.6.120"; version reverted, page back to 119.
- maintain: PASS node tests 20/20, PASS changelog current: v4.6.119 (68 entries), result OK.
- mobile: verify-mobile.py at 360/390 px, light+dark -> ALL VIEWPORTS OK, 0 overflow.
Diffstat (limited to 'scripts/build-changelog.py')
| -rw-r--r-- | scripts/build-changelog.py | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py new file mode 100644 index 0000000..71bcb28 --- /dev/null +++ b/scripts/build-changelog.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Regenerate the newOS4 changelog page from git history. + +Why git: the page used to be built by hand from VFS documents, so it had holes +(v4.6.59 - v4.6.100 were "unknown"). Git is the one source that has every +change, with dates and hashes, and it cannot drift from what shipped. + +The page is its own template: everything visible comes from the JSON island +(`#changelog-data`), so regenerating means swapping that island and refreshing +the two prose blocks that described the old data source. No design duplication, +no second copy of the CSS or the renderer. + +Run: + python3 scripts/build-changelog.py # write the page + CHANGELOG.md + python3 scripts/build-changelog.py --check # exit 1 if stale, write nothing +""" + +import html +import json +import os +import re +import subprocess +import sys +from collections import Counter, OrderedDict +from datetime import datetime, timezone + +HERE = os.path.dirname(os.path.abspath(__file__)) +APP = os.path.dirname(HERE) # spaces/newos4 +ROOT = os.path.dirname(os.path.dirname(APP)) # repo root +PAGE = os.path.join(ROOT, 'spaces/agando/downloads/changelog.html') +MD = os.path.join(APP, 'CHANGELOG.md') +LOADER = os.path.join('spaces/newos4/modules/core/module-loader.js') +APP_REL = 'spaces/newos4' + +# commit prefix -> the page's own kind vocabulary (KIND_LABEL in the renderer) +KIND = OrderedDict([ + ('feat', 'release'), ('fix', 'fix'), ('perf', 'fix'), ('revert', 'fix'), + ('test', 'review'), ('refactor', 'project'), ('docs', 'docs'), + ('style', 'project'), ('chore', 'project'), ('build', 'project'), ('ci', 'project'), +]) +KIND_ORDER = ['current', 'release', 'fix', 'review', 'docs', 'project', 'bug'] +MAX_ITEMS = 14 # per release; the rest is counted, not hidden + + +def git(*args): + return subprocess.run(['git', '-C', ROOT] + list(args), + capture_output=True, text=True, check=True).stdout + + +def commits(): + """Oldest first: [{sha, date, subject, files:[...]}]""" + raw = git('log', '--reverse', '--date=short', + '--format=%x01%H%x01%ad%x01%s', '--name-only', '--', APP_REL) + out, cur = [], None + for line in raw.split('\n'): + if line.startswith('\x01'): + _, sha, date, subject = line.split('\x01', 3) + cur = {'sha': sha, 'date': date, 'subject': subject, 'files': []} + out.append(cur) + elif line.strip() and cur is not None: + cur['files'].append(line.strip()) + return out + + +def version_at(sha): + try: + src = git('show', f'{sha}:{LOADER}') + except subprocess.CalledProcessError: + return None + m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", src) + if not m: + return None + # Only a version-shaped stamp is a build id; early builds used prose/date strings, which would + # otherwise become nonsense chip labels like "2026-06-02-fontfix3". + return m.group(1) if re.match(r'^v?\d+(\.\d+)+$', m.group(1).strip()) else None + + +def current_version(): + with open(os.path.join(ROOT, LOADER), encoding='utf-8') as fh: + m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", fh.read()) + if not m: + raise SystemExit('module-loader.js has no LATEST_VER - refusing to guess a version') + return m.group(1) + + +def kind_of(subject): + head = subject.split(':', 1)[0].split('(', 1)[0].strip().lower() + return KIND.get(head, 'project') + + +def clean(subject): + """Drop the conventional prefix; the kind is shown as a chip already. + + Subjects written by this project use `backticks` for identifiers, and the page + injects item text as author-controlled HTML, so turn each pair into <code>. + Everything is escaped first - a commit subject is still untrusted text. + """ + body = subject.split(':', 1)[1].strip() if ':' in subject else subject + body = body[0].upper() + body[1:] if body else subject + return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>', + html.escape(body)) + + +def build(): + rows = commits() + if not rows: + raise SystemExit('no commits touch ' + APP_REL + ' - nothing to build') + + # Each commit is stamped with the version that was live *at that commit*, so a + # release group is "everything that landed while this version was the build". + for row in rows: + row['version'] = version_at(row['sha']) + version = None + releases = OrderedDict() + for row in rows: + if row['version']: + version = row['version'] + if version: + releases.setdefault(version, []).append(row) + else: + releases.setdefault('\x00pre', []).append(row) + + ver_now = current_version() + first_known = next((v for v in releases if v != '\x00pre'), ver_now) + if '\x00pre' in releases: + # Commits that predate the first LATEST_VER stamp share one honest label, and it has to + # stay FIRST: the entries list is reversed for display, so order here is chronological. + fixed = OrderedDict() + for key, group in releases.items(): + fixed[('before ' + first_known) if key == '\x00pre' else key] = group + releases = fixed + + entries = [] + for ver, group in releases.items(): + items = [] + for row in group[:MAX_ITEMS]: + items.append('<strong>%s</strong> <code>%s</code>' % ( + clean(row['subject']), html.escape(row['sha'][:7]))) + if len(group) > MAX_ITEMS: + items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS)) + kinds = Counter(kind_of(r['subject']) for r in group) + # The dominant kind makes the chip honest: a build that was 9 fixes and 1 + # feature is a fix build, not a feature release. + kind = kinds.most_common(1)[0][0] + files = [f for r in group for f in r['files']] + areas = Counter(f.split('/')[2] for f in files if f.count('/') >= 2) + entries.append({ + 'kind': kind, + 'version': ver, + 'date': group[-1]['date'], + 'title': '%s — %d commit%s' % (ver, len(group), '' if len(group) == 1 else 's'), + 'items': items, + 'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4)) + + ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)), + 'sources': sorted({f for r in group for f in r['files'][:1]})[:4], + 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]], + }) + entries.reverse() # newest build first + + first_date = rows[0]['date'] + repo_first = (git('log', '--max-parents=0', '--date=short', '--format=%ad').split() or ['?'])[0] + gaps = [] + if repo_first < first_date: + gaps.append({'range': 'before ' + first_date, + 'why': 'The repository holds commits from ' + repo_first + ', but nothing that ' + 'touches ' + APP_REL + ' before this date - earlier work predates the ' + 'directory or was never committed here.'}) + gaps.append({'range': 'missing build numbers', + 'why': 'A version bumped twice before anything was committed leaves no trace: only ' + 'builds whose tree reached a commit can appear here. Read a jump in the ' + 'version chip as "those bumps were superseded before commit".'}) + gaps.append({'range': 'every build', + 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed ' + 'commit shows one line even if it changed ten files; run ' + '`git show <sha>` for the change itself.'}) + + page = { + 'generated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'currentVersion': ver_now, + 'currentSynthesized': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'stats': { + 'entries': len(entries), + 'versions': '%s – %s' % (first_known, ver_now), + 'records': len(rows), + 'sourcesRead': 1, + }, + 'gaps': gaps, + 'entries': entries, + } + return page, rows + + +DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the ' + 'commit it came from, so any claim here can be checked with <code>git show</code>. Where ' + 'the repository has no record, the gap is written down instead of filled in.') +GAP_INTRO = ('This page is generated from the repository\'s own history for ' + '<code>spaces/newos4</code> — the same source the running build comes from, so ' + 'there are no unexplained stretches in the release line. What it cannot tell you is ' + 'the shape of a squash-merged change.') + + +def write_page(page): + with open(PAGE, encoding='utf-8') as fh: + doc = fh.read() + island = json.dumps(page, ensure_ascii=False, indent=2) + new, n = re.subn(r'(<script type="application/json" id="changelog-data">)[\s\S]*?(</script>)', + lambda m: m.group(1) + '\n' + island + '\n' + m.group(2), doc, count=1) + assert n == 1, 'changelog JSON island not found - page layout changed' + # Prose that described the old VFS-only source; replaced by pattern so re-runs are idempotent. + new = re.sub(r'(<p class="dek">)[\s\S]*?(</p>)', lambda m: m.group(1) + DEK + m.group(2), new, count=1) + new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)', + lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1) + new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1) + with open(PAGE, 'w', encoding='utf-8') as fh: + fh.write(new) + return len(new) + + +def write_md(page, rows): + """App-side Keep-a-Changelog history; generated region is delimited.""" + order = {'release': 0, 'fix': 1, 'review': 2, 'docs': 3, 'project': 4, 'bug': 5} + out = ['# newOS4 changelog', '', + 'Generated from git history by `scripts/build-changelog.py` — do not edit by hand ' + 'between the markers.', '', '<!-- generated:start -->', ''] + for entry in page['entries']: + out.append('## %s — %s' % (entry['version'], entry['date'])) + out.append('') + if entry['note']: + out.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', '']) + for item in entry['items']: + out.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`')) + out.append('') + out.extend(['<!-- generated:end -->', '']) + body = '\n'.join(out) + if os.path.exists(MD): + old = open(MD, encoding='utf-8').read() + m = re.search(r'<!-- generated:start -->[\s\S]*?<!-- generated:end -->\n?', old) + if m: + body = old[:m.start()] + body + old[m.end():] + with open(MD, 'w', encoding='utf-8') as fh: + fh.write(body) + return len(body) + + +def main(): + page, rows = build() + if '--check' in sys.argv: + with open(PAGE, encoding='utf-8') as fh: + doc = fh.read() + m = re.search(r'"currentVersion"\s*:\s*"([^"]+)"', doc) + have = m.group(1) if m else 'none' + if have != page['currentVersion']: + print('changelog stale: page says %s, build is %s' % (have, page['currentVersion'])) + return 1 + print('changelog current: %s (%d entries)' % (have, len(page['entries']))) + return 0 + size = write_page(page) + mdsize = write_md(page, rows) + print('changelog: %d entries, %d commits, %s -> page %d bytes, md %d bytes' + % (len(page['entries']), len(rows), page['stats']['versions'], size, mdsize)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) |