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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +57 -17
  3. data/README.md +47 -14
  4. data/_plugins/figure_captions.rb +47 -10
  5. data/_sass/_documentation.scss +7 -5
  6. data/_sass/_manual.scss +7 -0
  7. data/docs/_documentation/en/02-tools.md +4 -4
  8. data/docs/_documentation/en/03-usage.md +62 -1
  9. data/docs/_documentation/en/06-github-web-editing.md +1 -1
  10. data/docs/_documentation/en/13-unaltremanual.md +12 -5
  11. data/docs/_documentation/en/20-syntax.md +14 -0
  12. data/docs/_documentation/en/25-caption-credits.md +120 -0
  13. data/docs/_documentation/en/26-image-backgrounds.md +103 -0
  14. data/docs/_documentation/en/31-template.md +1 -1
  15. data/docs/_documentation/en/32-development.md +1 -1
  16. data/docs/_documentation/en/40-distribution.md +60 -23
  17. data/docs/_documentation/en/42-docker-image.md +21 -11
  18. data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
  19. data/docs/_documentation/en/44-editorial-review.md +237 -0
  20. data/docs/agents/action-prompts/00-start-site-session.txt +14 -7
  21. data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
  22. data/docs/agents/manual-authoring-components.md +38 -0
  23. data/docs/agents/mcp-contract.md +112 -14
  24. data/docs/agents/visual-companions-0.4.0.md +74 -0
  25. data/docs/assets/img/caption-credits-demo.svg +19 -0
  26. data/scripts/editorial_check.py +12 -0
  27. data/scripts/image_background_check.py +12 -0
  28. data/scripts/manual/build_pdf.py +94 -16
  29. data/scripts/manual/filters/figure-captions.lua +65 -10
  30. data/scripts/manual/templates/manual.tex +6 -1
  31. data/scripts/test_gem_build.py +32 -2
  32. data/scripts/test_reproducible_jekyll_build.py +1 -1
  33. data/scripts/test_wheel_install.py +75 -5
  34. data/scripts/unaltraweb-mcp-bootstrap.sh +19 -1
  35. data/scripts/validate_distribution.py +19 -4
  36. data/scripts/validate_workflows.py +290 -5
  37. data/scripts/verify_package_publish.py +414 -0
  38. data/scripts/web_captures/render.py +1 -1
  39. data/src/unaltraweb_mcp/component-contract.json +37 -37
  40. data/src/unaltraweb_mcp/editorial.py +495 -0
  41. data/src/unaltraweb_mcp/editorial_sources.py +504 -0
  42. data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
  43. data/src/unaltraweb_mcp/image_probe.py +149 -0
  44. data/src/unaltraweb_mcp/processes.py +146 -0
  45. metadata +16 -2
@@ -4,22 +4,53 @@ local function append_all(destination, values)
4
4
  end
5
5
  end
6
6
 
7
- function Figure(figure)
8
- if not FORMAT:match("latex") or #figure.caption.long == 0 then
9
- return nil
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(caption, pandoc.Space())
11
+ table.insert(result, pandoc.Space())
16
12
  end
17
13
  if block.content then
18
- append_all(caption, block.content)
14
+ append_all(result, block.content)
19
15
  else
20
- table.insert(caption, pandoc.Str(pandoc.utils.stringify(block)))
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
- \captionsetup[table]{position=top,margin=1em,aboveskip=0pt,belowskip=7pt}
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},
@@ -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 != 0:
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.3.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 editor per file",
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
- "dedicated Git worktree",
162
- "Never share a mutable checkout",
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
- "remove the worktree",
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.3.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
- for component_id in ["gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf"]:
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__",