unaltraweb 0.3.0 → 0.4.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.
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env python3
2
+ """Authorize and stage exact language-package release candidates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
18
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
19
+ SEMVER_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
20
+ PACKAGE_WORKFLOW = ".github/workflows/package-prepare.yml"
21
+
22
+
23
+ def _require(condition: bool, message: str) -> None:
24
+ if not condition:
25
+ raise ValueError(message)
26
+
27
+
28
+ def _load_object(path: Path, label: str) -> dict[str, Any]:
29
+ _require(path.is_file() and not path.is_symlink(), f"{label} must be a regular file")
30
+
31
+ def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
32
+ result: dict[str, Any] = {}
33
+ for key, value in pairs:
34
+ _require(key not in result, f"{label} contains duplicate JSON key {key}")
35
+ result[key] = value
36
+ return result
37
+
38
+ def reject_constant(value: str) -> None:
39
+ raise ValueError(f"{label} contains non-finite JSON value {value}")
40
+
41
+ value = json.loads(
42
+ path.read_text(encoding="utf-8"),
43
+ object_pairs_hook=unique_object,
44
+ parse_constant=reject_constant,
45
+ )
46
+ _require(isinstance(value, dict), f"{label} must be a JSON object")
47
+ return value
48
+
49
+
50
+ def release_packages(receipt_path: Path, release_tag: str, receipt_sha256: str) -> dict[str, str]:
51
+ match = SEMVER_TAG.fullmatch(release_tag)
52
+ _require(match is not None, "release tag must be exact vX.Y.Z semver")
53
+ _require(SHA256.fullmatch(receipt_sha256) is not None, "receipt SHA-256 must be lowercase hexadecimal")
54
+ _require(receipt_path.is_file() and not receipt_path.is_symlink(),
55
+ "release receipt must be a regular file")
56
+ _require(_sha256(receipt_path) == receipt_sha256, "release receipt SHA-256 does not match the request")
57
+ version = release_tag.removeprefix("v")
58
+ receipt = _load_object(receipt_path, "release receipt")
59
+ _require(set(receipt) == {"schema_version", "release", "source_commit", "components"},
60
+ "release receipt has unexpected fields")
61
+ _require(receipt.get("schema_version") == 1, "release receipt schema_version must be 1")
62
+ _require(receipt.get("release") == release_tag, "release receipt tag does not match the request")
63
+ source_commit = receipt.get("source_commit")
64
+ _require(isinstance(source_commit, str) and FULL_SHA.fullmatch(source_commit) is not None,
65
+ "release receipt source_commit must be a full lowercase SHA")
66
+ components = receipt.get("components")
67
+ _require(isinstance(components, dict), "release receipt components must be an object")
68
+
69
+ expected_names = {
70
+ "gem": f"unaltraweb-{version}.gem",
71
+ "wheel": f"unaltraweb_mcp-{version}-py3-none-any.whl",
72
+ }
73
+ result = {
74
+ "version": version,
75
+ "source_commit": source_commit,
76
+ }
77
+ for component, expected_name in expected_names.items():
78
+ candidate = components.get(component)
79
+ _require(isinstance(candidate, dict), f"release receipt has no {component} candidate")
80
+ _require(set(candidate) == {"artifact", "sha256"},
81
+ f"release receipt {component} candidate has unexpected fields")
82
+ _require(candidate.get("artifact") == expected_name,
83
+ f"release receipt {component} artifact does not match version {version}")
84
+ digest = candidate.get("sha256")
85
+ _require(isinstance(digest, str) and SHA256.fullmatch(digest) is not None,
86
+ f"release receipt {component} sha256 must be lowercase hexadecimal")
87
+ result[f"{component}_artifact"] = expected_name
88
+ result[f"{component}_sha256"] = digest
89
+ return result
90
+
91
+
92
+ def _git(repository_dir: Path, *arguments: str) -> str:
93
+ completed = subprocess.run(
94
+ ["git", *arguments],
95
+ cwd=repository_dir,
96
+ text=True,
97
+ stdout=subprocess.PIPE,
98
+ stderr=subprocess.PIPE,
99
+ check=False,
100
+ )
101
+ if completed.returncode != 0:
102
+ detail = completed.stderr.strip() or completed.stdout.strip()
103
+ raise ValueError(f"git {' '.join(arguments)} failed: {detail}")
104
+ return completed.stdout.strip()
105
+
106
+
107
+ def validate_release_checkout(
108
+ repository_dir: Path,
109
+ receipt_path: Path,
110
+ release_tag: str,
111
+ tag_object: str,
112
+ receipt_sha256: str,
113
+ default_branch: str,
114
+ ) -> dict[str, str]:
115
+ _require(repository_dir.is_dir() and not repository_dir.is_symlink(),
116
+ "release checkout must be a real directory")
117
+ _require(receipt_path.parent.resolve() == repository_dir.resolve(),
118
+ "release receipt must be at the checkout root")
119
+ _require(FULL_SHA.fullmatch(tag_object) is not None, "tag object must be a full lowercase SHA")
120
+ packages = release_packages(receipt_path, release_tag, receipt_sha256)
121
+ tag_ref = f"refs/tags/{release_tag}"
122
+ _require(_git(repository_dir, "cat-file", "-t", tag_ref) == "tag",
123
+ "release reference must be an annotated tag")
124
+ _require(_git(repository_dir, "rev-parse", tag_ref) == tag_object,
125
+ "release tag object does not match the reviewed request")
126
+ release_commit = _git(repository_dir, "rev-parse", f"{tag_ref}^{{commit}}")
127
+ _require(_git(repository_dir, "rev-parse", "HEAD") == release_commit,
128
+ "release checkout does not match the tag target")
129
+ _git(
130
+ repository_dir,
131
+ "diff",
132
+ "--exit-code",
133
+ "HEAD",
134
+ "--",
135
+ "release-candidates.json",
136
+ "src/unaltraweb_mcp/component-contract.json",
137
+ )
138
+ source_commit = _git(repository_dir, "rev-parse", "HEAD^1")
139
+ _require(packages["source_commit"] == source_commit,
140
+ "release receipt source must be the tag target's first parent")
141
+ changed_paths = [
142
+ line
143
+ for line in _git(repository_dir, "diff", "--name-only", source_commit, "HEAD").splitlines()
144
+ if line
145
+ ]
146
+ _require(changed_paths == ["release-candidates.json"],
147
+ "release tag target may change only release-candidates.json from its first parent")
148
+ ancestor = subprocess.run(
149
+ ["git", "merge-base", "--is-ancestor", source_commit, f"origin/{default_branch}"],
150
+ cwd=repository_dir,
151
+ stdout=subprocess.PIPE,
152
+ stderr=subprocess.PIPE,
153
+ check=False,
154
+ )
155
+ _require(ancestor.returncode == 0, "release receipt source does not belong to the default branch")
156
+
157
+ contract_path = repository_dir / "src/unaltraweb_mcp/component-contract.json"
158
+ contract = _load_object(contract_path, "component contract")
159
+ _require(contract.get("schema_version") == 1, "component contract schema_version must be 1")
160
+ release = contract.get("release")
161
+ _require(isinstance(release, dict), "component contract release must be an object")
162
+ _require(release.get("tag") == release_tag and release.get("version") == packages["version"],
163
+ "component contract release does not match the reviewed tag")
164
+ components = contract.get("components")
165
+ _require(isinstance(components, dict), "component contract components must be an object")
166
+ receipt = _load_object(receipt_path, "release receipt")
167
+ candidates = receipt.get("components")
168
+ _require(isinstance(candidates, dict), "release receipt components must be an object")
169
+ ready_components: dict[str, dict[str, Any]] = {}
170
+ for component_id, component in components.items():
171
+ _require(isinstance(component_id, str) and isinstance(component, dict),
172
+ "component contract entries must be objects")
173
+ status = component.get("release_status")
174
+ _require(status in {"ready", "released"},
175
+ f"component {component_id} is not release-ready")
176
+ if status == "ready":
177
+ _require(component.get("version") == packages["version"] and component.get("release") == release_tag,
178
+ f"ready component {component_id} does not match the reviewed release")
179
+ ready_components[component_id] = component
180
+ _require(set(candidates) == set(ready_components),
181
+ "release receipt must contain exactly every ready component")
182
+ for component_id, component in ready_components.items():
183
+ candidate = candidates.get(component_id)
184
+ _require(isinstance(candidate, dict), f"release receipt candidate {component_id} must be an object")
185
+ kind = component.get("kind")
186
+ if kind == "container":
187
+ repository = component.get("image_repository")
188
+ reference = candidate.get("reference")
189
+ _require(isinstance(repository, str) and isinstance(reference, str) and
190
+ re.fullmatch(rf"{re.escape(repository)}@sha256:[0-9a-f]{{64}}", reference) is not None,
191
+ f"release receipt candidate {component_id} must use its immutable image repository")
192
+ _require(set(candidate) == {"reference"},
193
+ f"release receipt container {component_id} has unexpected fields")
194
+ elif kind in {"gem", "python-wheel"}:
195
+ expected_artifact = (
196
+ f"{component.get('name')}-{component.get('version')}.gem"
197
+ if kind == "gem"
198
+ else f"{str(component.get('name')).replace('-', '_')}-{component.get('version')}-py3-none-any.whl"
199
+ )
200
+ _require(candidate.get("artifact") == expected_artifact,
201
+ f"release receipt package {component_id} has an unexpected artifact")
202
+ candidate_sha = candidate.get("sha256")
203
+ _require(isinstance(candidate_sha, str) and SHA256.fullmatch(candidate_sha) is not None,
204
+ f"release receipt package {component_id} has an invalid SHA-256")
205
+ _require(set(candidate) == {"artifact", "sha256"},
206
+ f"release receipt package {component_id} has unexpected fields")
207
+ else:
208
+ raise ValueError(f"ready component {component_id} has unsupported kind {kind}")
209
+
210
+ return {
211
+ "receipt-sha256": receipt_sha256,
212
+ "release-commit": release_commit,
213
+ "source-commit": source_commit,
214
+ "tag-object": tag_object,
215
+ }
216
+
217
+
218
+ def authorize_run(
219
+ receipt_path: Path,
220
+ release_tag: str,
221
+ receipt_sha256: str,
222
+ run_id: int,
223
+ run_json_path: Path,
224
+ artifacts_json_path: Path,
225
+ repository: str,
226
+ default_branch: str,
227
+ ) -> dict[str, str]:
228
+ packages = release_packages(receipt_path, release_tag, receipt_sha256)
229
+ run = _load_object(run_json_path, "workflow run")
230
+ _require(run_id > 0 and run.get("id") == run_id, "workflow run ID does not match the request")
231
+ _require(run.get("path") == PACKAGE_WORKFLOW, "candidate run used an unexpected workflow path")
232
+ _require(run.get("event") == "workflow_dispatch", "candidate run was not manually dispatched")
233
+ _require(run.get("status") == "completed" and run.get("conclusion") == "success",
234
+ "candidate run did not complete successfully")
235
+ _require(run.get("head_sha") == packages["source_commit"],
236
+ "candidate run source does not match release receipt")
237
+ _require(run.get("head_branch") == default_branch, "candidate run did not use the default branch")
238
+ run_repository = run.get("repository")
239
+ head_repository = run.get("head_repository")
240
+ _require(isinstance(run_repository, dict) and run_repository.get("full_name") == repository,
241
+ "candidate run belongs to an unexpected repository")
242
+ _require(isinstance(head_repository, dict) and head_repository.get("full_name") == repository,
243
+ "candidate run head belongs to an unexpected repository")
244
+
245
+ payload = _load_object(artifacts_json_path, "workflow artifacts")
246
+ artifacts = payload.get("artifacts")
247
+ _require(isinstance(artifacts, list), "workflow artifacts must contain an artifact list")
248
+ _require(payload.get("total_count") == len(artifacts) == 1,
249
+ "candidate run must contain exactly one artifact")
250
+ artifact = artifacts[0]
251
+ _require(isinstance(artifact, dict), "candidate artifact must be an object")
252
+ expected_name = f"unaltraweb-{packages['version']}-{packages['source_commit']}"
253
+ _require(artifact.get("name") == expected_name, "candidate artifact name does not match the receipt")
254
+ _require(artifact.get("expired") is False, "candidate artifact has expired")
255
+ artifact_id = artifact.get("id")
256
+ _require(isinstance(artifact_id, int) and artifact_id > 0, "candidate artifact ID is invalid")
257
+ digest = artifact.get("digest")
258
+ _require(isinstance(digest, str) and digest.startswith("sha256:") and
259
+ SHA256.fullmatch(digest.removeprefix("sha256:")) is not None,
260
+ "candidate artifact archive has no SHA-256 digest")
261
+ artifact_run = artifact.get("workflow_run")
262
+ _require(isinstance(artifact_run, dict), "candidate artifact has no workflow run identity")
263
+ _require(artifact_run.get("id") == run_id, "candidate artifact belongs to another workflow run")
264
+ _require(artifact_run.get("head_sha") == packages["source_commit"],
265
+ "candidate artifact source does not match release receipt")
266
+ _require(artifact_run.get("head_branch") == default_branch,
267
+ "candidate artifact did not originate on the default branch")
268
+
269
+ return {
270
+ "artifact-id": str(artifact_id),
271
+ "artifact-name": expected_name,
272
+ "gem-artifact": packages["gem_artifact"],
273
+ "version": packages["version"],
274
+ "wheel-artifact": packages["wheel_artifact"],
275
+ }
276
+
277
+
278
+ def _sha256(path: Path) -> str:
279
+ digest = hashlib.sha256()
280
+ with path.open("rb") as handle:
281
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
282
+ digest.update(chunk)
283
+ return digest.hexdigest()
284
+
285
+
286
+ def stage_candidates(
287
+ receipt_path: Path,
288
+ release_tag: str,
289
+ receipt_sha256: str,
290
+ candidate_dir: Path,
291
+ output_dir: Path,
292
+ ) -> dict[str, str]:
293
+ packages = release_packages(receipt_path, release_tag, receipt_sha256)
294
+ _require(candidate_dir.is_dir() and not candidate_dir.is_symlink(),
295
+ "candidate directory must be a real directory")
296
+ entries = list(candidate_dir.iterdir())
297
+ _require(all(entry.is_file() and not entry.is_symlink() for entry in entries),
298
+ "candidate directory may contain only regular files")
299
+ expected_names = {
300
+ "SHA256SUMS",
301
+ packages["gem_artifact"],
302
+ packages["wheel_artifact"],
303
+ }
304
+ _require({entry.name for entry in entries} == expected_names,
305
+ "candidate directory inventory does not match the release receipt")
306
+
307
+ for component in ["gem", "wheel"]:
308
+ artifact = candidate_dir / packages[f"{component}_artifact"]
309
+ _require(_sha256(artifact) == packages[f"{component}_sha256"],
310
+ f"{component} candidate SHA-256 does not match the release receipt")
311
+
312
+ expected_sums = (
313
+ f"{packages['gem_sha256']} ./{packages['gem_artifact']}\n"
314
+ f"{packages['wheel_sha256']} ./{packages['wheel_artifact']}\n"
315
+ )
316
+ _require((candidate_dir / "SHA256SUMS").read_text(encoding="utf-8") == expected_sums,
317
+ "SHA256SUMS does not exactly match the release receipt")
318
+
319
+ _require(not output_dir.is_symlink(), "staging output must not be a symlink")
320
+ if output_dir.exists():
321
+ _require(output_dir.is_dir() and not any(output_dir.iterdir()),
322
+ "staging output must be absent or empty")
323
+ pypi_dir = output_dir / "pypi"
324
+ rubygems_dir = output_dir / "rubygems"
325
+ pypi_dir.mkdir(parents=True)
326
+ rubygems_dir.mkdir(parents=True)
327
+ wheel_target = pypi_dir / packages["wheel_artifact"]
328
+ gem_target = rubygems_dir / packages["gem_artifact"]
329
+ shutil.copyfile(candidate_dir / packages["wheel_artifact"], wheel_target)
330
+ shutil.copyfile(candidate_dir / packages["gem_artifact"], gem_target)
331
+ return {"gem": str(gem_target), "wheel": str(wheel_target)}
332
+
333
+
334
+ def _write_github_outputs(path: Path, values: dict[str, str]) -> None:
335
+ with path.open("a", encoding="utf-8") as handle:
336
+ for key, value in sorted(values.items()):
337
+ _require("\n" not in key and "\n" not in value, "GitHub output values must be single-line")
338
+ handle.write(f"{key}={value}\n")
339
+
340
+
341
+ def build_parser() -> argparse.ArgumentParser:
342
+ parser = argparse.ArgumentParser(description=__doc__)
343
+ commands = parser.add_subparsers(dest="command", required=True)
344
+
345
+ release = commands.add_parser("release", help="Validate the immutable release checkout and receipt.")
346
+ release.add_argument("--repository-dir", type=Path, required=True)
347
+ release.add_argument("--receipt", type=Path, required=True)
348
+ release.add_argument("--release-tag", required=True)
349
+ release.add_argument("--tag-object", required=True)
350
+ release.add_argument("--receipt-sha256", required=True)
351
+ release.add_argument("--default-branch", required=True)
352
+
353
+ authorize = commands.add_parser("authorize", help="Authorize one package candidate workflow artifact.")
354
+ authorize.add_argument("--receipt", type=Path, required=True)
355
+ authorize.add_argument("--release-tag", required=True)
356
+ authorize.add_argument("--receipt-sha256", required=True)
357
+ authorize.add_argument("--run-id", type=int, required=True)
358
+ authorize.add_argument("--run-json", type=Path, required=True)
359
+ authorize.add_argument("--artifacts-json", type=Path, required=True)
360
+ authorize.add_argument("--repository", required=True)
361
+ authorize.add_argument("--default-branch", required=True)
362
+ authorize.add_argument("--github-output", type=Path)
363
+
364
+ stage = commands.add_parser("stage", help="Verify and split exact package files for privileged jobs.")
365
+ stage.add_argument("--receipt", type=Path, required=True)
366
+ stage.add_argument("--release-tag", required=True)
367
+ stage.add_argument("--receipt-sha256", required=True)
368
+ stage.add_argument("--candidate-dir", type=Path, required=True)
369
+ stage.add_argument("--output-dir", type=Path, required=True)
370
+ return parser
371
+
372
+
373
+ def main(argv: list[str] | None = None) -> int:
374
+ args = build_parser().parse_args(argv)
375
+ try:
376
+ if args.command == "release":
377
+ result = validate_release_checkout(
378
+ args.repository_dir,
379
+ args.receipt,
380
+ args.release_tag,
381
+ args.tag_object,
382
+ args.receipt_sha256,
383
+ args.default_branch,
384
+ )
385
+ elif args.command == "authorize":
386
+ result = authorize_run(
387
+ args.receipt,
388
+ args.release_tag,
389
+ args.receipt_sha256,
390
+ args.run_id,
391
+ args.run_json,
392
+ args.artifacts_json,
393
+ args.repository,
394
+ args.default_branch,
395
+ )
396
+ if args.github_output:
397
+ _write_github_outputs(args.github_output, result)
398
+ else:
399
+ result = stage_candidates(
400
+ args.receipt,
401
+ args.release_tag,
402
+ args.receipt_sha256,
403
+ args.candidate_dir,
404
+ args.output_dir,
405
+ )
406
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
407
+ print(f"error: {exc}", file=sys.stderr)
408
+ return 1
409
+ print(json.dumps(result, indent=2, sort_keys=True))
410
+ return 0
411
+
412
+
413
+ if __name__ == "__main__":
414
+ raise SystemExit(main())
@@ -37,7 +37,7 @@ except ImportError as exc: # pragma: no cover - supplied by project tooling
37
37
 
38
38
  LOCK_PATH = Path(".unaltraweb/web-captures.lock.json")
39
39
  SOURCE_SUFFIXES = (".capture.yml", ".capture.yaml")
40
- DEFAULT_IMAGE = "ghcr.io/dosquartsdedocs/unaltraweb-web-capture:0.3.0"
40
+ DEFAULT_IMAGE = "ghcr.io/dosquartsdedocs/unaltraweb-web-capture@sha256:0bf1bc67fe63e1440bffe708a168beefa11c54441650a871ab99380d362f7c1e"
41
41
  DEFAULT_VIEWPORT = {"width": 1440, "height": 900, "device_scale_factor": 1}
42
42
  CAPTURE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
43
43
  SAFE_SOURCE_RE = re.compile(r"^[A-Za-z0-9_./-]+\.capture\.ya?ml$")
@@ -2,15 +2,15 @@
2
2
  "$schema": "component-contract.schema.json",
3
3
  "schema_version": 1,
4
4
  "release": {
5
- "version": "0.3.0",
6
- "tag": "v0.3.0"
5
+ "version": "0.4.0",
6
+ "tag": "v0.4.0"
7
7
  },
8
8
  "consumer_integration": {
9
9
  "schema_version": 1,
10
10
  "core_repository": "https://github.com/dosquartsdedocs/unaltraweb.git",
11
- "core_sha": "c54400927e7223e14e34390ab039ed94b2e974ad",
11
+ "core_sha": "f7ac29070167917dec6b314ba0a1ba9db089e7ec",
12
12
  "site_deploy_workflow": "dosquartsdedocs/unaltraweb/.github/workflows/site-deploy.yml",
13
- "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:48a7e17a85d205e4a890b7b7de18c9015eb657c9c12ba10f7cff123ac2b80660",
13
+ "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:bb3e373f8a512495eeed684c23ad904d298c2e20a6dc3ade0d0a60c7ec9a9f11",
14
14
  "vegavisuals_sha": "44a2753ffbb7c0694a459db9744342f69c6e78b9"
15
15
  },
16
16
  "receipt_contract": {
@@ -28,18 +28,18 @@
28
28
  "role": "Modular control, scaffolding, and offline inspection plane for unaltraweb sites.",
29
29
  "package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
30
30
  "factory_required_commands": ["factory-dir"],
31
- "package_only_mcp": ["bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "html-audit", "http-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
32
- "factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-publish", "manual-pdf-status", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
31
+ "package_only_mcp": ["bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "html-audit", "http-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
32
+ "factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
33
33
  "not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"]
34
34
  },
35
35
  "components": {
36
36
  "gem": {
37
37
  "kind": "gem",
38
38
  "name": "unaltraweb",
39
- "version": "0.3.0",
40
- "release": "v0.3.0",
39
+ "version": "0.4.0",
40
+ "release": "v0.4.0",
41
41
  "release_status": "ready",
42
- "reference": "unaltraweb (= 0.3.0)",
42
+ "reference": "unaltraweb (= 0.4.0)",
43
43
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
44
44
  "included_in_wheel": false,
45
45
  "features": ["jekyll-core", "site-build"]
@@ -47,10 +47,10 @@
47
47
  "wheel": {
48
48
  "kind": "python-wheel",
49
49
  "name": "unaltraweb-mcp",
50
- "version": "0.3.0",
51
- "release": "v0.3.0",
50
+ "version": "0.4.0",
51
+ "release": "v0.4.0",
52
52
  "release_status": "ready",
53
- "reference": "unaltraweb-mcp==0.3.0",
53
+ "reference": "unaltraweb-mcp==0.4.0",
54
54
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
55
55
  "included_in_wheel": true,
56
56
  "features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane"]
@@ -58,10 +58,10 @@
58
58
  "runtime": {
59
59
  "kind": "container",
60
60
  "name": "unaltraweb runtime",
61
- "version": "0.3.0",
62
- "release": "v0.3.0",
61
+ "version": "0.4.0",
62
+ "release": "v0.4.0",
63
63
  "release_status": "ready",
64
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.3.0",
64
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.4.0",
65
65
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
66
66
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
67
67
  "included_in_wheel": false,
@@ -70,10 +70,10 @@
70
70
  "mcp": {
71
71
  "kind": "container",
72
72
  "name": "unaltraweb MCP runtime",
73
- "version": "0.3.0",
74
- "release": "v0.3.0",
73
+ "version": "0.4.0",
74
+ "release": "v0.4.0",
75
75
  "release_status": "ready",
76
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.3.0",
76
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.4.0",
77
77
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
78
78
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
79
79
  "included_in_wheel": false,
@@ -82,8 +82,8 @@
82
82
  "compute_python": {
83
83
  "kind": "container",
84
84
  "name": "unaltraweb Python computation worker",
85
- "version": "0.3.0",
86
- "release": "v0.3.0",
85
+ "version": "0.4.0",
86
+ "release": "v0.4.0",
87
87
  "release_status": "released",
88
88
  "reference": "ghcr.io/dosquartsdedocs/unaltraweb-compute-python@sha256:18cb269811bd4005800382da25a480ec2bca7eac8d0501ad1ef36bad1c0f8cd9",
89
89
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-compute-python",
@@ -94,8 +94,8 @@
94
94
  "compute_r": {
95
95
  "kind": "container",
96
96
  "name": "unaltraweb R computation worker",
97
- "version": "0.3.0",
98
- "release": "v0.3.0",
97
+ "version": "0.4.0",
98
+ "release": "v0.4.0",
99
99
  "release_status": "released",
100
100
  "reference": "ghcr.io/dosquartsdedocs/unaltraweb-compute-r@sha256:928ffb93f221e09e8b929157dee473b838e061915a2eb67224e4124b85f81837",
101
101
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-compute-r",
@@ -106,10 +106,10 @@
106
106
  "web_capture": {
107
107
  "kind": "container",
108
108
  "name": "unaltraweb web capture worker",
109
- "version": "0.3.0",
110
- "release": "v0.3.0",
111
- "release_status": "ready",
112
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-web-capture:0.3.0",
109
+ "version": "0.4.0",
110
+ "release": "v0.4.0",
111
+ "release_status": "released",
112
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-web-capture@sha256:0bf1bc67fe63e1440bffe708a168beefa11c54441650a871ab99380d362f7c1e",
113
113
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-web-capture",
114
114
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
115
115
  "included_in_wheel": false,
@@ -118,10 +118,10 @@
118
118
  "manual_pdf": {
119
119
  "kind": "container",
120
120
  "name": "unaltraweb manual PDF worker",
121
- "version": "0.3.0",
122
- "release": "v0.3.0",
121
+ "version": "0.4.0",
122
+ "release": "v0.4.0",
123
123
  "release_status": "ready",
124
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:0.3.0",
124
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:0.4.0",
125
125
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf",
126
126
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
127
127
  "included_in_wheel": false,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unaltraweb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dosquartsdedocs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: classifier-reborn
@@ -827,6 +827,7 @@ files:
827
827
  - scripts/unaltraweb-mcp-project-id.sh
828
828
  - scripts/validate_distribution.py
829
829
  - scripts/validate_workflows.py
830
+ - scripts/verify_package_publish.py
830
831
  - scripts/web_captures/Dockerfile
831
832
  - scripts/web_captures/capture.mjs
832
833
  - scripts/web_captures/package-lock.json