From 1d78155c38fcbc9c76bad7c522e9d9f1060bc4b9 Mon Sep 17 00:00:00 2001 From: Agando Date: Tue, 27 Oct 2026 17:42:46 +0100 Subject: fix(docs): apply the GLM-5.3 review to the changelog pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verdict was APPROVE WITH CHANGES: 21 findings, 17 fixed, 3 refuted with the command that killed them, 1 kept with the audit that justifies it. The reviewer's best find was a design bug of mine, not a typo. - The age guard was unfixable by --fix and fired on a quiet week. check_changelog() regenerated only on a version mismatch, then returned from the version-match branch before the age check could act, so a page older than the threshold stayed red forever with the false diagnosis "the daily job is not running". Now --fix always regenerates (0.84 s) and age is read from a new `checked` stamp separate from `generated` (content-change time), which also stops a daily 65 KB rewrite of an unchanged page. Proved: seeded 641 h -> FAIL + "run maintain --fix to refresh" -> --fix -> PASS. - Atomic writes everywhere (temp + os.replace): nginx serves these files off disk, so an in-place truncate could hand a visitor half a page. - The JSON island now escapes " would have closed it early and truncated the page). Proved by injecting exactly that subject: island still parses, no stray script tag, payload survives as data. - Sibling drift is checked, not assumed: changelog.json's currentVersion is compared with LATEST_VER. Seeded v4.6.100 -> FAIL "feed drifted", --fix repairs. - Build tags are audited under --deep: 67 tags, each must be reachable from HEAD and name the version its commit tree carries. Proved by tampering (v9.9.9 on the root commit -> FAIL) and by deleting it (-> PASS). - Feed: ids no longer hardcode 2026 or contain spaces, entries carry a , timestamps are real iso-strict commit times instead of a fake T00:00:00Z on a local date, and summaries are truncated before escaping so no "&l" tail. 0 ids with spaces, 0 broken entity tails. - write_md refuses to overwrite a CHANGELOG.md that lost its markers; run() gained a 300 s timeout (a hung git used to block the whole 07:15 job); jumpToHash no longer stacks hint paragraphs; dead fields removed (KIND_ORDER, order, currentSynthesized, sourcesRead — each appeared exactly once in the page, i.e. only in the island the generator wrote). - HTML nits: guide.html footer separator, empty "fixed in" line suppressed, help.html version bumped off v4.5.22. Header stat relabelled "Commits behind it" (it read "Bug records on disk" while counting commits — my own defect, not the reviewer's). - Refuted: merge commits (repo has 0: `git log --merges | wc -l`), first-version board links (the 2 multi-version notes both name the fixing build first), sources disclosure (the page already renders those module paths). maintain --deep: node tests 20/20, changelog current, age 0.0h, 67 tags verified, result OK. Mobile: ALL VIEWPORTS OK. --- scripts/newos4-maintain.py | 132 +++++++++++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 34 deletions(-) (limited to 'scripts/newos4-maintain.py') diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py index 01cc986..2b4a961 100644 --- a/scripts/newos4-maintain.py +++ b/scripts/newos4-maintain.py @@ -11,7 +11,9 @@ FIX = '--fix' in sys.argv DEEP = '--deep' in sys.argv issues = [] def add(l, m): issues.append((l, m)) -def run(cmd, **kw): return subprocess.run(cmd, capture_output=True, text=True, **kw) +def run(cmd, **kw): + kw.setdefault('timeout', 300) + return subprocess.run(cmd, capture_output=True, text=True, **kw) def sh(cmd): return run(cmd, shell=True).stdout.strip() def check_versions(): @@ -120,49 +122,111 @@ def check_course(): r = run([sys.executable, 'scripts/course-validate.py'], cwd=ROOT) add('PASS' if r.returncode == 0 else 'FAIL', 'course ' + ('valid' if r.returncode == 0 else 'INVALID: ' + r.stdout[-160:])) +def check_build_tags(): + """--deep only: every build tag must be reachable from HEAD and must name the version its + commit actually carries. + + The changelog trusts tags (one `for-each-ref` pass instead of a `git describe` per commit). + That trust is cheap to audit but catastrophic to lose silently: a tag moved onto another + commit reassigns that commit to a different build, and a rewritten history orphans the whole + set, with no error anywhere. 67 `git show` calls, so it runs under --deep only. + """ + if not DEEP: + return + try: + out = sh('git -C /var/www/webx11.duckdns.org tag --merged HEAD') + except Exception as exc: + add('FAIL', 'build tags: git failed (%s)' % exc) + return + tags = [t for t in out.split() if re.match(r'^v?\d+(\.\d+)+$', t)] + if not tags: + add('WARN', 'build tags: none reachable from HEAD') + return + unreachable = sh('git -C /var/www/webx11.duckdns.org tag').split() + unreachable = [t for t in unreachable if re.match(r'^v?\d+(\.\d+)+$', t) and t not in tags] + bad = [] + for tag in tags: + try: + src = sh("git -C /var/www/webx11.duckdns.org show '%s:spaces/newos4/modules/core/module-loader.js'" % tag) + except Exception: + bad.append(tag + ' (unreadable)') + continue + m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", src or '') + if not m or m.group(1).strip() != tag: + bad.append('%s (tree says %s)' % (tag, m.group(1).strip() if m else 'nothing')) + if unreachable: + add('FAIL', 'build tags orphaned from HEAD: ' + ', '.join(unreachable[:4])) + if bad: + add('FAIL', 'build tags disagree with their commit: ' + '; '.join(bad[:4])) + if not unreachable and not bad: + add('PASS', 'build tags: %d reachable, all match their commit' % len(tags)) + + def check_changelog(): - """The changelog page must describe the build that is actually running. + """The changelog page must describe the running build and must be freshly generated. - docs/changelog.html was hand-built from VFS notes and had stopped at v4.6.101 while - the app was on v4.6.119 -- a docs page that lags the release is worse than no page, - because it reads as current. build-changelog.py derives every entry from git, so the - only thing left to check is whether it has been run since the last version bump. + Three separate failures, three separate signals: + - version mismatch -> the page describes an older build (regenerate); + - json/feed drift -> the page is current but a sibling artifact is not (regenerate); + - age > 30h -> the daily job is not running (only after a regeneration attempt). + + Under --fix we always regenerate first. That is deliberate: the generator costs 0.84 s, and a + page older than 30h is the NORMAL outcome of a quiet week with no version bumps. The first cut + only regenerated on a version mismatch, so a quiet fortnight turned into a permanent FAIL that + --fix could not clear - a red line that means "nothing happened", which trains the reader to + ignore red. """ script = os.path.join(ROOT, 'scripts/build-changelog.py') if not os.path.exists(script): add('FAIL', 'changelog: scripts/build-changelog.py missing') return - r = run([sys.executable, script, '--check'], cwd=ROOT) - if r.returncode == 0: - add('PASS', 'changelog: ' + (r.stdout.strip() or 'current')) - # A version match is not freshness: if the cron dies, the page and the build stay in - # agreement forever while the page stops being regenerated. - import datetime as _dt - m = re.search(r'"generated"\s*:\s*"([^"]+)"', open(PAGE, encoding='utf-8').read()) - if not m: - add('FAIL', 'changelog: page has no generated timestamp') - return - try: - gen = _dt.datetime.strptime(m.group(1), '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=_dt.timezone.utc) - except ValueError: - add('FAIL', 'changelog: unreadable generated timestamp ' + m.group(1)) - return - age_h = (_dt.datetime.now(_dt.timezone.utc) - gen).total_seconds() / 3600.0 - if age_h > 48: - add('FAIL', 'changelog: generated %.0fh ago (>48h) - the daily job is not running' % age_h) - else: - add('PASS', 'changelog age %.1fh' % age_h) + if not os.path.exists(PAGE): + add('FAIL', 'changelog: page missing - restore with ' + '`git checkout -- spaces/agando/downloads/changelog.html`') return - stale = (r.stdout or r.stderr).strip().splitlines() - stale = stale[-1][:150] if stale else 'stale' + if FIX: - r2 = run([sys.executable, script], cwd=ROOT) - if r2.returncode == 0: - add('PASS', 'changelog regenerated: ' + (r2.stdout.strip()[:130] or 'ok')) + r = run([sys.executable, script], cwd=ROOT) + if r.returncode != 0: + add('FAIL', 'changelog regen failed: ' + (r.stderr.strip()[:150] or '?')) return - add('FAIL', 'changelog regen failed: ' + (r2.stderr.strip()[:150] or '?')) + + r = run([sys.executable, script, '--check'], cwd=ROOT) + if r.returncode != 0: + stale = (r.stdout or r.stderr).strip().splitlines() + add('FAIL', 'changelog ' + (stale[-1][:150] if stale else 'stale')) return - add('FAIL', 'changelog ' + stale) + add('PASS', 'changelog: ' + (r.stdout.strip() or 'current')) + + # The page can be current while its siblings lag (a crash between writes). Compare, don't assume. + try: + js = json.load(open(os.path.join(os.path.dirname(PAGE), 'changelog.json'), encoding='utf-8')) + except Exception as exc: + add('FAIL', 'changelog.json unreadable: %s' % exc) + return + if js.get('currentVersion') != re.search(r"LATEST_VER\s*=\s*'([^']+)'", + open(os.path.join(ROOT, 'modules/core/module-loader.js'), encoding='utf-8').read()).group(1): + add('FAIL', 'changelog.json is on %s, build is newer - feed drifted' + % js.get('currentVersion')) + return + + import datetime as _dt + stamp = js.get('checked') or js.get('generated') + if not stamp: + add('FAIL', 'changelog: no checked/generated timestamp in changelog.json') + return + try: + gen = _dt.datetime.strptime(stamp, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=_dt.timezone.utc) + except ValueError: + add('FAIL', 'changelog: unreadable timestamp ' + str(stamp)) + return + age_h = (_dt.datetime.now(_dt.timezone.utc) - gen).total_seconds() / 3600.0 + if age_h > 30: + add('FAIL', 'changelog: generated %.0fh ago (>30h) - the daily job is not running ' + '(run maintain --fix to refresh)' % age_h) + else: + add('PASS', 'changelog age %.1fh' % age_h) + def check_help_coverage(): """Every command the code registers must appear in help.html (2026-10-22). @@ -233,7 +297,7 @@ def check_links(): def main(): os.chdir(ROOT) bad = syntax_failures() # computed once, gates the bundle rebuild - check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links(); check_changelog() + check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links(); check_changelog(); check_build_tags() fails = [m for l, m in issues if l == 'FAIL'] print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else '')) for l, m in issues: print(f'{l}: {m}') -- cgit v1.2.3