MimIR
MimIR is my Intermediate Representation
Loading...
Searching...
No Matches
deploy.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Deploy generated documentation into the website repository."""
3
4import argparse
5import json
6import os
7import re
8import shutil
9import subprocess
10import sys
11from pathlib import Path
12
13
14VERSION_RE = re.compile(r'v[0-9][A-Za-z0-9.+-]*')
15
16
17def parse_args() -> argparse.Namespace:
18 """Parse command line arguments."""
19 workspace = Path(os.environ.get('GITHUB_WORKSPACE', '.')).resolve()
20 parser = argparse.ArgumentParser(description='Deploy generated docs to the site checkout.')
21 parser.add_argument('--build-dir', type=Path, default=workspace / 'build' / 'html', help='Generated HTML docs directory')
22 parser.add_argument('--site-dir', type=Path, default=workspace / 'site', help='Checked out site repository')
23 parser.add_argument('--git-ref', default=os.environ.get('GITHUB_REF'), help='Git ref being deployed')
24 parser.add_argument('--ref-name', default=os.environ.get('GITHUB_REF_NAME'), help='Short git ref name being deployed')
25 return parser.parse_args()
26
27
28def remove_path(path: Path) -> None:
29 """Remove a file or directory."""
30 if path.is_dir() and not path.is_symlink():
31 shutil.rmtree(path)
32 else:
33 path.unlink()
34
35
36def copy_children(source_dir: Path, target_dir: Path) -> None:
37 """Copy all children from source_dir into target_dir."""
38 for child in source_dir.iterdir():
39 destination = target_dir / child.name
40 if child.is_dir():
41 shutil.copytree(child, destination)
42 else:
43 shutil.copy2(child, destination)
44
45
46def deploy_root(build_dir: Path, site_dir: Path) -> None:
47 """Deploy docs to the site root while preserving versioned subdirectories."""
48 preserved_names = {'.git'}
49 preserved_names.update(path.name for path in site_dir.iterdir() if path.is_dir() and VERSION_RE.fullmatch(path.name))
50
51 for child in site_dir.iterdir():
52 if child.name not in preserved_names:
53 remove_path(child)
54
55 copy_children(build_dir, site_dir)
56
57
58def deploy_version(build_dir: Path, site_dir: Path, version: str) -> None:
59 """Deploy docs to a versioned subdirectory."""
60 target_dir = site_dir / version
61 if target_dir.exists():
62 remove_path(target_dir)
63 target_dir.mkdir(parents=True, exist_ok=True)
64 copy_children(build_dir, target_dir)
65
66
67def natural_key(name: str) -> list[object]:
68 """Split version names into comparable text and numeric chunks."""
69 return [int(part) if part.isdigit() else part for part in re.findall(r'\d+|[^\d]+', name)]
70
71
72def update_versions(site_dir: Path) -> None:
73 """Update versions.json in the root docs and each versioned snapshot."""
74 version_dirs = [
75 path for path in site_dir.iterdir()
76 if path.is_dir() and VERSION_RE.fullmatch(path.name) and (path / 'index.html').exists()
77 ]
78
79 versions: list[dict[str, str]] = []
80 if (site_dir / 'index.html').exists():
81 versions.append({'label': 'master', 'href': '/'})
82
83 for path in sorted(version_dirs, key=lambda entry: natural_key(entry.name), reverse=True):
84 versions.append({'label': path.name, 'href': f'/{path.name}/'})
85
86 payload = json.dumps(versions, indent=2) + '\n'
87 for output_dir in [site_dir, *version_dirs]:
88 (output_dir / 'versions.json').write_text(payload)
89
90
91def git(site_dir: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
92 """Run a git command in the site repository."""
93 return subprocess.run(
94 ['git', *args],
95 cwd=site_dir,
96 check=check,
97 capture_output=True,
98 text=True,
99 )
100
101
102def commit_and_push(site_dir: Path, label: str) -> None:
103 """Commit site changes and push with a small rebase retry loop."""
104 git(site_dir, 'add', '-A')
105 if git(site_dir, 'diff', '--cached', '--quiet', check=False).returncode == 0:
106 return
107
108 git(site_dir, 'config', 'user.name', 'github-actions[bot]')
109 git(site_dir, 'config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com')
110 git(site_dir, 'commit', '-m', f'Deploy docs for {label}')
111
112 for attempt in range(3):
113 if git(site_dir, 'push', 'origin', 'master', check=False).returncode == 0:
114 return
115 git(site_dir, 'pull', '--rebase', 'origin', 'master')
116 if attempt < 2:
117 continue
118
119 raise RuntimeError('failed to push deployed docs after 3 attempts')
120
121
122def deploy(build_dir: Path, site_dir: Path, git_ref: str, ref_name: str) -> None:
123 """Deploy docs to the correct location based on the ref."""
124 if not build_dir.is_dir():
125 raise FileNotFoundError(f'Build directory does not exist: {build_dir}')
126 if not site_dir.is_dir():
127 raise FileNotFoundError(f'Site directory does not exist: {site_dir}')
128 if not git_ref:
129 raise ValueError('Missing git ref')
130 if not ref_name:
131 raise ValueError('Missing ref name')
132
133 if git_ref == 'refs/heads/master':
134 deploy_root(build_dir, site_dir)
135 label = 'master'
136 else:
137 deploy_version(build_dir, site_dir, ref_name)
138 label = ref_name
139
140 update_versions(site_dir)
141 (site_dir / '.nojekyll').touch()
142 commit_and_push(site_dir, label)
143
144
145def main() -> int:
146 """Program entry point."""
147 args = parse_args()
148 deploy(args.build_dir.resolve(), args.site_dir.resolve(), args.git_ref, args.ref_name)
149 return 0
150
151
152if __name__ == '__main__':
153 sys.exit(main())
list[object] natural_key(str name)
Definition deploy.py:67
int main()
Definition deploy.py:145
None copy_children(Path source_dir, Path target_dir)
Definition deploy.py:36
None deploy_root(Path build_dir, Path site_dir)
Definition deploy.py:46
None remove_path(Path path)
Definition deploy.py:28
subprocess.CompletedProcess[str] git(Path site_dir, *str args, bool check=True)
Definition deploy.py:91
None update_versions(Path site_dir)
Definition deploy.py:72
argparse.Namespace parse_args()
Definition deploy.py:17
None commit_and_push(Path site_dir, str label)
Definition deploy.py:102
None deploy_version(Path build_dir, Path site_dir, str version)
Definition deploy.py:58