From 306927d4d61dcbe7d349137dbc40f7f452bfb315 Mon Sep 17 00:00:00 2001 From: plmn95 Date: Mon, 14 Sep 2026 18:01:46 +0300 Subject: [PATCH] Build and preserve documentation bundles for Vizard releases --- .github/workflows/deploy.yml | 16 ++++- README.md | 18 +++++ astro.config.mjs | 3 +- package.json | 5 +- scripts/assemble-archives.py | 65 +++++++++++++++++++ scripts/build-release.mjs | 34 ++++++++++ scripts/test-archives.py | 60 +++++++++++++++++ src/components/ManualFooter.astro | 13 ++++ src/components/SiteTitle.astro | 2 +- .../docs/reference/modulation/macro.md | 6 +- src/content/docs/reference/modules/scope.md | 8 ++- src/content/docs/reference/modules/source.md | 1 + src/content/docs/reference/shortcuts.md | 29 +++++++++ .../docs/reference/windows/settings.md | 4 +- 14 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 scripts/assemble-archives.py create mode 100644 scripts/build-release.mjs create mode 100644 scripts/test-archives.py create mode 100644 src/components/ManualFooter.astro diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8338c48..4d46a9c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,10 +21,22 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --no-audit --no-fund - name: Validate documentation run: npm run check:docs - - name: Build and upload site - uses: withastro/action@v6 + - run: npm run test:archives + - run: npm run build + - name: Assemble frozen release manuals + run: npm run assemble:archives + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/upload-pages-artifact@v3 + with: + path: dist deploy: needs: build diff --git a/README.md b/README.md index bc0ee3b..b175ee2 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,21 @@ npm run check Small corrections can be made with the **Edit page** link on the published site. For larger changes, read [CONTRIBUTING.md](CONTRIBUTING.md) and open a pull request. + +## Manuals shipped with Vizard + +`npm run build:release -- vX.Y.Z` builds a complete manual at +`/vizard-docs/releases/vX.Y.Z/`, with release identity, local search and a hashed +file inventory in `dist/manifest.json`. The app build captures newest docs main +once and keeps the generated bundle for every platform and release retry. + +Current docs continue deploying on main pushes. App releases upload the already +built `manual.zip` and `manual.zip.json` to matching docs releases. Deployment +runs `npm run assemble:archives` after building current docs, copying those +verified files unchanged into `dist/releases`. Never rebuild/restyle old manuals +or replace published release tags/assets. The `/versions/` page links to current +and archived manuals; links from installed manuals are marked online. + +Run `npm run test:archives` to check preservation and failure behavior. To deploy +current docs explicitly: `gh workflow run deploy.yml --repo plmn95/vizard-docs`. +The app repository owns publication credentials and the build/release procedure. diff --git a/astro.config.mjs b/astro.config.mjs index e4dad1b..7f80bb7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,7 +7,7 @@ const isVercel = process.env.VERCEL === '1'; // https://astro.build/config export default defineConfig({ site: isVercel ? 'https://vizard-docs.vercel.app' : 'https://plmn95.github.io', - base: isVercel ? '/' : '/vizard-docs', + base: process.env.VIZARD_DOCS_BASE || (isVercel ? '/' : '/vizard-docs'), integrations: [ starlight({ title: 'Vizard Documentation', @@ -18,6 +18,7 @@ export default defineConfig({ ], components: { SiteTitle: './src/components/SiteTitle.astro', + Footer: './src/components/ManualFooter.astro', }, editLink: { baseUrl: 'https://github.com/plmn95/vizard-docs/edit/main/', diff --git a/package.json b/package.json index 61b296d..afc6fe6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "check": "npm run check:docs && npm run build", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "build:release": "node scripts/build-release.mjs", + "assemble:archives": "python3 scripts/assemble-archives.py", + "test:archives": "python3 scripts/test-archives.py" }, "dependencies": { "@astrojs/starlight": "^0.41.10", diff --git a/scripts/assemble-archives.py b/scripts/assemble-archives.py new file mode 100644 index 0000000..4090b56 --- /dev/null +++ b/scripts/assemble-archives.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Assemble frozen release assets after building the current site.""" +import hashlib +import html +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import tempfile +import zipfile + +repo = 'plmn95/vizard-docs' +root = Path('dist') + +def gh(*args): + return subprocess.check_output(['gh', *args], text=True) + +def sha(data): + return hashlib.sha256(data).hexdigest() + +pages = json.loads(gh('api', '--paginate', '--slurp', f'repos/{repo}/releases?per_page=100')) +releases = [r for page in pages for r in page if not r['draft']] +links = [] +for release in releases: + version = release['tag_name'] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', version): + continue + names = {a['name'] for a in release['assets']} + if not {'manual.zip', 'manual.zip.json'} <= names: + raise RuntimeError(f'Release {version} is missing its manual. Refusing to remove it from the site.') + with tempfile.TemporaryDirectory() as tmp: + for name in ('manual.zip', 'manual.zip.json'): + gh('release', 'download', version, '--repo', repo, '--pattern', name, '--dir', tmp) + archive = Path(tmp, 'manual.zip') + record = json.loads(Path(tmp, 'manual.zip.json').read_text()) + if sha(archive.read_bytes()) != record['sha256'] or record['appVersion'] != version: + raise RuntimeError(f'Archive checksum/identity mismatch: {version}') + destination = root / 'releases' / version + if destination.exists(): + raise RuntimeError(f'Archive path already exists: {destination}; build a clean site first') + with zipfile.ZipFile(archive) as z: + seen = set() + for entry in z.infolist(): + name = entry.filename + if name in seen or name.startswith('/') or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or ':' in name or (entry.external_attr >> 16) & 0o170000 == 0o120000: + raise RuntimeError('Unsafe archive entry') + seen.add(name) + data = z.read('manifest.json') + manifest = json.loads(data) + if sha(data) != record['manifestSha256'] or manifest['docsCommit'] != record['docsCommit'] or manifest['appVersion'] != version or manifest.get('localPreview') or manifest['basePath'] != f'/vizard-docs/releases/{version}/': + raise RuntimeError(f'Manifest mismatch: {version}') + if seen != set(manifest['files']) | {'manifest.json'}: + raise RuntimeError('Archive file inventory mismatch') + for name, expected in manifest['files'].items(): + if sha(z.read(name)) != expected: + raise RuntimeError('File checksum mismatch: ' + name) + z.extractall(destination) + links.append(f'
  • Vizard {html.escape(version)}
  • ') +chooser = root / 'versions' +chooser.mkdir(exist_ok=True) +(chooser / 'index.html').write_text('Vizard documentation versions

    Documentation versions

    Each release keeps the manual and appearance shipped with the app.

    ') +size = sum(p.stat().st_size for p in root.rglob('*') if p.is_file()) +print(f'Published site: {size:,} bytes; {len(links)} frozen manuals') +if size > 950_000_000: + raise RuntimeError('Site is approaching the GitHub Pages 1 GB limit; change archive hosting before deploying') diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs new file mode 100644 index 0000000..68fcf5b --- /dev/null +++ b/scripts/build-release.mjs @@ -0,0 +1,34 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, writeFileSync, lstatSync } from 'node:fs'; +import { resolve, relative } from 'node:path'; + +const [version, base = `/vizard-docs/releases/${version}/`] = process.argv.slice(2); +if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version || '')) + throw new Error('Usage: npm run build:release -- vX.Y.Z[-prerelease] [basePath]'); +if (!/^\/(?:[A-Za-z0-9._-]+\/)+$/.test(base) || base.split('/').includes('..')) + throw new Error('basePath must be a safe absolute URL directory ending in /'); +const commit = process.env.VIZARD_DOCS_COMMIT || execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid documentation commit'); +execFileSync(process.execPath, ['scripts/check-docs.mjs'], { stdio: 'inherit' }); +execFileSync(process.execPath, ['node_modules/astro/bin/astro.mjs', 'build'], { + stdio: 'inherit', env: { ...process.env, VIZARD_DOCS_VERSION: version, VIZARD_DOCS_BASE: base }, +}); +const root = resolve('dist'); +const files = {}; +function visit(dir) { + for (const name of readdirSync(dir).sort()) { + const path = resolve(dir, name), stat = lstatSync(path); + if (stat.isSymbolicLink()) throw new Error(`Symlinks cannot ship: ${path}`); + if (stat.isDirectory()) visit(path); + else files[relative(root, path).replaceAll('\\', '/')] = createHash('sha256').update(readFileSync(path)).digest('hex'); + } +} +visit(root); +if (!files['index.html'] || !Object.keys(files).some(p => p.startsWith('pagefind/wasm.') && p.endsWith('.pagefind'))) + throw new Error('Missing manual entry or search engine'); +writeFileSync(resolve(root, 'manifest.json'), JSON.stringify({ + schemaVersion: 1, appVersion: version, docsCommit: commit, basePath: base, + entryPage: 'index.html', localPreview: process.env.VIZARD_DOCS_PREVIEW === '1', files, +}, null, 2) + '\n'); +console.log(`Release manual: ${version}, documentation ${commit}, ${Object.keys(files).length} files`); diff --git a/scripts/test-archives.py b/scripts/test-archives.py new file mode 100644 index 0000000..352f972 --- /dev/null +++ b/scripts/test-archives.py @@ -0,0 +1,60 @@ +import hashlib +import json +import os +from pathlib import Path +import runpy +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +SCRIPT = Path(__file__).with_name('assemble-archives.py') + +class ArchiveTest(unittest.TestCase): + def test_preserves_two_versions_and_rejects_missing_or_changed_assets(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + assets = {} + releases = [] + for version, style in [('v1.0.0', 'old style'), ('v2.0.0', 'new style')]: + files = {'index.html': style.encode()} + manifest = json.dumps({'schemaVersion': 1, 'appVersion': version, 'docsCommit': 'a'*40, + 'basePath': f'/vizard-docs/releases/{version}/', 'entryPage': 'index.html', 'localPreview': False, + 'files': {p: hashlib.sha256(b).hexdigest() for p,b in files.items()}}).encode() + archive = root / (version + '.zip') + with zipfile.ZipFile(archive, 'w') as z: + z.writestr('index.html', style) + z.writestr('manifest.json', manifest) + assets[version] = {'manual.zip': archive.read_bytes(), 'manual.zip.json': json.dumps({ + 'appVersion': version, 'docsCommit': 'a'*40, + 'sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + 'manifestSha256': hashlib.sha256(manifest).hexdigest()}).encode()} + releases.append({'draft': False, 'tag_name': version, 'assets': [{'name': n} for n in assets[version]]}) + def gh(args, **kwargs): + if args[1] == 'api': return json.dumps([releases]) + version = args[3] + name = args[args.index('--pattern') + 1] + directory = Path(args[args.index('--dir') + 1]) + (directory / name).write_bytes(assets[version][name]) + return '' + cwd = Path.cwd() + try: + os.chdir(root) + Path('dist').mkdir() + with patch('subprocess.check_output', side_effect=gh): runpy.run_path(str(SCRIPT)) + self.assertEqual(Path('dist/releases/v1.0.0/index.html').read_text(), 'old style') + self.assertEqual(Path('dist/releases/v2.0.0/index.html').read_text(), 'new style') + chooser = Path('dist/versions/index.html').read_text() + self.assertIn('v1.0.0', chooser); self.assertIn('v2.0.0', chooser) + import shutil + shutil.rmtree('dist'); Path('dist').mkdir() + releases[0]['assets'] = [] + with patch('subprocess.check_output', side_effect=gh), self.assertRaisesRegex(RuntimeError, 'missing'): + runpy.run_path(str(SCRIPT)) + releases[0]['assets'] = [{'name': n} for n in assets['v1.0.0']] + assets['v1.0.0']['manual.zip'] += b'tampered' + with patch('subprocess.check_output', side_effect=gh), self.assertRaisesRegex(RuntimeError, 'checksum'): + runpy.run_path(str(SCRIPT)) + finally: os.chdir(cwd) + +if __name__ == '__main__': unittest.main() diff --git a/src/components/ManualFooter.astro b/src/components/ManualFooter.astro new file mode 100644 index 0000000..50ffb47 --- /dev/null +++ b/src/components/ManualFooter.astro @@ -0,0 +1,13 @@ +--- +import Footer from '@astrojs/starlight/components/Footer.astro'; +const version = process.env.VIZARD_DOCS_VERSION; +--- + + + diff --git a/src/components/SiteTitle.astro b/src/components/SiteTitle.astro index 63c0746..2e7da87 100644 --- a/src/components/SiteTitle.astro +++ b/src/components/SiteTitle.astro @@ -4,7 +4,7 @@ const { siteTitleHref } = Astro.locals.starlightRoute; VIZARD - Documentation + Documentation{process.env.VIZARD_DOCS_VERSION ? ` ยท ${process.env.VIZARD_DOCS_VERSION}` : ''}