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