hd-garden 0.14.8 → 0.14.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/TableOfContents.tsx +13 -6
- package/app/globals.css +5 -7
- package/app/lib/i18n.ts +2 -6
- package/editors/dist/{garden-typora-0.14.8.zip → garden-typora-0.14.10.zip} +0 -0
- package/editors/dist/typora/manifest.json +1 -1
- package/editors/dist/vscode/{garden-ui-0.14.8.vsix → garden-ui-0.14.10.vsix} +0 -0
- package/package.json +1 -1
- package/scripts/release.py +367 -0
- package/scripts/release_executor.py +347 -0
- package/ui/preview/dist/garden-ui.esm.js +1 -1
- package/ui/preview/dist/garden-ui.iife.js +1 -1
|
@@ -15,7 +15,7 @@ import { usePathname } from "next/navigation";
|
|
|
15
15
|
import { nestHeadings, expandedTocSections, toggleTocSection, type TocHeading, type TocNode, type TocExpansion } from "../lib/toc-expansion";
|
|
16
16
|
import { createPortal } from "react-dom";
|
|
17
17
|
import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
|
|
18
|
-
import { X } from "@phosphor-icons/react";
|
|
18
|
+
import { CaretUp, CaretDown, X } from "@phosphor-icons/react";
|
|
19
19
|
import { ui, type UiLang } from "../lib/i18n";
|
|
20
20
|
|
|
21
21
|
type TocProps = { headings: TocHeading[]; label?: string; lang?: UiLang };
|
|
@@ -457,6 +457,18 @@ function ContentsSession({
|
|
|
457
457
|
>
|
|
458
458
|
<div className="toc-header">
|
|
459
459
|
<p>{label}</p>
|
|
460
|
+
{tree.some((node) => node.children.length > 0) ? <div className="toc-mode-controls">
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
data-toc-mode-toggle=""
|
|
464
|
+
aria-controls={listId}
|
|
465
|
+
aria-label={expansion.mode === "follow" ? t.tocExpandAll : t.tocResumeFollow}
|
|
466
|
+
title={expansion.mode === "follow" ? t.tocExpandAll : t.tocResumeFollow}
|
|
467
|
+
onClick={() => setExpansion({ mode: expansion.mode === "follow" ? "all" : "follow" })}
|
|
468
|
+
>
|
|
469
|
+
{expansion.mode === "follow" ? <CaretDown size={14} aria-hidden="true" /> : <CaretUp size={14} aria-hidden="true" />}
|
|
470
|
+
</button>
|
|
471
|
+
</div> : null}
|
|
460
472
|
<button
|
|
461
473
|
ref={mobileCloseRef}
|
|
462
474
|
className="toc-mobile-close"
|
|
@@ -470,11 +482,6 @@ function ContentsSession({
|
|
|
470
482
|
<X size={18} aria-hidden="true" />
|
|
471
483
|
</a>
|
|
472
484
|
</div>
|
|
473
|
-
{tree.some((node) => node.children.length > 0) ? <div className="toc-mode-controls" role="group" aria-label={t.tocDisplay}>
|
|
474
|
-
<button type="button" data-toc-follow="" aria-controls={listId} aria-pressed={expansion.mode === "follow"} onClick={() => setExpansion({ mode: "follow" })}>{t.tocFollow}</button>
|
|
475
|
-
<button type="button" data-toc-expand-all="" aria-controls={listId} aria-pressed={expansion.mode === "all"} onClick={() => setExpansion({ mode: "all" })}>{t.tocExpandAll}</button>
|
|
476
|
-
<span className="toc-mode-status" role="status">{expansion.mode === "manual" ? t.tocManual : ""}</span>
|
|
477
|
-
</div> : null}
|
|
478
485
|
<ol id={listId} ref={listRef} className="toc-list" role="list">
|
|
479
486
|
{tree.map((node, index) => renderNode(node, [index]))}
|
|
480
487
|
</ol>
|
package/app/globals.css
CHANGED
|
@@ -1735,6 +1735,8 @@ button {
|
|
|
1735
1735
|
}
|
|
1736
1736
|
|
|
1737
1737
|
.toc-header > p {
|
|
1738
|
+
flex: 1 1 auto;
|
|
1739
|
+
min-width: 0;
|
|
1738
1740
|
margin: 0;
|
|
1739
1741
|
color: var(--accent);
|
|
1740
1742
|
font-size: 12px;
|
|
@@ -1810,12 +1812,9 @@ button {
|
|
|
1810
1812
|
|
|
1811
1813
|
.toc-list > .toc-section > .toc-item-row > a { font-weight: 600; }
|
|
1812
1814
|
.toc-sublist > .toc-section > .toc-item-row > a { font-weight: 400; }
|
|
1813
|
-
.toc-mode-controls { display: flex;
|
|
1814
|
-
.toc-mode-controls button {
|
|
1815
|
-
.toc-mode-controls button[aria-pressed="true"] { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 4px; }
|
|
1815
|
+
.toc-mode-controls { display: flex; align-items: center; flex: 0 0 auto; margin-left: auto; }
|
|
1816
|
+
.toc-mode-controls button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; background: transparent; color: var(--muted); cursor: pointer; touch-action: manipulation; }
|
|
1816
1817
|
.toc-mode-controls button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
|
1817
|
-
.toc-mode-status { color: var(--muted); }
|
|
1818
|
-
.toc-mode-status:empty { display: none; }
|
|
1819
1818
|
|
|
1820
1819
|
.toc a:hover {
|
|
1821
1820
|
color: var(--ink);
|
|
@@ -3872,7 +3871,6 @@ html[data-theme="dark"] .search-footer {
|
|
|
3872
3871
|
panel, because the post layout's desktop rule overrides the panel's
|
|
3873
3872
|
padding on post pages but not on column pages. */
|
|
3874
3873
|
.toc .toc-header,
|
|
3875
|
-
.toc .toc-mode-controls,
|
|
3876
3874
|
.toc .toc-list {
|
|
3877
3875
|
padding-left: max(20px, env(safe-area-inset-left));
|
|
3878
3876
|
padding-right: max(20px, env(safe-area-inset-right));
|
|
@@ -3880,7 +3878,7 @@ html[data-theme="dark"] .search-footer {
|
|
|
3880
3878
|
|
|
3881
3879
|
.toc .toc-item-row > a { display: flex; align-items: center; min-height: 40px; padding-block: 4px; }
|
|
3882
3880
|
.toc .toc-section-toggle { min-width: 40px; min-height: 40px; }
|
|
3883
|
-
.toc .toc-mode-controls button {
|
|
3881
|
+
.toc .toc-mode-controls button { width: 44px; height: 44px; }
|
|
3884
3882
|
|
|
3885
3883
|
.toc .toc-list .toc-section {
|
|
3886
3884
|
margin: 2px 0;
|
package/app/lib/i18n.ts
CHANGED
|
@@ -38,10 +38,8 @@ const zh = {
|
|
|
38
38
|
columnNewestFirst: (title: string | undefined) => `${title},从新到旧`,
|
|
39
39
|
toc: "本篇目录",
|
|
40
40
|
tocShort: "目录",
|
|
41
|
-
|
|
42
|
-
tocFollow: "跟随阅读",
|
|
41
|
+
tocResumeFollow: "恢复跟读",
|
|
43
42
|
tocExpandAll: "全部展开",
|
|
44
|
-
tocManual: "手动调整",
|
|
45
43
|
tocPage: "本页目录",
|
|
46
44
|
open: "打开",
|
|
47
45
|
close: "关闭",
|
|
@@ -300,10 +298,8 @@ const en: typeof zh = {
|
|
|
300
298
|
columnNewestFirst: (title: string | undefined) => `${title}, newest first`,
|
|
301
299
|
toc: "Contents",
|
|
302
300
|
tocShort: "TOC",
|
|
303
|
-
|
|
304
|
-
tocFollow: "Follow reading",
|
|
301
|
+
tocResumeFollow: "Resume following",
|
|
305
302
|
tocExpandAll: "Expand all",
|
|
306
|
-
tocManual: "Manual selection",
|
|
307
303
|
tocPage: "On this page",
|
|
308
304
|
open: "Open ",
|
|
309
305
|
close: "Close ",
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Safe checks by default; explicit execute mode requires current release authorization."""
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
import zipfile
|
|
17
|
+
|
|
18
|
+
PHASES = ('test', 'pack', 'linux', 'tag-plan', 'published', 'blog-check', 'deployed')
|
|
19
|
+
ENGINE = 'hedon954/garden'
|
|
20
|
+
BLOG = 'hedon954/garden-blog'
|
|
21
|
+
PAGES = 'hedon954/hedon954.github.io'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require(ok, message):
|
|
25
|
+
if not ok:
|
|
26
|
+
raise RuntimeError(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def digest(data):
|
|
30
|
+
return hashlib.sha256(data).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def command(args, cwd):
|
|
34
|
+
return subprocess.check_output(args, cwd=cwd, stderr=subprocess.PIPE).decode().rstrip('\n')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git(root, *args):
|
|
38
|
+
return command(['git', *args], root)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def atomic_json(path, data):
|
|
42
|
+
temporary = path.with_suffix('.tmp')
|
|
43
|
+
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
44
|
+
temporary.replace(path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def snapshot(root):
|
|
48
|
+
names = git(root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z').split('\0')
|
|
49
|
+
result = {}
|
|
50
|
+
for name in sorted(set(filter(None, names))):
|
|
51
|
+
p = root / name
|
|
52
|
+
result[name] = ('link:' + os.readlink(p) if p.is_symlink() else
|
|
53
|
+
digest(p.read_bytes()) if p.is_file() else 'MISSING')
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def protect(before, after, allowed=()):
|
|
58
|
+
changed = [p for p in before.keys() | after.keys()
|
|
59
|
+
if p not in allowed and before.get(p) != after.get(p)]
|
|
60
|
+
require(not changed, 'Protected files changed; preserve and review manually: ' + ', '.join(sorted(changed)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_remote(url):
|
|
64
|
+
return url.removesuffix('.git').replace('git@github.com:', 'https://github.com/')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_layout(root, blog):
|
|
68
|
+
require(canonical_remote(git(root, 'remote', 'get-url', 'origin')) == 'https://github.com/' + BLOG,
|
|
69
|
+
'origin must be garden-blog; never infer the push remote')
|
|
70
|
+
require(canonical_remote(git(root, 'remote', 'get-url', 'upstream')) == 'https://github.com/' + ENGINE,
|
|
71
|
+
'upstream must be garden engine')
|
|
72
|
+
require(git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir') ==
|
|
73
|
+
git(blog, 'rev-parse', '--path-format=absolute', '--git-common-dir'), 'Expected existing shared worktrees')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def preflight(root, blog):
|
|
77
|
+
validate_layout(root, blog)
|
|
78
|
+
version = json.loads((root / 'package.json').read_text())['version']
|
|
79
|
+
require(len(version.split('.')) == 3 and all(p.isdigit() for p in version.split('.')), 'Stable version required')
|
|
80
|
+
command(['node', 'scripts/sync-editor-version.mjs', '--check'], root)
|
|
81
|
+
lock = json.loads((root / 'package-lock.json').read_text())
|
|
82
|
+
require(lock['version'] == version == lock['packages']['']['version'], 'Root lock version mismatch')
|
|
83
|
+
return version, git(root, 'rev-parse', 'HEAD')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def http(url):
|
|
87
|
+
request = urllib.request.Request(url, headers={'Cache-Control': 'no-cache', 'User-Agent': 'Garden-release-check'})
|
|
88
|
+
try:
|
|
89
|
+
with urllib.request.urlopen(request, timeout=45) as response:
|
|
90
|
+
require(response.status == 200, 'HTTP not 200: ' + url)
|
|
91
|
+
return response.read()
|
|
92
|
+
except urllib.error.HTTPError:
|
|
93
|
+
raise
|
|
94
|
+
except (urllib.error.URLError, TimeoutError):
|
|
95
|
+
result = subprocess.run(['curl', '--location', '--silent', '--show-error', '--connect-timeout', '10',
|
|
96
|
+
'--max-time', '45', '--write-out', '\n%{http_code}', url], capture_output=True)
|
|
97
|
+
body, separator, status = result.stdout.rpartition(b'\n')
|
|
98
|
+
require(result.returncode == 0 and separator and status.isdigit(), 'Public HTTPS read failed; never infer absence')
|
|
99
|
+
code = int(status)
|
|
100
|
+
if code != 200:
|
|
101
|
+
raise urllib.error.HTTPError(url, code, 'HTTPS response', None, io.BytesIO(body))
|
|
102
|
+
return body
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def registry(version):
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(http('https://registry.npmjs.org/hd-garden/' + version))
|
|
108
|
+
except urllib.error.HTTPError as error:
|
|
109
|
+
if error.code == 404:
|
|
110
|
+
return None
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def api(path, root):
|
|
115
|
+
return json.loads(command(['gh', 'api', path], root))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tar_files(data):
|
|
119
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
|
|
120
|
+
return {m.name.removeprefix('package/'): archive.extractfile(m).read()
|
|
121
|
+
for m in archive.getmembers() if m.isfile()}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def package_audit(data, version, assets=None):
|
|
125
|
+
files = tar_files(data)
|
|
126
|
+
require(json.loads(files['package.json'])['version'] == version, 'npm version mismatch')
|
|
127
|
+
paths = [f'editors/dist/vscode/garden-ui-{version}.vsix', f'editors/dist/garden-typora-{version}.zip']
|
|
128
|
+
for p in paths:
|
|
129
|
+
require(p in files, 'Missing packaged editor: ' + p)
|
|
130
|
+
if assets is not None:
|
|
131
|
+
require(files[p] == assets[Path(p).name], 'Editor bytes mismatch: ' + p)
|
|
132
|
+
with zipfile.ZipFile(io.BytesIO(files[paths[0]])) as archive:
|
|
133
|
+
require(json.loads(archive.read('extension/package.json'))['version'] == version, 'VSIX version mismatch')
|
|
134
|
+
with zipfile.ZipFile(io.BytesIO(files[paths[1]])) as archive:
|
|
135
|
+
manifests = [p for p in archive.namelist() if p.endswith('/manifest.json')]
|
|
136
|
+
require(len(manifests) == 1 and json.loads(archive.read(manifests[0]))['version'] == version,
|
|
137
|
+
'Typora version mismatch')
|
|
138
|
+
return {p: digest(files[p]) for p in paths}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def source_audit(root, data):
|
|
142
|
+
tracked = set(git(root, 'ls-files', '-z').split('\0'))
|
|
143
|
+
compared = 0
|
|
144
|
+
for name, content in tar_files(data).items():
|
|
145
|
+
if name in tracked:
|
|
146
|
+
require((root / name).read_bytes() == content, 'Packed source differs from clean commit: ' + name)
|
|
147
|
+
compared += 1
|
|
148
|
+
require(compared > 0, 'No source files verified in archive')
|
|
149
|
+
return compared
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def linux_evidence(root, commit):
|
|
153
|
+
runs = api(f'repos/{ENGINE}/actions/runs?head_sha={commit}&per_page=100', root)['workflow_runs']
|
|
154
|
+
good = [r for r in runs if r['head_sha'] == commit and r.get('head_branch') == 'main'
|
|
155
|
+
and r.get('event') == 'push' and r.get('path') == '.github/workflows/verify.yml'
|
|
156
|
+
and r.get('status') == 'completed' and r.get('conclusion') == 'success']
|
|
157
|
+
require(good, 'No successful main Linux full-test workflow for this exact commit; do not tag')
|
|
158
|
+
run = max(good, key=lambda r: r['id'])
|
|
159
|
+
jobs = api(f'repos/{ENGINE}/actions/runs/{run["id"]}/jobs?per_page=100', root)['jobs']
|
|
160
|
+
job = next((j for j in jobs if j['name'] == 'verify'), None)
|
|
161
|
+
require(job and job['conclusion'] == 'success' and any(s.startswith('ubuntu') for s in job['labels']),
|
|
162
|
+
'Linux verify job missing, skipped, or failed')
|
|
163
|
+
steps = {s['name']: s.get('conclusion') for s in job['steps']}
|
|
164
|
+
for name in ['Run npm test', 'A new site from garden init installs the packed engine and builds', 'Verify GitHub Pages export']:
|
|
165
|
+
require(steps.get(name) == 'success', 'Required CI step did not pass: ' + name)
|
|
166
|
+
return {'run': run['html_url'], 'commit': commit}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tag_plan(root, version, commit):
|
|
170
|
+
proof = linux_evidence(root, commit)
|
|
171
|
+
require(registry(version) is None, 'Version already published: never overwrite or republish')
|
|
172
|
+
require(not git(root, 'ls-remote', '--tags', 'upstream', 'refs/tags/v' + version), 'Remote tag already exists')
|
|
173
|
+
local = command(['git', 'tag', '--list', 'v' + version], root)
|
|
174
|
+
require(not local, 'Local tag already exists; reconcile manually, never delete or overwrite')
|
|
175
|
+
require(git(root, 'ls-remote', 'upstream', 'refs/heads/main').split()[0] == commit,
|
|
176
|
+
'upstream/main must equal the tested commit')
|
|
177
|
+
return {**proof, 'manualOnly': [f'git tag -a v{version} {commit} -m "Release {version}"',
|
|
178
|
+
f'git push upstream refs/tags/v{version}']}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def npm_archive(metadata):
|
|
182
|
+
require(metadata['dist']['tarball'].startswith('https://registry.npmjs.org/hd-garden/-/'), 'Unexpected npm artifact URL')
|
|
183
|
+
data = http(metadata['dist']['tarball'])
|
|
184
|
+
integrity = 'sha512-' + base64.b64encode(hashlib.sha512(data).digest()).decode()
|
|
185
|
+
require(integrity in metadata['dist']['integrity'].split(), 'Official npm SHA512 mismatch')
|
|
186
|
+
return data
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def published(root, version, commit):
|
|
190
|
+
metadata = registry(version)
|
|
191
|
+
require(metadata and metadata.get('gitHead') == commit, 'Published gitHead mismatch or version absent')
|
|
192
|
+
remote = git(root, 'ls-remote', 'upstream', 'refs/tags/v' + version + '^{}')
|
|
193
|
+
require(remote and remote.split()[0] == commit, 'Annotated release tag does not resolve to source commit')
|
|
194
|
+
data = npm_archive(metadata)
|
|
195
|
+
release = api(f'repos/{ENGINE}/releases/tags/v{version}', root)
|
|
196
|
+
require(not release['draft'] and not release['prerelease'], 'Not a formal Release')
|
|
197
|
+
assets = {a['name']: http(a['browser_download_url']) for a in release['assets']
|
|
198
|
+
if a['name'] in [f'garden-ui-{version}.vsix', f'garden-typora-{version}.zip']}
|
|
199
|
+
hashes = package_audit(data, version, assets)
|
|
200
|
+
return {'gitHead': commit, 'npmSHA256': digest(data), 'sourceFiles': source_audit(root, data), 'release': release['html_url'], 'editorSHA256': hashes}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def consumer(blog, version):
|
|
204
|
+
require(json.loads((blog / 'package.json').read_text())['dependencies']['hd-garden'] == '^' + version,
|
|
205
|
+
'First explicitly install hd-garden@^VERSION from registry; this tool never changes dependencies')
|
|
206
|
+
installed = blog / 'node_modules/hd-garden'
|
|
207
|
+
require(installed.is_dir() and not installed.is_symlink(), 'Consumer must not be a local file link')
|
|
208
|
+
lock = json.loads((blog / 'package-lock.json').read_text())['packages']['node_modules/hd-garden']
|
|
209
|
+
metadata = registry(version)
|
|
210
|
+
require(metadata and lock.get('version') == version and not lock.get('link')
|
|
211
|
+
and lock.get('resolved') == metadata['dist']['tarball']
|
|
212
|
+
and lock.get('integrity') == metadata['dist']['integrity'], 'Registry consumer lock mismatch')
|
|
213
|
+
require(json.loads((installed / 'package.json').read_text())['version'] == version, 'Installed version mismatch')
|
|
214
|
+
for name, data in tar_files(npm_archive(metadata)).items():
|
|
215
|
+
p = installed / name
|
|
216
|
+
require(p.resolve().is_relative_to(installed.resolve()) and p.is_file() and not p.is_symlink()
|
|
217
|
+
and p.read_bytes() == data, 'Installed registry bytes differ: ' + name)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def deployed(root, blog):
|
|
221
|
+
commit = git(blog, 'rev-parse', 'HEAD')
|
|
222
|
+
require(git(blog, 'ls-remote', 'origin', 'refs/heads/main').split()[0] == commit, 'Blog is not pushed normally yet')
|
|
223
|
+
def successful(repo, sha, path):
|
|
224
|
+
runs = api(f'repos/{repo}/actions/runs?head_sha={sha}&per_page=100', root)['workflow_runs']
|
|
225
|
+
good = [r for r in runs if r.get('head_sha') == sha and r.get('path') == path
|
|
226
|
+
and r.get('status') == 'completed' and r.get('conclusion') == 'success']
|
|
227
|
+
require(good, 'No successful deployment for ' + repo + ' at ' + sha)
|
|
228
|
+
return max(good, key=lambda r: r['id'])['html_url']
|
|
229
|
+
source_run = successful(BLOG, commit, '.github/workflows/pages.yml')
|
|
230
|
+
branch = api(f'repos/{PAGES}/branches/main', root)['commit']
|
|
231
|
+
require(branch['commit']['message'] == 'Deploy ' + commit, 'Public snapshot is not this blog commit')
|
|
232
|
+
pages_run = successful(PAGES, branch['sha'], 'dynamic/pages/pages-build-deployment')
|
|
233
|
+
tree = api(f'repos/{PAGES}/git/trees/{branch["sha"]}?recursive=1', root)
|
|
234
|
+
require(not tree.get('truncated'), 'Incomplete snapshot tree')
|
|
235
|
+
from html.parser import HTMLParser
|
|
236
|
+
from urllib.parse import unquote, urljoin, urlsplit
|
|
237
|
+
blobs = {x['path']: x['sha'] for x in tree['tree'] if x['type'] == 'blob'}
|
|
238
|
+
assets = set()
|
|
239
|
+
class Resources(HTMLParser):
|
|
240
|
+
def handle_starttag(self, tag, attributes):
|
|
241
|
+
a = dict(attributes)
|
|
242
|
+
value = a.get('src') if tag == 'script' else a.get('href') if tag == 'link' else None
|
|
243
|
+
if value:
|
|
244
|
+
u = urlsplit(urljoin('https://hedon.top/', value))
|
|
245
|
+
if u.netloc == 'hedon.top' and u.path.endswith(('.css', '.js')):
|
|
246
|
+
assets.add(unquote(u.path).lstrip('/'))
|
|
247
|
+
def check(path):
|
|
248
|
+
url = 'https://hedon.top/' + path.removesuffix('index.html')
|
|
249
|
+
data = http(url)
|
|
250
|
+
sha = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
|
|
251
|
+
require(blobs.get(path) == sha, 'Production differs from new snapshot: ' + url)
|
|
252
|
+
return {'url': url, 'sha256': digest(data)}, data
|
|
253
|
+
pages = []
|
|
254
|
+
targets = ['index.html'] + [p for p in blobs if p.startswith('blog/') and p.endswith('/index.html') and 'inspired' in p.lower()]
|
|
255
|
+
require(len(targets) > 1, 'Expected actual Inspired article in snapshot')
|
|
256
|
+
for path in targets:
|
|
257
|
+
info, data = check(path); pages.append(info); Resources().feed(data.decode())
|
|
258
|
+
evidence = []
|
|
259
|
+
for path in sorted(assets):
|
|
260
|
+
info, _ = check(path); evidence.append(info)
|
|
261
|
+
require(evidence, 'No production CSS/JS discovered')
|
|
262
|
+
return {'sourceCI': source_run, 'pagesCI': pages_run, 'snapshot': branch['sha'], 'pages': pages, 'assets': evidence}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_steps(root, directory, steps):
|
|
266
|
+
evidence = []
|
|
267
|
+
for number, (cwd, args, extra_env) in enumerate(steps):
|
|
268
|
+
receipt = directory / f'{number}.json'
|
|
269
|
+
log = directory / f'{number}.log'
|
|
270
|
+
inputs = snapshot(cwd)
|
|
271
|
+
identity = {'cwd': str(cwd), 'argv': args, 'env': extra_env, 'inputs': inputs}
|
|
272
|
+
if receipt.exists():
|
|
273
|
+
saved = json.loads(receipt.read_text())
|
|
274
|
+
if saved['identity'] == identity and saved['exit'] == 0 and log.exists() and saved['logSHA256'] == digest(log.read_bytes()):
|
|
275
|
+
evidence.append(saved); continue
|
|
276
|
+
with log.open('wb') as stream:
|
|
277
|
+
result = subprocess.run(args, cwd=cwd, env={**os.environ, **extra_env}, stdout=stream, stderr=subprocess.STDOUT)
|
|
278
|
+
saved = {'identity': identity, 'exit': result.returncode, 'logSHA256': digest(log.read_bytes())}
|
|
279
|
+
atomic_json(receipt, saved)
|
|
280
|
+
protect(inputs, snapshot(cwd))
|
|
281
|
+
require(result.returncode == 0, f'Failed {args}; see {log}; no later phase executed')
|
|
282
|
+
evidence.append(saved)
|
|
283
|
+
return evidence
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main():
|
|
287
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
288
|
+
parser.add_argument('phase', nargs='?', default='plan', choices=['plan', 'status', 'execute', *PHASES])
|
|
289
|
+
parser.add_argument('--blog', type=Path)
|
|
290
|
+
parser.add_argument('--run', action='store_true', help='run selected checks; execute mode additionally requires current approval and exact --confirm')
|
|
291
|
+
parser.add_argument('--version', help='target stable release version, execute mode only')
|
|
292
|
+
parser.add_argument('--confirm', help='exact phrase: release hd-garden VERSION; operator must have current approval')
|
|
293
|
+
parser.add_argument('--session', default='default', help='reviewed new source after a failed attempt may use a new session')
|
|
294
|
+
parser.add_argument('--retry-stage', choices=['version', 'source-push', 'tag-create', 'tag-push', 'vsix', 'blog-pin', 'blog-commit', 'blog-push'])
|
|
295
|
+
parser.add_argument('--wait-seconds', type=int, default=1200)
|
|
296
|
+
args = parser.parse_args()
|
|
297
|
+
root = Path(__file__).resolve().parent.parent
|
|
298
|
+
blog = (args.blog or root.parent / 'garden-blog').resolve()
|
|
299
|
+
if args.phase == 'execute':
|
|
300
|
+
sys.dont_write_bytecode = True
|
|
301
|
+
from release_executor import entry
|
|
302
|
+
print(json.dumps(entry(root, blog, args), ensure_ascii=False, indent=2)); return
|
|
303
|
+
version, commit = preflight(root, blog)
|
|
304
|
+
directory = root / '.garden/release' / commit
|
|
305
|
+
if args.phase in ('plan', 'status') or not args.run:
|
|
306
|
+
state = directory / 'state.json'
|
|
307
|
+
print(json.dumps({'version': version, 'commit': commit, 'phases': PHASES, 'requested': args.phase,
|
|
308
|
+
'dryRun': True, 'recordedPhases': list(json.loads(state.read_text())['phases']) if state.exists() else [],
|
|
309
|
+
'statePath': str(state),
|
|
310
|
+
'externalWrites': 'Check modes never write remotely. execute --run requires current approval and exact confirmation.'}, indent=2))
|
|
311
|
+
return
|
|
312
|
+
require(not git(root, 'status', '--porcelain=v1'), 'Commit/review source first; checks must bind to clean engine HEAD')
|
|
313
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
with (directory / 'lock').open('w') as lock:
|
|
315
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
316
|
+
state_path = directory / 'state.json'
|
|
317
|
+
before = {'engine': snapshot(root), 'blog': snapshot(blog)}
|
|
318
|
+
state = json.loads(state_path.read_text()) if state_path.exists() else {'commit': commit, 'version': version, 'protected': before, 'phases': {}}
|
|
319
|
+
require(state['commit'] == commit and state['version'] == version, 'Stale evidence identity')
|
|
320
|
+
for name in before:
|
|
321
|
+
protect(state['protected'][name], before[name], ('package.json', 'package-lock.json') if name == 'blog' else ())
|
|
322
|
+
phase_dir = directory / args.phase
|
|
323
|
+
phase_dir.mkdir(exist_ok=True)
|
|
324
|
+
atomic_json(state_path, state)
|
|
325
|
+
if args.phase == 'test':
|
|
326
|
+
result = run_steps(root, phase_dir, [(root, ['npm', 'test'], {})])
|
|
327
|
+
elif args.phase == 'pack':
|
|
328
|
+
require((directory / 'test/0.json').exists(), 'Run test phase first')
|
|
329
|
+
test = json.loads((directory / 'test/0.json').read_text())
|
|
330
|
+
require(test['exit'] == 0 and test['identity']['inputs'] == snapshot(root)
|
|
331
|
+
and test['logSHA256'] == digest((directory / 'test/0.log').read_bytes()), 'Tests missing or stale')
|
|
332
|
+
archive_path = phase_dir / f'hd-garden-{version}.tgz'
|
|
333
|
+
old_pack = state['phases'].get('pack')
|
|
334
|
+
if not archive_path.exists() or (old_pack and (old_pack['npmSHA256'] != digest(archive_path.read_bytes()) or any(not (root / p).exists() or digest((root / p).read_bytes()) != h for p, h in old_pack['editorSHA256'].items()))):
|
|
335
|
+
(phase_dir / '0.json').unlink(missing_ok=True)
|
|
336
|
+
run_steps(root, phase_dir, [(root, ['npm', 'pack', '--json', '--pack-destination', str(phase_dir)], {})])
|
|
337
|
+
data = (phase_dir / f'hd-garden-{version}.tgz').read_bytes()
|
|
338
|
+
assets = {f'garden-ui-{version}.vsix': (root / f'editors/dist/vscode/garden-ui-{version}.vsix').read_bytes(),
|
|
339
|
+
f'garden-typora-{version}.zip': (root / f'editors/dist/garden-typora-{version}.zip').read_bytes()}
|
|
340
|
+
result = {'npmSHA256': digest(data), 'sourceFiles': source_audit(root, data), 'editorSHA256': package_audit(data, version, assets)}
|
|
341
|
+
elif args.phase == 'linux': result = linux_evidence(root, commit)
|
|
342
|
+
elif args.phase == 'tag-plan':
|
|
343
|
+
packed = state['phases'].get('pack')
|
|
344
|
+
archive = directory / 'pack' / f'hd-garden-{version}.tgz'
|
|
345
|
+
require(packed and archive.exists() and packed['npmSHA256'] == digest(archive.read_bytes()),
|
|
346
|
+
'Pack proof missing or artifact changed; do not tag')
|
|
347
|
+
require(packed['editorSHA256'] == package_audit(archive.read_bytes(), version), 'Package evidence changed')
|
|
348
|
+
result = tag_plan(root, version, commit)
|
|
349
|
+
elif args.phase == 'published': result = published(root, version, commit)
|
|
350
|
+
elif args.phase == 'blog-check':
|
|
351
|
+
consumer(blog, version)
|
|
352
|
+
result = run_steps(root, phase_dir, [(blog, ['node', 'scripts/check-lockfile-registry.mjs'], {}),
|
|
353
|
+
(blog, ['./node_modules/.bin/garden', 'verify'], {}), (blog, ['npm', 'run', 'build'], {'STATIC_EXPORT': '1'}),
|
|
354
|
+
(blog, ['npm', 'run', 'pages:validate'], {}), (blog, ['./node_modules/.bin/garden', 'verify', '--rendered'], {})])
|
|
355
|
+
else: result = deployed(root, blog)
|
|
356
|
+
for name, path in [('engine', root), ('blog', blog)]: protect(before[name], snapshot(path))
|
|
357
|
+
state['phases'][args.phase] = result
|
|
358
|
+
atomic_json(state_path, state)
|
|
359
|
+
print(json.dumps({'phase': args.phase, 'commit': commit, 'version': version, 'evidence': {'verifiedSteps': len(result)} if isinstance(result, list) else result, 'statePath': str(state_path)}, indent=2))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == '__main__':
|
|
363
|
+
try:
|
|
364
|
+
main()
|
|
365
|
+
except (RuntimeError, OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, KeyError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
|
|
366
|
+
print('STOP:', str(error), file=sys.stderr)
|
|
367
|
+
sys.exit(1)
|