unaltraweb 0.3.0 → 0.5.0
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.
- checksums.yaml +4 -4
- data/Makefile +57 -17
- data/README.md +47 -14
- data/_plugins/figure_captions.rb +47 -10
- data/_sass/_documentation.scss +7 -5
- data/_sass/_manual.scss +7 -0
- data/docs/_documentation/en/02-tools.md +4 -4
- data/docs/_documentation/en/03-usage.md +62 -1
- data/docs/_documentation/en/06-github-web-editing.md +1 -1
- data/docs/_documentation/en/13-unaltremanual.md +12 -5
- data/docs/_documentation/en/20-syntax.md +14 -0
- data/docs/_documentation/en/25-caption-credits.md +120 -0
- data/docs/_documentation/en/26-image-backgrounds.md +103 -0
- data/docs/_documentation/en/31-template.md +1 -1
- data/docs/_documentation/en/32-development.md +1 -1
- data/docs/_documentation/en/40-distribution.md +60 -23
- data/docs/_documentation/en/42-docker-image.md +21 -11
- data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
- data/docs/_documentation/en/44-editorial-review.md +237 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +14 -7
- data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
- data/docs/agents/manual-authoring-components.md +38 -0
- data/docs/agents/mcp-contract.md +112 -14
- data/docs/agents/visual-companions-0.4.0.md +74 -0
- data/docs/assets/img/caption-credits-demo.svg +19 -0
- data/scripts/editorial_check.py +12 -0
- data/scripts/image_background_check.py +12 -0
- data/scripts/manual/build_pdf.py +94 -16
- data/scripts/manual/filters/figure-captions.lua +65 -10
- data/scripts/manual/templates/manual.tex +6 -1
- data/scripts/test_gem_build.py +32 -2
- data/scripts/test_reproducible_jekyll_build.py +1 -1
- data/scripts/test_wheel_install.py +75 -5
- data/scripts/unaltraweb-mcp-bootstrap.sh +19 -1
- data/scripts/validate_distribution.py +19 -4
- data/scripts/validate_workflows.py +290 -5
- data/scripts/verify_package_publish.py +414 -0
- data/scripts/web_captures/render.py +1 -1
- data/src/unaltraweb_mcp/component-contract.json +37 -37
- data/src/unaltraweb_mcp/editorial.py +495 -0
- data/src/unaltraweb_mcp/editorial_sources.py +504 -0
- data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
- data/src/unaltraweb_mcp/image_probe.py +149 -0
- data/src/unaltraweb_mcp/processes.py +146 -0
- metadata +16 -2
|
@@ -4,22 +4,53 @@ local function append_all(destination, values)
|
|
|
4
4
|
end
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
local caption = {pandoc.RawInline("latex", "\\caption{")}
|
|
13
|
-
for index, block in ipairs(figure.caption.long) do
|
|
7
|
+
local function caption_inlines(caption)
|
|
8
|
+
local result = pandoc.Inlines({})
|
|
9
|
+
for index, block in ipairs(caption.long) do
|
|
14
10
|
if index > 1 then
|
|
15
|
-
table.insert(
|
|
11
|
+
table.insert(result, pandoc.Space())
|
|
16
12
|
end
|
|
17
13
|
if block.content then
|
|
18
|
-
append_all(
|
|
14
|
+
append_all(result, block.content)
|
|
19
15
|
else
|
|
20
|
-
table.insert(
|
|
16
|
+
table.insert(result, pandoc.Str(pandoc.utils.stringify(block)))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
return result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local function description_only(inlines)
|
|
23
|
+
local has_source = false
|
|
24
|
+
local description = inlines:walk({
|
|
25
|
+
Span = function(span)
|
|
26
|
+
if span.classes:includes("uw-caption-source") then
|
|
27
|
+
has_source = true
|
|
28
|
+
return {}
|
|
29
|
+
end
|
|
21
30
|
end
|
|
31
|
+
})
|
|
32
|
+
while #description > 0 and (description[#description].t == "Space" or description[#description].t == "SoftBreak") do
|
|
33
|
+
table.remove(description)
|
|
34
|
+
end
|
|
35
|
+
return description, has_source
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
local function figure_caption(figure)
|
|
39
|
+
if not FORMAT:match("latex") or #figure.caption.long == 0 then
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
local full = caption_inlines(figure.caption)
|
|
44
|
+
local short, has_source = description_only(full)
|
|
45
|
+
local caption = {}
|
|
46
|
+
if has_source then
|
|
47
|
+
table.insert(caption, pandoc.RawInline("latex", "\\caption[{"))
|
|
48
|
+
append_all(caption, short)
|
|
49
|
+
table.insert(caption, pandoc.RawInline("latex", "}]{"))
|
|
50
|
+
else
|
|
51
|
+
table.insert(caption, pandoc.RawInline("latex", "\\caption{"))
|
|
22
52
|
end
|
|
53
|
+
append_all(caption, full)
|
|
23
54
|
table.insert(caption, pandoc.RawInline("latex", "}"))
|
|
24
55
|
if figure.identifier and figure.identifier ~= "" then
|
|
25
56
|
table.insert(caption, pandoc.RawInline("latex", "\\label{" .. figure.identifier .. "}"))
|
|
@@ -33,3 +64,27 @@ function Figure(figure)
|
|
|
33
64
|
table.insert(blocks, pandoc.RawBlock("latex", "\\end{figure}"))
|
|
34
65
|
return blocks
|
|
35
66
|
end
|
|
67
|
+
|
|
68
|
+
local function table_caption(element)
|
|
69
|
+
if not FORMAT:match("latex") then
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
local short, has_source = description_only(caption_inlines(element.caption))
|
|
73
|
+
if has_source then
|
|
74
|
+
element.caption.short = short
|
|
75
|
+
return element
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
local function source_style(span)
|
|
80
|
+
if FORMAT:match("latex") and span.classes:includes("uw-caption-source") then
|
|
81
|
+
local inlines = {pandoc.RawInline("latex", "{\\itshape ")}
|
|
82
|
+
append_all(inlines, span.content)
|
|
83
|
+
table.insert(inlines, pandoc.RawInline("latex", "}"))
|
|
84
|
+
return inlines
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
-- Preserve semantic credit spans until short captions have been computed.
|
|
89
|
+
-- Citeproc runs before this filter, including citations inside the spans.
|
|
90
|
+
return {{Figure = figure_caption, Table = table_caption}, {Span = source_style}}
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
\displaywidowpenalty=10000
|
|
45
45
|
\setcounter{secnumdepth}{3}
|
|
46
46
|
\setcounter{tocdepth}{2}
|
|
47
|
+
\makeatletter
|
|
48
|
+
\renewcommand*{\l@section}{\@dottedtocline{1}{1.5em}{3em}}
|
|
49
|
+
\renewcommand*{\l@subsection}{\@dottedtocline{2}{4.5em}{4em}}
|
|
50
|
+
\makeatother
|
|
47
51
|
\pretocmd{\section}{\Needspace{6\baselineskip}}{}{}
|
|
48
52
|
\pretocmd{\subsection}{\Needspace{5\baselineskip}}{}{}
|
|
49
53
|
\pretocmd{\subsubsection}{\Needspace{5\baselineskip}}{}{}
|
|
@@ -81,7 +85,8 @@
|
|
|
81
85
|
singlelinecheck=false
|
|
82
86
|
}
|
|
83
87
|
\captionsetup[figure]{position=top,margin=1em,aboveskip=0pt,belowskip=7pt}
|
|
84
|
-
|
|
88
|
+
% longtable uses the caption skip below a top caption; belowskip alone is ignored.
|
|
89
|
+
\captionsetup[table]{position=top,margin=1em,skip=7pt}
|
|
85
90
|
\captionsetup[lstlisting]{position=top,margin=0pt,aboveskip=0pt,belowskip=7pt}
|
|
86
91
|
\captionsetup[subfigure]{
|
|
87
92
|
font={manualsubcaption,color=ManualMuted},
|
data/scripts/test_gem_build.py
CHANGED
|
@@ -23,7 +23,7 @@ CONTRACT = json.loads((ROOT / "src/unaltraweb_mcp/component-contract.json").read
|
|
|
23
23
|
RUNTIME_IMAGE = str(CONTRACT["components"]["runtime"]["reference"])
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def run(command: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
def run(command: list[str], *, cwd: Path = ROOT, expected: int = 0) -> subprocess.CompletedProcess[str]:
|
|
27
27
|
completed = subprocess.run(
|
|
28
28
|
command,
|
|
29
29
|
cwd=cwd,
|
|
@@ -32,7 +32,7 @@ def run(command: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[
|
|
|
32
32
|
stderr=subprocess.PIPE,
|
|
33
33
|
check=False,
|
|
34
34
|
)
|
|
35
|
-
if completed.returncode !=
|
|
35
|
+
if completed.returncode != expected:
|
|
36
36
|
raise RuntimeError(f"Command failed: {' '.join(command)}\n{completed.stdout}\n{completed.stderr}")
|
|
37
37
|
return completed
|
|
38
38
|
|
|
@@ -61,12 +61,16 @@ def build(output: Path, *, source: Path = ROOT) -> str:
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def inspect_gem(path: Path) -> None:
|
|
64
|
+
editorial_files = ["scripts/editorial_check.py", "src/unaltraweb_mcp/editorial.py", "src/unaltraweb_mcp/editorial_sources.py"]
|
|
65
|
+
inspection_files = editorial_files + ["scripts/image_background_check.py", "src/unaltraweb_mcp/image_backgrounds.py",
|
|
66
|
+
"src/unaltraweb_mcp/image_probe.py", "src/unaltraweb_mcp/processes.py"]
|
|
64
67
|
with tarfile.open(path, mode="r") as package:
|
|
65
68
|
data_member = package.extractfile("data.tar.gz")
|
|
66
69
|
if data_member is None:
|
|
67
70
|
raise RuntimeError("Built gem has no data.tar.gz payload.")
|
|
68
71
|
with tarfile.open(fileobj=io.BytesIO(data_member.read()), mode="r:gz") as payload:
|
|
69
72
|
names = set(payload.getnames())
|
|
73
|
+
editorial_bytes = {name: payload.extractfile(name).read() for name in inspection_files}
|
|
70
74
|
required = {
|
|
71
75
|
"LICENSE",
|
|
72
76
|
"README.md",
|
|
@@ -97,6 +101,7 @@ def inspect_gem(path: Path) -> None:
|
|
|
97
101
|
"src/unaltraweb_mcp/component-contract.json",
|
|
98
102
|
"src/unaltraweb_mcp/component-contract.schema.json",
|
|
99
103
|
"src/unaltraweb_mcp/docker_mount.py",
|
|
104
|
+
*inspection_files,
|
|
100
105
|
}
|
|
101
106
|
missing = sorted(required - names)
|
|
102
107
|
if missing:
|
|
@@ -104,6 +109,31 @@ def inspect_gem(path: Path) -> None:
|
|
|
104
109
|
unexpected_data = sorted(name for name in names if name.startswith("_data/") and not name.startswith("_data/i18n/"))
|
|
105
110
|
if unexpected_data:
|
|
106
111
|
raise RuntimeError(f"Built gem contains non-runtime data files: {unexpected_data}")
|
|
112
|
+
# Exercise only the actual gem payload with isolated Python: no factory,
|
|
113
|
+
# wheel, inherited PYTHONPATH, source checkout or MCP dependency may help.
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="unaltraweb-gem-editorial-") as temporary:
|
|
115
|
+
core = Path(temporary) / "core"
|
|
116
|
+
for name, content in editorial_bytes.items():
|
|
117
|
+
target = core / name
|
|
118
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
target.write_bytes(content)
|
|
120
|
+
site = Path(temporary) / "site"
|
|
121
|
+
(site / "_pages/en").mkdir(parents=True)
|
|
122
|
+
(site / "_config.yml").write_text("unaltraweb:\n site_profile: unaltreselfie\n", encoding="utf-8")
|
|
123
|
+
page = site / "_pages/en/about.md"
|
|
124
|
+
page.write_text("I study spatial data.\n", encoding="utf-8")
|
|
125
|
+
command = [sys.executable, "-I", str(core / editorial_files[0]), "--project", str(site)]
|
|
126
|
+
if not json.loads(run(command, cwd=site).stdout)["ok"]:
|
|
127
|
+
raise RuntimeError("Gem-native editorial check rejected publication copy.")
|
|
128
|
+
page.write_text("As requested, I have added the biography.\n", encoding="utf-8")
|
|
129
|
+
if json.loads(run(command, cwd=site, expected=1).stdout)["ok"]:
|
|
130
|
+
raise RuntimeError("Gem-native editorial gate accepted chat-dependent copy.")
|
|
131
|
+
(site / "assets").mkdir()
|
|
132
|
+
(site / "assets/test.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="2" fill="blue"/></svg>', encoding="utf-8")
|
|
133
|
+
image_command = [sys.executable, "-I", str(core / "scripts/image_background_check.py"), "--project", str(site), "--source", "assets/test.svg"]
|
|
134
|
+
inspection = json.loads(run(image_command, cwd=site).stdout)
|
|
135
|
+
if not inspection["ok"] or inspection["images"][0]["state"] != "transparent" or not inspection["warnings"]:
|
|
136
|
+
raise RuntimeError(f"Gem-native SVG background inspection failed: {inspection}")
|
|
107
137
|
|
|
108
138
|
|
|
109
139
|
def main() -> int:
|
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
-
IMAGE = os.environ.get("DOCKER_IMAGE", "ghcr.io/dosquartsdedocs/unaltraweb:0.
|
|
16
|
+
IMAGE = os.environ.get("DOCKER_IMAGE", "ghcr.io/dosquartsdedocs/unaltraweb:0.5.0")
|
|
17
17
|
SELECTOR = "v2026.09"
|
|
18
18
|
EPOCH = "946684800"
|
|
19
19
|
|
|
@@ -73,7 +73,12 @@ def main() -> int:
|
|
|
73
73
|
"unaltraweb_mcp/calibre_import.py",
|
|
74
74
|
"unaltraweb_mcp/component-contract.json",
|
|
75
75
|
"unaltraweb_mcp/component-contract.schema.json",
|
|
76
|
+
"unaltraweb_mcp/manual_pdf_preview.py",
|
|
76
77
|
"unaltraweb_mcp/manual_release.py",
|
|
78
|
+
"unaltraweb_mcp/editorial.py",
|
|
79
|
+
"unaltraweb_mcp/editorial_sources.py",
|
|
80
|
+
"unaltraweb_mcp/image_backgrounds.py",
|
|
81
|
+
"unaltraweb_mcp/image_probe.py",
|
|
77
82
|
"unaltraweb_mcp/scaffolds/common/AGENTS.md.tmpl",
|
|
78
83
|
"unaltraweb_mcp/scaffolds/common/Makefile.tmpl",
|
|
79
84
|
"unaltraweb_mcp/scaffolds/common/README.md.tmpl",
|
|
@@ -123,6 +128,7 @@ def main() -> int:
|
|
|
123
128
|
if not created["ok"]:
|
|
124
129
|
raise RuntimeError(f"new-web failed for {profile} from clean wheel: {created}")
|
|
125
130
|
readme = (profile_site / "README.md").read_text(encoding="utf-8")
|
|
131
|
+
agents = (profile_site / "AGENTS.md").read_text(encoding="utf-8")
|
|
126
132
|
collaboration = (profile_site / ".github/CONTRIBUTING.md").read_text(encoding="utf-8")
|
|
127
133
|
dependabot = (profile_site / ".github/dependabot.yml").read_text(encoding="utf-8")
|
|
128
134
|
pr_template = (profile_site / ".github/pull_request_template.md").read_text(encoding="utf-8")
|
|
@@ -132,7 +138,7 @@ def main() -> int:
|
|
|
132
138
|
f"**`{profile}`** profile: {description}",
|
|
133
139
|
f"## Editable Content For `{profile}`",
|
|
134
140
|
profile_path,
|
|
135
|
-
"only one active
|
|
141
|
+
"only one active editing session per repository",
|
|
136
142
|
"Never edit or commit directly to `main`",
|
|
137
143
|
"Draft pull request",
|
|
138
144
|
"Starts the deploy workflow manually",
|
|
@@ -146,6 +152,18 @@ def main() -> int:
|
|
|
146
152
|
raise RuntimeError(f"generated {profile} README does not explain all profiles")
|
|
147
153
|
if "correctly named short-lived branch" not in pr_template or "Required local checks and renders pass" not in pr_template:
|
|
148
154
|
raise RuntimeError(f"generated {profile} pull request template lacks coordination checks")
|
|
155
|
+
agent_markers = [
|
|
156
|
+
"read-only checkout preflight",
|
|
157
|
+
"primary mutable checkout",
|
|
158
|
+
"process-held cooperative lease",
|
|
159
|
+
"`exec` wrapper",
|
|
160
|
+
"one active editing session per repository",
|
|
161
|
+
"one top-level MCP",
|
|
162
|
+
"declared dependency closure",
|
|
163
|
+
"`MCP_CONSUMER_WORKSPACE`",
|
|
164
|
+
]
|
|
165
|
+
if any(value not in agents for value in agent_markers):
|
|
166
|
+
raise RuntimeError(f"generated {profile} agent contract lacks single-checkout guidance")
|
|
149
167
|
collaboration_markers = [
|
|
150
168
|
"content/<issue>-<slug>",
|
|
151
169
|
"reference/<issue>-<slug>",
|
|
@@ -158,20 +176,37 @@ def main() -> int:
|
|
|
158
176
|
"integration/<provider>/<issue>-<slug>",
|
|
159
177
|
"A maintainer must accept an exact reservation",
|
|
160
178
|
"not broad directories or globs",
|
|
161
|
-
"
|
|
162
|
-
"
|
|
179
|
+
"one active editing session per repository",
|
|
180
|
+
"primary mutable checkout",
|
|
181
|
+
"read-only checkout preflight",
|
|
182
|
+
"process-held cooperative lease",
|
|
183
|
+
"`exec` wrapper",
|
|
184
|
+
"Never create, switch to, move, prune, repair, or remove Git worktrees implicitly",
|
|
185
|
+
"one top-level MCP",
|
|
186
|
+
"declared dependency closure",
|
|
187
|
+
"`MCP_CONSUMER_WORKSPACE`",
|
|
188
|
+
"factory `build`, `check`, and `smoke` operations",
|
|
163
189
|
"one atomic bundle",
|
|
164
190
|
"source, every local data/input file, output, caption-bearing content reference",
|
|
165
191
|
"immutable release, full commit SHA, or image digest",
|
|
166
192
|
"Draft pull request after the first coherent change",
|
|
167
193
|
"delete the local and remote task branch",
|
|
168
|
-
"
|
|
194
|
+
"retain the primary checkout",
|
|
169
195
|
]
|
|
170
196
|
if any(value not in collaboration for value in collaboration_markers):
|
|
171
197
|
raise RuntimeError(f"generated {profile} collaboration contract is incomplete")
|
|
172
198
|
if "dependency-name: unaltraweb" not in dependabot or f"dependency-name: {integration['site_deploy_workflow']}" not in dependabot:
|
|
173
199
|
raise RuntimeError(f"generated {profile} Dependabot policy permits partial integration updates")
|
|
174
|
-
combined_guidance = readme + collaboration + pr_template
|
|
200
|
+
combined_guidance = readme + collaboration + pr_template + agents
|
|
201
|
+
obsolete_guidance = [
|
|
202
|
+
"dedicated Git worktree",
|
|
203
|
+
"Never share a mutable checkout",
|
|
204
|
+
"remove the worktree",
|
|
205
|
+
"one active editor per file",
|
|
206
|
+
"no other active editor",
|
|
207
|
+
]
|
|
208
|
+
if any(value in combined_guidance for value in obsolete_guidance):
|
|
209
|
+
raise RuntimeError(f"generated {profile} guidance retains obsolete worktree coordination")
|
|
175
210
|
forbidden = ["${{", "secrets.", "GITHUB_TOKEN", "id-token:", "contents: write", "on:\n push:"]
|
|
176
211
|
if any(value in combined_guidance for value in forbidden):
|
|
177
212
|
raise RuntimeError(f"generated {profile} editor guidance exposes workflow or secret internals")
|
|
@@ -198,6 +233,7 @@ def main() -> int:
|
|
|
198
233
|
raise RuntimeError(f"generated {profile} .gitignore ignores arbitrary publication assets")
|
|
199
234
|
gemfile = (profile_site / "Gemfile").read_text(encoding="utf-8")
|
|
200
235
|
lockfile = (profile_site / "Gemfile.lock").read_text(encoding="utf-8")
|
|
236
|
+
makefile = (profile_site / "Makefile").read_text(encoding="utf-8")
|
|
201
237
|
deploy = (profile_site / ".github/workflows/deploy.yml").read_text(encoding="utf-8")
|
|
202
238
|
if f'git: "{integration["core_repository"]}"' not in gemfile or f'ref: "{integration["core_sha"]}"' not in gemfile:
|
|
203
239
|
raise RuntimeError(f"generated {profile} Gemfile does not use the reviewed core revision")
|
|
@@ -207,6 +243,16 @@ def main() -> int:
|
|
|
207
243
|
raise RuntimeError(f"generated {profile} deploy caller does not use the reviewed core revision")
|
|
208
244
|
if f'manual-pdf-image: "{integration["manual_pdf_image"]}"' not in deploy:
|
|
209
245
|
raise RuntimeError(f"generated {profile} deploy caller does not use the reviewed PDF image")
|
|
246
|
+
makefile_markers = [
|
|
247
|
+
f"MANUAL_PDF_IMAGE ?= {integration['manual_pdf_image']}",
|
|
248
|
+
"manual-pdf-preview-prepare: runtime-image",
|
|
249
|
+
"manual-pdf-preview-clean: runtime-image",
|
|
250
|
+
"build: manual-pdf-preview-prepare",
|
|
251
|
+
"serve: manual-pdf-preview-prepare",
|
|
252
|
+
"test: manual-pdf-preview-prepare",
|
|
253
|
+
]
|
|
254
|
+
if any(value not in makefile for value in makefile_markers):
|
|
255
|
+
raise RuntimeError(f"generated {profile} Makefile lacks managed manual PDF preview staging")
|
|
210
256
|
if f'vegavisuals-sha: "{integration["vegavisuals_sha"]}"' not in deploy:
|
|
211
257
|
raise RuntimeError(f"generated {profile} deploy caller does not use the reviewed Vega renderer")
|
|
212
258
|
manifest = json.loads((profile_site / ".unaltraweb/scaffold.json").read_text(encoding="utf-8"))
|
|
@@ -224,6 +270,9 @@ def main() -> int:
|
|
|
224
270
|
if set(manifest["files"]) != expected_managed:
|
|
225
271
|
raise RuntimeError(f"generated {profile} scaffold baseline is incomplete: {manifest['files']}")
|
|
226
272
|
sites[profile] = profile_site
|
|
273
|
+
checked = json.loads(run([str(cli), "--project", str(profile_site), "mcp", "prose-check"], cwd=temp).stdout)
|
|
274
|
+
if not checked["ok"] or checked["profile"] != profile:
|
|
275
|
+
raise RuntimeError(f"Package-only editorial check failed for {profile}: {checked}")
|
|
227
276
|
|
|
228
277
|
site = sites["unaltredocs"]
|
|
229
278
|
if not (site / ".unaltraweb/scaffold.json").is_file():
|
|
@@ -253,6 +302,9 @@ def main() -> int:
|
|
|
253
302
|
detected = json.loads(run([str(cli), "--project", str(site), "mcp", "detect-site"], cwd=temp).stdout)
|
|
254
303
|
if not detected["is_unaltraweb_site"]:
|
|
255
304
|
raise RuntimeError(f"package-only inspection failed from clean wheel: {detected}")
|
|
305
|
+
backgrounds = json.loads(run([str(cli), "--project", str(site), "mcp", "image-background-check"], cwd=temp).stdout)
|
|
306
|
+
if not backgrounds["ok"] or not backgrounds["read_only"]:
|
|
307
|
+
raise RuntimeError(f"Factory-free background inspection failed: {backgrounds}")
|
|
256
308
|
project_doctor = json.loads(run([str(cli), "doctor", "--project", str(site)], cwd=temp).stdout)
|
|
257
309
|
if not project_doctor["ok"] or project_doctor["project"]["profile"] != "unaltredocs":
|
|
258
310
|
raise RuntimeError(f"project doctor failed from clean wheel: {project_doctor}")
|
|
@@ -270,6 +322,24 @@ def main() -> int:
|
|
|
270
322
|
scaffold = json.loads(run([str(cli), "--project", str(site), "mcp", "scaffold-sync"], cwd=temp).stdout)
|
|
271
323
|
if not scaffold["ok"] or not scaffold["dry_run"]:
|
|
272
324
|
raise RuntimeError(f"wheel scaffold sync dry-run failed: {scaffold}")
|
|
325
|
+
context = json.loads(run([str(cli), "--project", str(site), "mcp", "site-context"], cwd=temp).stdout)
|
|
326
|
+
if context["update_status"]["state"] != "current" or context["update_status"]["can_apply"]:
|
|
327
|
+
raise RuntimeError(f"wheel consumer update advisory failed: {context['update_status']}")
|
|
328
|
+
prepared = json.loads(run([str(cli), "--project", str(site), "mcp", "editorial-review-prepare"], cwd=temp).stdout)
|
|
329
|
+
report = {"id": "wheel-review", "source_digest": prepared["source_digest"], "reviewer": "Wheel smoke", "findings": []}
|
|
330
|
+
recorded = json.loads(run([
|
|
331
|
+
str(cli), "--project", str(site), "mcp", "editorial-review-record",
|
|
332
|
+
"--report-json", json.dumps(report), "--expected-revision", "0",
|
|
333
|
+
], cwd=temp).stdout)
|
|
334
|
+
editorial = json.loads(run([str(cli), "--project", str(site), "mcp", "editorial-status"], cwd=temp).stdout)
|
|
335
|
+
if recorded["revision"] != 1 or editorial["reviews"]["wheel-review"]["stale"]:
|
|
336
|
+
raise RuntimeError("Factory-free wheel did not preserve a fresh editorial review.")
|
|
337
|
+
confirmed = json.loads(run([
|
|
338
|
+
str(cli), "--project", str(site), "mcp", "scaffold-sync", "--apply", "--confirm-sync",
|
|
339
|
+
"--expected-plan-sha256", scaffold["plan_sha256"],
|
|
340
|
+
], cwd=temp).stdout)
|
|
341
|
+
if not confirmed["ok"] or not confirmed["applied"]:
|
|
342
|
+
raise RuntimeError(f"wheel reviewed scaffold synchronization failed: {confirmed}")
|
|
273
343
|
|
|
274
344
|
factory_error = run([str(cli), "--project", str(site), "mcp", "prompts"], cwd=temp, expected=1)
|
|
275
345
|
if "requires the unaltraweb factory checkout" not in factory_error.stderr:
|
|
@@ -10,7 +10,7 @@ If --project is omitted, MCP_CONSUMER_WORKSPACE is used, then the current direct
|
|
|
10
10
|
USAGE
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.
|
|
13
|
+
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.5.0}"
|
|
14
14
|
project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
|
|
15
15
|
|
|
16
16
|
while [ "$#" -gt 0 ]; do
|
|
@@ -62,6 +62,24 @@ project_id="$(/bin/sh "$script_dir/unaltraweb-mcp-project-id.sh" "$project")"
|
|
|
62
62
|
workspace_mount="$(/bin/sh "$script_dir/unaltraweb-docker-mount.sh" "$project" /workspace)"
|
|
63
63
|
mirror_mount="$(/bin/sh "$script_dir/unaltraweb-docker-mount.sh" "$project" "$project")"
|
|
64
64
|
|
|
65
|
+
if [ -S "$docker_socket" ]; then
|
|
66
|
+
unresolved_socket="$docker_socket"
|
|
67
|
+
if ! docker_socket="$(realpath -e -- "$unresolved_socket")"; then
|
|
68
|
+
printf '%s\n' "Cannot resolve Docker socket: $unresolved_socket" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
if [ "$(stat -c '%h' "$docker_socket")" != 1 ]; then
|
|
72
|
+
printf '%s\n' 'UNALTRAWEB_DOCKER_SOCKET must not have hard-link aliases that could enter the consumer project.' >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
case "$docker_socket" in
|
|
76
|
+
"$project"|"$project"/*)
|
|
77
|
+
printf '%s\n' 'UNALTRAWEB_DOCKER_SOCKET must be outside the consumer project so the persistent preview cannot inherit it.' >&2
|
|
78
|
+
exit 1
|
|
79
|
+
;;
|
|
80
|
+
esac
|
|
81
|
+
fi
|
|
82
|
+
|
|
65
83
|
if ! resolved_image="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null)"; then
|
|
66
84
|
docker pull "$image" >/dev/null
|
|
67
85
|
resolved_image="$(docker image inspect --format '{{.Id}}' "$image")"
|
|
@@ -71,6 +71,23 @@ def make_value(path: Path, variable: str) -> str:
|
|
|
71
71
|
return match.group(1).strip('"\'') if match else ""
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def component_version_errors(contract: dict[str, Any]) -> list[str]:
|
|
75
|
+
"""Retain the real version of already-published, digest-pinned workers."""
|
|
76
|
+
version = str(contract["release"]["version"])
|
|
77
|
+
errors = []
|
|
78
|
+
reusable_workers = {"compute_python", "compute_r", "web_capture"}
|
|
79
|
+
for component_id in ["gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf"]:
|
|
80
|
+
selected = contract["components"][component_id]
|
|
81
|
+
if (component_id in reusable_workers and selected["kind"] == "container"
|
|
82
|
+
and selected["release_status"] == "released"):
|
|
83
|
+
# Digest/repository/version-tag semantics remain independently checked
|
|
84
|
+
# by component_contract_semantic_errors; this is not a mutable fallback.
|
|
85
|
+
continue
|
|
86
|
+
if str(selected["version"]) != version:
|
|
87
|
+
errors.append(f"{component_id} version does not match release {version}")
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
|
|
74
91
|
def validate(root: Path = ROOT) -> list[str]:
|
|
75
92
|
errors: list[str] = []
|
|
76
93
|
contract = distribution_contract()
|
|
@@ -87,9 +104,7 @@ def validate(root: Path = ROOT) -> list[str]:
|
|
|
87
104
|
errors.append("release-candidates.json must be excluded from Docker build contexts")
|
|
88
105
|
if __version__ != version:
|
|
89
106
|
errors.append(f"wheel version {__version__} != contract version {version}")
|
|
90
|
-
|
|
91
|
-
if str(contract["components"][component_id]["version"]) != version:
|
|
92
|
-
errors.append(f"{component_id} version does not match release {version}")
|
|
107
|
+
errors.extend(component_version_errors(contract))
|
|
93
108
|
included = {name for name, item in contract["components"].items() if item["included_in_wheel"]}
|
|
94
109
|
if included != {"wheel"}:
|
|
95
110
|
errors.append(f"wheel must not bundle external components: {sorted(included)}")
|
|
@@ -241,7 +256,7 @@ def validate(root: Path = ROOT) -> list[str]:
|
|
|
241
256
|
scaffold_templates = {
|
|
242
257
|
"Gemfile.tmpl": ["__GEM_VERSION__", "__CORE_REPOSITORY__", "__CORE_SHA__"],
|
|
243
258
|
"Gemfile.lock.tmpl": ["__GEM_VERSION__", "__CORE_REPOSITORY__", "__CORE_SHA__"],
|
|
244
|
-
"Makefile.tmpl": ["__MCP_IMAGE__"],
|
|
259
|
+
"Makefile.tmpl": ["__MCP_IMAGE__", "__MANUAL_PDF_IMAGE__"],
|
|
245
260
|
".github/workflows/deploy.yml.tmpl": [
|
|
246
261
|
"__SITE_DEPLOY_WORKFLOW__",
|
|
247
262
|
"__CORE_SHA__",
|