"""Pack a CTAN upload and rebuild its documentation from the archived sources. Copyright (C) 2026 Junhao Wu. LPPL 1.3c or later; see LICENSE. """ import argparse import hashlib import io from pathlib import Path, PurePosixPath import re import stat import shutil import subprocess import tempfile import zipfile ROOT = Path(__file__).resolve().parents[1] ROOT_FILES = ( 'LICENSE', 'Makefile', 'README.md', 'build.lua', 'wiseref.dtx', 'wiseref.ins', 'wiseref-en.tex', 'wiseref-zh.tex', 'wiseref-en.pdf', 'wiseref-zh.pdf', 'wiseref-example-en.tex', 'wiseref-example-zh.tex', ) TEST_SUFFIXES = {'.py', '.tex', '.json', '.md', '.txt'} def payload(include_pdfs=True, documents=ROOT): files = [ROOT / name for name in ROOT_FILES if include_pdfs or not name.endswith('.pdf')] files.extend(sorted(path for path in (ROOT / 'test').iterdir() if path.is_file() and (path.suffix in TEST_SUFFIXES or path.name == 'wiseref-probe.sty'))) data = {} basenames = set() for path in files: relative = path.relative_to(ROOT).as_posix() input_path = documents / path.name if path.suffix == '.pdf' else path if input_path.is_symlink() or not input_path.is_file(): raise ValueError(f'Not a regular source file: {relative}') for part in PurePosixPath(relative).parts: if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*', part): raise ValueError(f'Invalid CTAN filename: {relative}') if path.name.lower() in basenames: raise ValueError(f'Duplicate filename ignoring case: {relative}') basenames.add(path.name.lower()) content = input_path.read_bytes() if not content: raise ValueError(f'Empty file: {relative}') if path.suffix != '.pdf': content.decode('utf-8') if content.startswith(b'\xef\xbb\xbf') or b'\r' in content: raise ValueError(f'Text must be UTF-8 without BOM and use LF: {relative}') elif not content.startswith(b'%PDF-'): raise ValueError(f'Invalid PDF header: {relative}') data[relative] = content for name, content in data.items(): if name.endswith('.md'): for link in re.findall(r'\[[^\]]*\]\(([^)]+)\)', content.decode('utf-8')): if re.match(r'[a-z]+:|#', link): continue target = (ROOT / name).parent / link.split('#', 1)[0] relative = target.resolve().relative_to(ROOT).as_posix() if relative not in data: raise ValueError(f'Markdown link omitted from archive: {name} -> {link}') return data def validate_tds(content, data, runtime=None): if runtime is None: runtime = (ROOT / 'wiseref.sty').read_bytes() expected = {'tex/latex/wiseref/wiseref.sty': runtime} for name, value in data.items(): area = 'source' if name in ('wiseref.dtx', 'wiseref.ins', 'build.lua') else 'doc' expected[f'{area}/latex/wiseref/{name}'] = value with zipfile.ZipFile(io.BytesIO(content)) as archive: if archive.testzip() is not None: raise ValueError('TDS ZIP integrity check failed') actual = {entry.filename for entry in archive.infolist() if not entry.is_dir()} if actual != set(expected): raise ValueError(f'Unexpected TDS contents: missing={sorted(set(expected)-actual)[:10]}, extra={sorted(actual-set(expected))[:10]}') for name, value in expected.items(): if archive.read(name) != value: raise ValueError(f'TDS file differs from current source/output: {name}') print('TDS layout: PASS (one runtime file; documentation, examples, and sources in their own trees)') def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, help='Output .zip path (default: dist/wiseref-vVERSION.zip)') parser.add_argument('--stage', type=Path, help='Stage source-only files for l3build packaging') parser.add_argument('--check-tag', help='Check a release tag against the declared version, without building') parser.add_argument('--documents', type=Path, default=ROOT, help='Directory containing the current manual PDFs and build records') args = parser.parse_args() source = (ROOT / 'wiseref.dtx').read_text(encoding='utf-8') date, version = re.search(r'\\ProvidesExplPackage\{wiseref\}\{([^}]+)\}\{([^}]+)\}', source).groups() if not re.fullmatch(r'[0-9][A-Za-z0-9.-]*', version): raise ValueError(f'Invalid release version: {version}') if f'\\date{{{date}\\qquad v{version}}}' not in source: raise ValueError('Manual date/version differs from the package declaration') readme = (ROOT / 'README.md').read_text(encoding='utf-8') if f'Version v{version} ({date.replace("/", "-")})' not in readme: raise ValueError('README date/version differs from the package declaration') if args.check_tag: if args.check_tag != f'v{version}': raise ValueError(f'Release tag {args.check_tag} does not match v{version}') print(f'Release tag {args.check_tag}: PASS') return if args.stage: stage = args.stage.resolve() if stage != ROOT / 'build/release-source': raise ValueError('Source staging must use build/release-source') data = payload(include_pdfs=False) if stage.exists(): shutil.rmtree(stage) for name, content in data.items(): target = stage / name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(content) print(f'l3build source staging: PASS ({len(data)} files)') return for language in ('en', 'zh'): pdf = args.documents / f'wiseref-{language}.pdf' inputs = (ROOT / 'wiseref.dtx', ROOT / 'wiseref.ins', ROOT / f'wiseref-{language}.tex') if not pdf.is_file() or pdf.stat().st_mtime < max(path.stat().st_mtime for path in inputs): raise ValueError(f'{pdf.name} is missing or stale; run make check-doc') data = payload(documents=args.documents) tds = (ROOT / 'wiseref.tds.zip').read_bytes() validate_tds(tds, data) with zipfile.ZipFile(ROOT / 'wiseref-ctan.zip') as archive: expected = {f'wiseref/{name}': value for name, value in data.items()} expected['wiseref.tds.zip'] = tds actual = {entry.filename for entry in archive.infolist() if not entry.is_dir()} if archive.testzip() is not None or actual != set(expected): raise ValueError(f'Unexpected l3build CTAN contents: missing={sorted(set(expected)-actual)[:10]}, extra={sorted(actual-set(expected))[:10]}') for name, value in expected.items(): if archive.read(name) != value: raise ValueError(f'l3build CTAN file differs from current source/output: {name}') buffer = io.BytesIO() year, month, day = map(int, date.split('/')) with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive: for name, content in sorted(data.items()): info = zipfile.ZipInfo(f'wiseref/{name}', (year, month, day, 0, 0, 0)) info.create_system = 3 info.external_attr = (stat.S_IFREG | 0o644) << 16 info.internal_attr = 0 if name.endswith('.pdf') else 1 archive.writestr(info, content, compress_type=zipfile.ZIP_DEFLATED) info = zipfile.ZipInfo('wiseref.tds.zip', (year, month, day, 0, 0, 0)) info.create_system = 3 info.external_attr = (stat.S_IFREG | 0o644) << 16 archive.writestr(info, tds, compress_type=zipfile.ZIP_DEFLATED) output = args.output or ROOT / 'dist' / f'wiseref-v{version}.zip' if output.suffix != '.zip': raise ValueError('The output filename must end in .zip') output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='wiseref-release-', dir=output.parent) as work: with zipfile.ZipFile(buffer) as archive: if archive.testzip() is not None: raise ValueError('ZIP integrity check failed') archive.extractall(work) package = Path(work) / 'wiseref' for language in ('en', 'zh'): (package / f'wiseref-{language}.pdf').unlink() result = subprocess.run(['make', 'check-doc'], cwd=package, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) log = output.with_suffix('.build.log') log.write_text(result.stdout, encoding='utf-8') if result.returncode: raise RuntimeError(f'Clean archive build failed; see {log}') print('Clean archive build: PASS (source extraction, both manuals, live outputs and links)') output.write_bytes(buffer.getvalue()) if args.documents.resolve() != ROOT: for language in ('en', 'zh'): for suffix in ('.pdf', '.aux', '.log'): shutil.copy2(args.documents / f'wiseref-{language}{suffix}', ROOT / f'wiseref-{language}{suffix}') for name in ('wiseref-ctan.zip', 'wiseref.tds.zip'): (output.parent / name).write_bytes((ROOT / name).read_bytes()) assets = [output, output.parent / 'wiseref-ctan.zip', output.parent / 'wiseref.tds.zip'] (output.parent / 'SHA256SUMS').write_text(''.join( f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n' for path in assets), encoding='utf-8') print(f'CTAN file checks: PASS ({len(data)} files, UTF-8/LF text, regular 0644 files)') print(f'Archive: {output}') print(f'SHA-256: {hashlib.sha256(buffer.getvalue()).hexdigest()}') if __name__ == '__main__': main()