diff options
Diffstat (limited to 'scripts/build-changelog.py')
| -rw-r--r-- | scripts/build-changelog.py | 92 |
1 files changed, 64 insertions, 28 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py index ac4e8c1..1b00a5e 100644 --- a/scripts/build-changelog.py +++ b/scripts/build-changelog.py @@ -43,24 +43,25 @@ KIND = OrderedDict([ ('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): + # encoding is pinned: the cron runs with an unset locale and commit subjects carry non-ASCII + # (em dashes, ellipses). Without it a decode error would kill every 07:15 regeneration. return subprocess.run(['git', '-C', ROOT] + list(args), - capture_output=True, text=True, check=True).stdout + capture_output=True, text=True, encoding='utf-8', 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) + raw = git('log', '--reverse', '--date=iso-strict', + '--format=%x01%H%x01%aI%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': []} + _, sha, iso, subject = line.split('\x01', 3) + cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []} out.append(cur) elif line.strip() and cur is not None: cur['files'].append(line.strip()) @@ -182,6 +183,7 @@ def build(): 'kind': kind, 'version': ver, 'date': group[-1]['date'], + 'updated': group[-1]['iso'], '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)) @@ -208,15 +210,20 @@ def build(): 'commit shows one line even if it changed ten files; run ' '`git show <sha>` for the change itself.'}) + now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + prev = existing_island() + # `generated` is the last time the CONTENT changed; `checked` is the last time this ran. Two + # fields because one cannot serve both: a daily rewrite would churn 65 KB of git for nothing, + # while a content-only stamp cannot tell a live cron from a dead one. + content_same = bool(prev) and prev.get('entries') == entries page = { - 'generated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'generated': (prev or {}).get('generated', now) if content_same else now, + 'checked': now, '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, @@ -233,10 +240,29 @@ GAP_INTRO = ('This page is generated from the repository\'s own history for ' 'the shape of a squash-merged change.') +def write_atomic(path, text): + """Temp + rename: nginx serves these files straight off disk, so an in-place truncate can + hand a visitor a half-written page (or leave one after a crash).""" + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as fh: + fh.write(text) + os.replace(tmp, path) + + +def existing_island(): + try: + with open(PAGE, encoding='utf-8') as fh: + return json.loads(fh.read().split('id="changelog-data">')[1].split('</script>')[0]) + except Exception: + return None + + def write_page(page): with open(PAGE, encoding='utf-8') as fh: doc = fh.read() - island = json.dumps(page, ensure_ascii=False, indent=2) + # json.dumps leaves "</" intact, so a commit subject containing </script> would end the island + # early and truncate the page. Escape it; JSON.parse reads "<\/" back as "</". + island = json.dumps(page, ensure_ascii=False, indent=2).replace('</', '<\\/') 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' @@ -245,14 +271,12 @@ def write_page(page): 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) + write_atomic(PAGE, 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 -->', ''] @@ -267,12 +291,16 @@ def write_md(page, rows): out.extend(['<!-- generated:end -->', '']) body = '\n'.join(out) if os.path.exists(MD): - old = open(MD, encoding='utf-8').read() + with open(MD, encoding='utf-8') as fh: + old = fh.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) + if not m: + # Refuse rather than replace: without the markers the file may hold prose that a + # hand-edit added, and silently dropping it is worse than failing loudly. + raise SystemExit('CHANGELOG.md exists but has no generated:start/end markers - ' + 'refusing to overwrite it') + body = old[:m.start()] + body + old[m.end():] + write_atomic(MD, body) return len(body) @@ -283,20 +311,30 @@ def write_feed(page): pretending to a time of day. Item markup is stripped: a feed reader gets text, not HTML. """ base = os.path.dirname(PAGE) - with open(os.path.join(base, 'changelog.json'), 'w', encoding='utf-8') as fh: - json.dump(page, fh, ensure_ascii=False, indent=1) + write_atomic(os.path.join(base, 'changelog.json'), + json.dumps(page, ensure_ascii=False, indent=1)) entries = [] for e in page['entries']: title = '%s — %s' % (e['version'], e['date']) - body = ' '.join(re.sub(r'<[^>]+>', ' ', i).strip() for i in e['items']) + body = re.sub(r'\s+', ' ', ' '.join(re.sub(r'<[^>]+>', ' ', i).strip() + for i in e['items'])).strip() + # Truncate the PLAIN text, then escape: slicing an escaped string can cut "<" in half + # and leave garbage at the tail of every long summary. + if len(body) > 900: + body = body[:900].rsplit(' ', 1)[0] + ' …' + # A tag: URI cannot contain spaces ("before v4.6.10") and must not hardcode the year. + year = (e['date'] or '')[:4] or '2026' + slug = re.sub(r'[^A-Za-z0-9._-]+', '-', e['version']) + anchor = re.sub(r'[^a-z0-9]+', '-', e['version'].lower()) entries.append( ' <entry>\n' ' <title>%s</title>\n' - ' <id>tag:webx11.duckdns.org,2026:%s</id>\n' - ' <updated>%sT00:00:00Z</updated>\n' + ' <id>tag:webx11.duckdns.org,%s:%s</id>\n' + ' <link href="https://webx11.duckdns.org/spaces/agando/downloads/changelog.html#%s"/>\n' + ' <updated>%s</updated>\n' ' <summary>%s</summary>\n' - ' </entry>' % (html.escape(title), html.escape(e['version']), - e['date'], html.escape(re.sub(r'\s+', ' ', body)[:900])) + ' </entry>' % (html.escape(title), year, slug, anchor, + e.get('updated') or (e['date'] + 'T00:00:00Z'), html.escape(body)) ) feed = ('<?xml version="1.0" encoding="utf-8"?>\n' '<feed xmlns="http://www.w3.org/2005/Atom">\n' @@ -304,9 +342,7 @@ def write_feed(page): ' <id>tag:webx11.duckdns.org,2026:changelog</id>\n' ' <updated>%s</updated>\n <author><name>WebX11</name></author>\n%s\n</feed>\n' % (page['generated'], '\n'.join(entries))) - path = os.path.join(base, 'feed.xml') - with open(path, 'w', encoding='utf-8') as fh: - fh.write(feed) + write_atomic(os.path.join(base, 'feed.xml'), feed) return len(feed) |