unaltraweb 0.7.0 → 0.7.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +27 -5
  3. data/docs/_documentation/en/33-reusable-workflows.md +28 -3
  4. data/docs/_documentation/en/40-distribution.md +57 -19
  5. data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
  6. data/docs/_documentation/en/46-live-runtime.md +6 -3
  7. data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
  8. data/docs/agents/consolidation-0.7.2.md +97 -0
  9. data/docs/agents/d0-runtime-identity.md +22 -5
  10. data/docs/agents/deploy-provenance-86.md +86 -0
  11. data/docs/agents/deployment-integration-0.7.1.md +142 -0
  12. data/docs/agents/manual-authoring-components.md +23 -0
  13. data/docs/agents/mcp-contract.md +112 -6
  14. data/docs/agents/owner-closeout-85.md +269 -0
  15. data/docs/agents/owner-closeout-93.md +185 -0
  16. data/docs/agents/practice-editions-98.md +816 -0
  17. data/docs/agents/practice-pdf-installable-98.md +250 -0
  18. data/docs/agents/practice-runtime-archives-98.md +147 -0
  19. data/docs/agents/private-practice-verifier-98.md +296 -0
  20. data/docs/agents/source-ci-98.md +148 -0
  21. data/scripts/check_practice_verifier_runtime.py +69 -0
  22. data/scripts/export_practice_runtime_archives.py +138 -0
  23. data/scripts/manual/filters/practice-finish.lua +36 -0
  24. data/scripts/manual/filters/practice-layout.lua +84 -0
  25. data/scripts/manual/practice_pdf.py +31 -0
  26. data/scripts/manual/render_practice.py +125 -0
  27. data/scripts/manual/templates/practice.tex +95 -0
  28. data/scripts/prepare_practice_candidate.py +159 -0
  29. data/scripts/prepare_practice_editions_candidate.py +119 -0
  30. data/scripts/test_wheel_install.py +16 -1
  31. data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
  32. data/scripts/validate_distribution.py +51 -5
  33. data/scripts/validate_workflows.py +33 -4
  34. data/src/unaltraweb_mcp/component-contract.json +59 -22
  35. data/src/unaltraweb_mcp/component-contract.schema.json +35 -2
  36. data/src/unaltraweb_mcp/distribution.py +42 -2
  37. data/src/unaltraweb_mcp/editorial_sources.py +62 -4
  38. metadata +22 -2
@@ -103,6 +103,10 @@ def main() -> int:
103
103
  "unaltraweb_mcp/editorial_sources.py",
104
104
  "unaltraweb_mcp/image_backgrounds.py",
105
105
  "unaltraweb_mcp/image_probe.py",
106
+ "unaltraweb_mcp/source_checks.py",
107
+ "unaltraweb_mcp/source-check-policy-v1.schema.json",
108
+ "unaltraweb_mcp/practice_verifier.py",
109
+ "unaltraweb_mcp/practice-verifier-runtime.json",
106
110
  "unaltraweb_mcp/scaffolds/common/AGENTS.md.tmpl",
107
111
  "unaltraweb_mcp/scaffolds/common/Makefile.tmpl",
108
112
  "unaltraweb_mcp/scaffolds/common/README.md.tmpl",
@@ -117,6 +121,7 @@ def main() -> int:
117
121
  "unaltraweb_mcp/scaffolds/common/.github/dependabot.yml.tmpl",
118
122
  "unaltraweb_mcp/scaffolds/common/.github/pull_request_template.md",
119
123
  "unaltraweb_mcp/scaffolds/common/.github/workflows/deploy.yml.tmpl",
124
+ "unaltraweb_mcp/scaffolds/common/.github/workflows/source-checks.yml.tmpl",
120
125
  ]
121
126
  missing = [name for name in required if name not in names]
122
127
  if missing:
@@ -129,6 +134,10 @@ def main() -> int:
129
134
  python = environment / "bin/python"
130
135
  cli = environment / "bin/unaltraweb-mcp"
131
136
  run([str(python), "-m", "pip", "install", "--no-deps", str(wheels[0])], cwd=temp, env=env)
137
+ source_cli = environment / "bin/unaltraweb-source-checks"
138
+ source_help = run([str(source_cli), "--help"], cwd=temp).stdout
139
+ if "--base-sha" not in source_help or "--reports" not in source_help:
140
+ raise RuntimeError("Installed source-CI entrypoint is missing its bounded interface")
132
141
  docker_cli = environment / "bin/unaltraweb-mcp-docker"
133
142
  launcher = Path(run([str(docker_cli), "path"], cwd=temp).stdout.strip())
134
143
  if launcher != environment / "share/unaltraweb-launcher":
@@ -178,6 +187,11 @@ def main() -> int:
178
187
  ], cwd=temp).stdout)
179
188
  if not created["ok"]:
180
189
  raise RuntimeError(f"new-web failed for {profile} from clean wheel: {created}")
190
+ source_policy = json.loads((profile_site / ".unaltraweb/source-checks.json").read_bytes())
191
+ if source_policy["profile"] != profile:
192
+ raise RuntimeError("Source-CI policy did not declare the generated profile")
193
+ if not (profile_site / ".github/workflows/source-checks.yml").is_file():
194
+ raise RuntimeError("Source-CI workflow control is missing")
181
195
  readme = (profile_site / "README.md").read_text(encoding="utf-8")
182
196
  agents = (profile_site / "AGENTS.md").read_text(encoding="utf-8")
183
197
  collaboration = (profile_site / ".github/CONTRIBUTING.md").read_text(encoding="utf-8")
@@ -290,7 +304,7 @@ def main() -> int:
290
304
  raise RuntimeError(f"generated {profile} Gemfile does not use the reviewed core revision")
291
305
  if f'revision: {integration["core_sha"]}' not in lockfile:
292
306
  raise RuntimeError(f"generated {profile} lockfile does not use the reviewed core revision")
293
- if f'uses: {integration["site_deploy_workflow"]}@{integration["core_sha"]}' not in deploy:
307
+ if f'uses: {integration["site_deploy_workflow"]}@{integration["site_deploy_workflow_sha"]}' not in deploy:
294
308
  raise RuntimeError(f"generated {profile} deploy caller does not use the reviewed core revision")
295
309
  if f'manual-pdf-image: "{integration["manual_pdf_image"]}"' not in deploy:
296
310
  raise RuntimeError(f"generated {profile} deploy caller does not use the reviewed PDF image")
@@ -317,6 +331,7 @@ def main() -> int:
317
331
  "Gemfile.lock",
318
332
  ".github/pull_request_template.md",
319
333
  ".github/workflows/deploy.yml",
334
+ ".github/workflows/source-checks.yml",
320
335
  }
321
336
  if set(manifest["files"]) != expected_managed:
322
337
  raise RuntimeError(f"generated {profile} scaffold baseline is incomplete: {manifest['files']}")
@@ -15,7 +15,7 @@ Managed mode requires an immutable reference or an expected full image ID.
15
15
  USAGE
16
16
  }
17
17
 
18
- image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:736c4ddd0a543454e3edaeac2e9cfd97a1279ce472923ac54e326c1281b2ba05}"
18
+ image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:6aa20cff86c3891a876e37ba0548fa36f3c5633c9f22ba672e9f50123d677cf6}"
19
19
  project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
20
20
  operation=serve
21
21
  expected_image="${UNALTRAWEB_EXPECTED_IMAGE_ID:-}"
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import hashlib
7
8
  import json
8
9
  import os
9
10
  import re
@@ -39,7 +40,8 @@ from unaltraweb_mcp.distribution import ( # noqa: E402
39
40
  is_mutable_reference,
40
41
  validate_component_contract,
41
42
  )
42
- from scripts.validate_workflows import load_workflow_text, reusable_deploy_input_errors # noqa: E402
43
+ from scripts.validate_workflows import DEPLOY_PDF_PROVENANCE_POLICY_SHA256, load_workflow_text, reusable_deploy_input_errors # noqa: E402
44
+ from scripts import check_practice_verifier_runtime as practice_verifier # noqa: E402
43
45
 
44
46
 
45
47
  def load_yaml(path: Path) -> dict[str, Any]:
@@ -88,6 +90,21 @@ def component_version_errors(contract: dict[str, Any]) -> list[str]:
88
90
  return errors
89
91
 
90
92
 
93
+ def selected_deployment_errors(contract: dict[str, Any], workflow: dict[str, Any], provider_contract: dict[str, Any]) -> list[str]:
94
+ """Release readiness binds the called workflow's code and data, not local YAML."""
95
+ errors = []
96
+ steps = workflow.get("jobs", {}).get("build", {}).get("steps", [])
97
+ step = next((row for row in steps if row.get("name") == "Verify manual PDF image provenance"), {})
98
+ if hashlib.sha256(json.dumps(step, sort_keys=True, separators=(",", ":")).encode()).hexdigest() != DEPLOY_PDF_PROVENANCE_POLICY_SHA256:
99
+ errors.append("selected deployment workflow does not contain the reviewed contract-driven provenance gate")
100
+ reference = contract["consumer_integration"]["manual_pdf_image"]
101
+ expected = [row for row in contract.get("deployment_contract", {}).get("manual_pdf_workers", []) if row.get("reference") == reference]
102
+ actual = [row for row in provider_contract.get("deployment_contract", {}).get("manual_pdf_workers", []) if row.get("reference") == reference]
103
+ if len(expected) != 1 or actual != expected:
104
+ errors.append("selected deployment workflow's producer record differs from the package's PDF identity")
105
+ return errors
106
+
107
+
91
108
  def validate(root: Path = ROOT) -> list[str]:
92
109
  errors: list[str] = []
93
110
  contract = distribution_contract()
@@ -95,6 +112,9 @@ def validate(root: Path = ROOT) -> list[str]:
95
112
  required_components = {
96
113
  "gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"
97
114
  }
115
+ if practice_verifier.COMPONENT in contract.get("components", {}):
116
+ required_components.add(practice_verifier.COMPONENT)
117
+ errors.extend(practice_verifier.publication_errors(contract))
98
118
  if contract.get("schema_version") != 1:
99
119
  errors.append("component contract schema_version must be 1")
100
120
  if set(contract.get("components", {})) != required_components:
@@ -105,6 +125,10 @@ def validate(root: Path = ROOT) -> list[str]:
105
125
  if __version__ != version:
106
126
  errors.append(f"wheel version {__version__} != contract version {version}")
107
127
  errors.extend(component_version_errors(contract))
128
+ if practice_verifier.required(contract):
129
+ dockerfile = (root / "Dockerfile.mcp").read_text()
130
+ if "install_practice_verifier" in dockerfile or re.search(r"(?im)^\s*(?:RUN|COPY|ADD).*unaltracaptura", dockerfile):
131
+ errors.append("Public MCP image must not download or embed the private QGIS package")
108
132
  included = {name for name, item in contract["components"].items() if item["included_in_wheel"]}
109
133
  if included != {"wheel"}:
110
134
  errors.append(f"wheel must not bundle external components: {sorted(included)}")
@@ -125,6 +149,11 @@ def validate(root: Path = ROOT) -> list[str]:
125
149
  errors.append(f"component contract schema validation failed: {exc}")
126
150
  errors.extend(f"component contract semantic validation failed: {error}" for error in component_contract_semantic_errors(contract))
127
151
  core_sha = str(contract["consumer_integration"]["core_sha"])
152
+ workflow_sha = str(contract["consumer_integration"].get("site_deploy_workflow_sha", core_sha))
153
+ workflow_ancestor = subprocess.run(["git", "merge-base", "--is-ancestor", workflow_sha, "HEAD"], cwd=root,
154
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
155
+ if workflow_ancestor.returncode != 0:
156
+ errors.append("consumer integration workflow SHA must be an ancestor of the current source")
128
157
  core_object = subprocess.run(
129
158
  ["git", "cat-file", "-e", f"{core_sha}^{{commit}}"],
130
159
  cwd=root,
@@ -145,7 +174,7 @@ def validate(root: Path = ROOT) -> list[str]:
145
174
  if core_ancestor.returncode != 0:
146
175
  errors.append("consumer integration core SHA must be an ancestor of the current source")
147
176
  pinned_workflow = subprocess.run(
148
- ["git", "show", f"{core_sha}:.github/workflows/site-deploy.yml"],
177
+ ["git", "show", f"{workflow_sha}:.github/workflows/site-deploy.yml"],
149
178
  cwd=root,
150
179
  text=True,
151
180
  stdout=subprocess.PIPE,
@@ -153,7 +182,7 @@ def validate(root: Path = ROOT) -> list[str]:
153
182
  check=False,
154
183
  )
155
184
  if pinned_workflow.returncode != 0:
156
- errors.append("consumer integration core SHA does not contain the reusable deploy workflow")
185
+ errors.append("consumer integration workflow SHA does not contain the reusable deploy workflow")
157
186
  else:
158
187
  try:
159
188
  pinned_deploy = load_workflow_text(pinned_workflow.stdout)
@@ -164,6 +193,14 @@ def validate(root: Path = ROOT) -> list[str]:
164
193
  f"consumer integration workflow: {error}"
165
194
  for error in reusable_deploy_input_errors(pinned_deploy)
166
195
  )
196
+ if "deployment_contract" in contract and contract["components"]["mcp"]["release_status"] == "ready":
197
+ recorded = subprocess.run(["git", "show", f"{workflow_sha}:src/unaltraweb_mcp/component-contract.json"],
198
+ cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
199
+ try:
200
+ provider_contract = json.loads(recorded.stdout) if recorded.returncode == 0 else {}
201
+ errors.extend(selected_deployment_errors(contract, pinned_deploy, provider_contract))
202
+ except (ValueError, KeyError, TypeError):
203
+ errors.append("selected deployment workflow has no valid provider-owned component contract")
167
204
  if schema.get("properties", {}).get("schema_version", {}).get("const") != 1:
168
205
  errors.append("component contract schema does not select version 1")
169
206
 
@@ -183,6 +220,9 @@ def validate(root: Path = ROOT) -> list[str]:
183
220
  if make_value(root / "packaging/launcher/Makefile", "MCP_RELEASE_IMAGE") != component_reference("mcp"):
184
221
  errors.append("installed launcher must select its own package release image")
185
222
  project = pyproject.get("project", {})
223
+ dependencies = [*project.get("dependencies", []), *(entry for values in project.get("optional-dependencies", {}).values() for entry in values)]
224
+ if any(re.search(r"unaltracaptura[-_]qgis", str(entry), re.I) for entry in dependencies):
225
+ errors.append("Public Web wheel must not install the private QGIS package as a dependency")
186
226
  if "version" in project or project.get("dynamic") != ["version"]:
187
227
  errors.append("pyproject version must be derived from the package component contract")
188
228
  if pyproject.get("tool", {}).get("setuptools", {}).get("dynamic", {}).get("version", {}).get("attr") != "unaltraweb_mcp.__version__":
@@ -273,7 +313,7 @@ def validate(root: Path = ROOT) -> list[str]:
273
313
  "Makefile.tmpl": ["__MCP_IMAGE__", "__MANUAL_PDF_IMAGE__"],
274
314
  ".github/workflows/deploy.yml.tmpl": [
275
315
  "__SITE_DEPLOY_WORKFLOW__",
276
- "__CORE_SHA__",
316
+ "__SITE_DEPLOY_WORKFLOW_SHA__",
277
317
  "__MANUAL_PDF_IMAGE__",
278
318
  "__VEGAVISUALS_SHA__",
279
319
  ],
@@ -486,7 +526,7 @@ def publish_ref_errors(
486
526
  default_branch: str,
487
527
  component_ids: list[str],
488
528
  ) -> list[str]:
489
- errors: list[str] = []
529
+ errors: list[str] = practice_verifier.publication_errors(contract)
490
530
  release = contract.get("release", {})
491
531
  version = str(release.get("version") or "")
492
532
  release_tag = str(release.get("tag") or "")
@@ -561,6 +601,10 @@ def main(argv: list[str] | None = None) -> int:
561
601
  component_id for component_id, selected in contract["components"].items()
562
602
  if selected["release_status"] == "pending"
563
603
  )
604
+ verifier_errors = practice_verifier.publication_errors(contract)
605
+ if verifier_errors and practice_verifier.COMPONENT not in pending:
606
+ pending.append(practice_verifier.COMPONENT)
607
+ pending.sort()
564
608
  unavailable = sorted(
565
609
  component_id for component_id, selected in contract["components"].items()
566
610
  if selected["release_status"] == "unavailable"
@@ -583,6 +627,8 @@ def main(argv: list[str] | None = None) -> int:
583
627
  "errors": errors,
584
628
  "pending_releases": pending,
585
629
  "unavailable_releases": unavailable,
630
+ "practice_verifier": {"selection_ready": not verifier_errors, "errors": verifier_errors, "origin": "prepared-image",
631
+ "private_checks": {"state": "not_run", "reason": "Public CI uses metadata/unit fixtures; native private acceptance and review are separate required gates"}},
586
632
  }, indent=2, sort_keys=True))
587
633
  readiness_errors = release_tag_errors + candidate_receipt_errors
588
634
  structural_errors = [error for error in errors if error not in readiness_errors]
@@ -52,6 +52,9 @@ PYPI_PUBLISH_IMAGE = (
52
52
  )
53
53
  # Canonical JSON digest of the complete unprivileged verification job.
54
54
  PACKAGE_PUBLISH_VERIFY_POLICY_SHA256 = "e8d99c35040770a2b00d3c62497da637739e794310b253d1893078d048d51c48"
55
+ # Canonical JSON of the complete provider-owned PDF digest/producer gate.
56
+ # Extending its reviewed release records also requires published-worker tests.
57
+ DEPLOY_PDF_PROVENANCE_POLICY_SHA256 = "da18b92db4069655e9979308dd90279ea8bfce4b77e47158efd5ac4421e06417"
55
58
  IMAGE_WORKFLOWS = {
56
59
  "compute-images.yml",
57
60
  "docker-image.yml",
@@ -119,14 +122,17 @@ def load_consumer_integration(root: Path) -> dict[str, Any]:
119
122
  "manual_pdf_image",
120
123
  "vegavisuals_sha",
121
124
  }
122
- if not isinstance(integration, dict) or set(integration) != required:
125
+ if not isinstance(integration, dict) or not required <= set(integration) or set(integration) - required - {"site_deploy_workflow_sha"}:
123
126
  raise ValueError("component contract has no exact consumer_integration object")
127
+ integration = dict(integration)
128
+ integration.setdefault("site_deploy_workflow_sha", integration["core_sha"])
124
129
  return integration
125
130
 
126
131
 
127
132
  def load_scaffold_deploy(path: Path, integration: dict[str, Any]) -> dict[str, Any]:
128
133
  replacements = {
129
134
  "CORE_SHA": str(integration["core_sha"]),
135
+ "SITE_DEPLOY_WORKFLOW_SHA": str(integration["site_deploy_workflow_sha"]),
130
136
  "SITE_DEPLOY_WORKFLOW": str(integration["site_deploy_workflow"]),
131
137
  "MANUAL_PDF_IMAGE": str(integration["manual_pdf_image"]),
132
138
  "VEGAVISUALS_SHA": str(integration["vegavisuals_sha"]),
@@ -213,6 +219,7 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
213
219
  integration = {
214
220
  "core_sha": "invalid",
215
221
  "site_deploy_workflow": "invalid",
222
+ "site_deploy_workflow_sha": "invalid",
216
223
  "manual_pdf_image": "invalid",
217
224
  "vegavisuals_sha": "invalid",
218
225
  }
@@ -1047,6 +1054,16 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
1047
1054
  if marker not in ci_text:
1048
1055
  errors.append(f"ci.yml: missing required check {marker}")
1049
1056
  ci_docker = ci.get("jobs", {}).get("docker", {})
1057
+ published_pdf_gate = _named_step(ci.get("jobs", {}).get("distribution", {}), "Test published PDF deployment provenance")
1058
+ if published_pdf_gate != {
1059
+ "name": "Test published PDF deployment provenance",
1060
+ "env": {
1061
+ "UNALTRAWEB_DEPLOY_PROVENANCE_DOCKER": "1",
1062
+ "UNALTRAWEB_DEPLOY_PROVENANCE_SOURCE_SHA": "${{ github.event.pull_request.head.sha || github.sha }}",
1063
+ },
1064
+ "run": "PYTHONPATH=test python -m unittest test_deploy_provenance.PublishedDeployProvenanceTests",
1065
+ }:
1066
+ errors.append("ci.yml: published PDF deployment provenance must run unconditionally with real Docker")
1050
1067
  ruby_tests = str(_named_step(ci_docker, "Test all Ruby files").get("run") or "")
1051
1068
  if not all(
1052
1069
  marker in ruby_tests
@@ -1362,8 +1379,20 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
1362
1379
  for marker in ["MANUAL_PDF_IMAGE", "WORKFLOW_REPOSITORY", "WORKFLOW_SHA", "docker pull", "docker image inspect", "org.opencontainers.image.revision"]:
1363
1380
  if marker not in provenance_text:
1364
1381
  errors.append(f"site-deploy.yml: PDF worker provenance check missing {marker}")
1365
- if '[[ "$image_revision" != "$WORKFLOW_SHA" ]]' not in provenance_text:
1366
- errors.append("site-deploy.yml: PDF worker revision label must be compared with the defining workflow SHA")
1382
+ if '[[ "$image_revision" != "$expected_pdf_revision" ]]' not in provenance_text:
1383
+ errors.append("site-deploy.yml: PDF worker revision label must be compared with its recorded producer SHA")
1384
+ provenance_sha256 = hashlib.sha256(
1385
+ json.dumps(provenance_step, sort_keys=True, separators=(",", ":")).encode()
1386
+ ).hexdigest()
1387
+ if provenance_sha256 != DEPLOY_PDF_PROVENANCE_POLICY_SHA256:
1388
+ errors.append("site-deploy.yml: PDF digest/producer gate differs from the exact reviewed structure")
1389
+ try:
1390
+ contract = json.loads((ROOT / "src/unaltraweb_mcp/component-contract.json").read_text())
1391
+ records = contract["deployment_contract"]["manual_pdf_workers"]
1392
+ if sum(row.get("reference") == integration["manual_pdf_image"] for row in records) != 1:
1393
+ errors.append("site-deploy.yml: selected PDF worker has no reviewed digest/producer record")
1394
+ except (OSError, ValueError, KeyError, TypeError):
1395
+ errors.append("site-deploy.yml: missing or invalid provider deployment records")
1367
1396
  if "docker pull" in provenance_text and "docker image inspect" in provenance_text and provenance_text.index("docker pull") > provenance_text.index("docker image inspect"):
1368
1397
  errors.append("site-deploy.yml: PDF worker digest must be pulled before its revision label is inspected")
1369
1398
  if "${{ github.workflow_sha }}" in deploy_text or "${{ github.workflow_repository }}" in deploy_text:
@@ -1542,7 +1571,7 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
1542
1571
  action, separator, revision = uses.rpartition("@")
1543
1572
  if not separator or action != integration["site_deploy_workflow"] or not FULL_SHA.fullmatch(revision):
1544
1573
  errors.append(f"scaffold deploy.yml:{job_name}: reusable workflow must use an immutable unaltraweb SHA")
1545
- elif revision != integration["core_sha"]:
1574
+ elif revision != integration["site_deploy_workflow_sha"]:
1546
1575
  errors.append(f"scaffold deploy.yml:{job_name}: reusable workflow SHA has not been reviewed")
1547
1576
  reusable_job = scaffold.get("jobs", {}).get(job_name, {})
1548
1577
  if reusable_job.get("needs") != "validate":
@@ -2,17 +2,43 @@
2
2
  "$schema": "component-contract.schema.json",
3
3
  "schema_version": 1,
4
4
  "release": {
5
- "version": "0.7.0",
6
- "tag": "v0.7.0"
5
+ "version": "0.7.2",
6
+ "tag": "v0.7.2"
7
7
  },
8
8
  "consumer_integration": {
9
9
  "schema_version": 1,
10
10
  "core_repository": "https://github.com/dosquartsdedocs/unaltraweb.git",
11
- "core_sha": "216ab2e8b6b8b45a5bb7da0a54bffd10edee895d",
11
+ "core_sha": "3332e0b93933f12317e05f1ce8d38ce80046feab",
12
12
  "site_deploy_workflow": "dosquartsdedocs/unaltraweb/.github/workflows/site-deploy.yml",
13
- "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:0ba267cb87f53ebaca4e31805fe00610cd61fdf97a8d2c3692f4655700dceaed",
13
+ "site_deploy_workflow_sha": "3332e0b93933f12317e05f1ce8d38ce80046feab",
14
+ "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:1bb3f2dafd741e96c28639ec1cc8ccaeb81cbaa73e649f787c0b4ef59997bd4b",
14
15
  "vegavisuals_sha": "a448cc50a5f511743a6fcfc213baeb9be80f4a27"
15
16
  },
17
+ "deployment_contract": {
18
+ "schema_version": 1,
19
+ "manual_pdf_workers": [
20
+ {
21
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:9e0b3a45753c170b795e9a9d6df61580085c113436beac5bf6c8de69b6562097",
22
+ "producer_sha": "d857f8c9f5fea90cf450c0b30b4e77a37b541275"
23
+ },
24
+ {
25
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:0ba267cb87f53ebaca4e31805fe00610cd61fdf97a8d2c3692f4655700dceaed",
26
+ "producer_sha": "5cf9817489c8dc47cee726bfe42fe3071cd32b85"
27
+ },
28
+ {
29
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:1bb3f2dafd741e96c28639ec1cc8ccaeb81cbaa73e649f787c0b4ef59997bd4b",
30
+ "producer_sha": "b7ec1d7a139c65b3b0b88699cdbeb19080f13be1"
31
+ }
32
+ ],
33
+ "caller_migrations": [
34
+ {
35
+ "id": "deploy-provenance-86",
36
+ "path": ".github/workflows/deploy.yml",
37
+ "baseline_sha256": "a789870542614d5817d30ea4328c7d1ad38d27443c009c68a48ad5aaa3106b8e",
38
+ "current_sha256": "c632a548d0782867be20a2a52fe03469cf63066b7364bace77fd54a226cb3b18"
39
+ }
40
+ ]
41
+ },
16
42
  "receipt_contract": {
17
43
  "schema_version": 1,
18
44
  "path_template": ".unaltraweb/receipts/{provider}.json",
@@ -29,17 +55,17 @@
29
55
  "package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
30
56
  "factory_required_commands": ["factory-dir"],
31
57
  "package_only_mcp": ["artifact-import-check", "import-artifact-bundle", "bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "editorial-policy", "editorial-publication-check", "editorial-review-prepare", "editorial-review-record", "editorial-review-resolve", "editorial-status", "html-audit", "http-check", "image-background-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", "prose-check", "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
- "not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"]
58
+ "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-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
59
+ "not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals", "qgis_practice_verifier"]
34
60
  },
35
61
  "components": {
36
62
  "gem": {
37
63
  "kind": "gem",
38
64
  "name": "unaltraweb",
39
- "version": "0.7.0",
40
- "release": "v0.7.0",
65
+ "version": "0.7.2",
66
+ "release": "v0.7.2",
41
67
  "release_status": "ready",
42
- "reference": "unaltraweb (= 0.7.0)",
68
+ "reference": "unaltraweb (= 0.7.2)",
43
69
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
44
70
  "included_in_wheel": false,
45
71
  "features": ["jekyll-core", "site-build"]
@@ -47,21 +73,21 @@
47
73
  "wheel": {
48
74
  "kind": "python-wheel",
49
75
  "name": "unaltraweb-mcp",
50
- "version": "0.7.0",
51
- "release": "v0.7.0",
76
+ "version": "0.7.2",
77
+ "release": "v0.7.2",
52
78
  "release_status": "ready",
53
- "reference": "unaltraweb-mcp==0.7.0",
79
+ "reference": "unaltraweb-mcp==0.7.2",
54
80
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
55
81
  "included_in_wheel": true,
56
- "features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane"]
82
+ "features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane", "source-ci"]
57
83
  },
58
84
  "runtime": {
59
85
  "kind": "container",
60
86
  "name": "unaltraweb runtime",
61
- "version": "0.7.0",
62
- "release": "v0.7.0",
87
+ "version": "0.7.2",
88
+ "release": "v0.7.2",
63
89
  "release_status": "ready",
64
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.7.0",
90
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.7.2",
65
91
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
66
92
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
67
93
  "included_in_wheel": false,
@@ -70,10 +96,10 @@
70
96
  "mcp": {
71
97
  "kind": "container",
72
98
  "name": "unaltraweb MCP runtime",
73
- "version": "0.7.0",
74
- "release": "v0.7.0",
99
+ "version": "0.7.2",
100
+ "release": "v0.7.2",
75
101
  "release_status": "ready",
76
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.7.0",
102
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.7.2",
77
103
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
78
104
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
79
105
  "included_in_wheel": false,
@@ -118,15 +144,26 @@
118
144
  "manual_pdf": {
119
145
  "kind": "container",
120
146
  "name": "unaltraweb manual PDF worker",
121
- "version": "0.6.0",
122
- "release": "v0.6.0",
147
+ "version": "0.7.1",
148
+ "release": "v0.7.1",
123
149
  "release_status": "released",
124
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:0ba267cb87f53ebaca4e31805fe00610cd61fdf97a8d2c3692f4655700dceaed",
150
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:1bb3f2dafd741e96c28639ec1cc8ccaeb81cbaa73e649f787c0b4ef59997bd4b",
125
151
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf",
126
152
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
127
153
  "included_in_wheel": false,
128
154
  "features": ["manual-pdf"]
129
155
  },
156
+ "qgis_practice_verifier": {
157
+ "kind": "companion",
158
+ "name": "unaltracaptura-qgis",
159
+ "version": "0.6.0",
160
+ "release": "v0.6.0",
161
+ "release_status": "released",
162
+ "reference": "https://github.com/dosquartsdedocs/unaltracaptura-qgis.git@v0.6.0",
163
+ "repository": "https://github.com/dosquartsdedocs/unaltracaptura-qgis",
164
+ "included_in_wheel": false,
165
+ "features": ["qgis-practice-v1", "qgis-practice-v2", "private-prepared-runtime"]
166
+ },
130
167
  "diavisuals": {
131
168
  "kind": "companion",
132
169
  "name": "diavisuals",
@@ -7,7 +7,7 @@
7
7
  "required": ["schema_version", "release", "consumer_integration", "receipt_contract", "wheel_contract", "components"],
8
8
  "properties": {
9
9
  "$schema": {"type": "string"},
10
- "schema_version": {"const": 1},
10
+ "schema_version": {"type": "integer", "const": 1},
11
11
  "release": {
12
12
  "type": "object",
13
13
  "additionalProperties": false,
@@ -22,14 +22,47 @@
22
22
  "additionalProperties": false,
23
23
  "required": ["schema_version", "core_repository", "core_sha", "site_deploy_workflow", "manual_pdf_image", "vegavisuals_sha"],
24
24
  "properties": {
25
- "schema_version": {"const": 1},
25
+ "schema_version": {"type": "integer", "const": 1},
26
26
  "core_repository": {"const": "https://github.com/dosquartsdedocs/unaltraweb.git"},
27
27
  "core_sha": {"type": "string", "minLength": 40, "maxLength": 40, "pattern": "^[0-9a-f]{40}$"},
28
28
  "site_deploy_workflow": {"const": "dosquartsdedocs/unaltraweb/.github/workflows/site-deploy.yml"},
29
+ "site_deploy_workflow_sha": {"type": "string", "minLength": 40, "maxLength": 40, "pattern": "^[0-9a-f]{40}$"},
29
30
  "manual_pdf_image": {"type": "string", "minLength": 117, "maxLength": 117, "pattern": "^ghcr\\.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:[0-9a-f]{64}$"},
30
31
  "vegavisuals_sha": {"type": "string", "minLength": 40, "maxLength": 40, "pattern": "^[0-9a-f]{40}$"}
31
32
  }
32
33
  },
34
+ "deployment_contract": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["schema_version", "manual_pdf_workers", "caller_migrations"],
38
+ "properties": {
39
+ "schema_version": {"type": "integer", "const": 1},
40
+ "manual_pdf_workers": {
41
+ "type": "array", "minItems": 1, "uniqueItems": true,
42
+ "items": {
43
+ "type": "object", "additionalProperties": false,
44
+ "required": ["reference", "producer_sha"],
45
+ "properties": {
46
+ "reference": {"type": "string", "minLength": 117, "maxLength": 117, "pattern": "^ghcr\\.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:[0-9a-f]{64}$"},
47
+ "producer_sha": {"type": "string", "minLength": 40, "maxLength": 40, "pattern": "^[0-9a-f]{40}$"}
48
+ }
49
+ }
50
+ },
51
+ "caller_migrations": {
52
+ "type": "array", "uniqueItems": true,
53
+ "items": {
54
+ "type": "object", "additionalProperties": false,
55
+ "required": ["id", "path", "baseline_sha256", "current_sha256"],
56
+ "properties": {
57
+ "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$"},
58
+ "path": {"const": ".github/workflows/deploy.yml"},
59
+ "baseline_sha256": {"type": "string", "minLength": 64, "maxLength": 64, "pattern": "^[0-9a-f]{64}$"},
60
+ "current_sha256": {"type": "string", "minLength": 64, "maxLength": 64, "pattern": "^[0-9a-f]{64}$"}
61
+ }
62
+ }
63
+ }
64
+ }
65
+ },
33
66
  "receipt_contract": {
34
67
  "type": "object",
35
68
  "additionalProperties": false,
@@ -20,7 +20,15 @@ def _strict_json(path: Path) -> Any:
20
20
  def reject(value: str) -> None:
21
21
  raise ValueError(f"non-finite JSON number is not allowed: {value}")
22
22
 
23
- return json.loads(path.read_text(encoding="utf-8"), parse_constant=reject)
23
+ def unique(pairs):
24
+ result = {}
25
+ for key, value in pairs:
26
+ if key in result:
27
+ raise ValueError(f"duplicate component contract key: {key}")
28
+ result[key] = value
29
+ return result
30
+
31
+ return json.loads(path.read_text(encoding="utf-8"), parse_constant=reject, object_pairs_hook=unique)
24
32
 
25
33
 
26
34
  def _schema_type_matches(value: Any, expected: str) -> bool:
@@ -155,6 +163,9 @@ def component_contract_semantic_errors(value: dict[str, Any]) -> list[str]:
155
163
  expected_workflow = f"{expected_core_repository.removeprefix('https://github.com/').removesuffix('.git')}/.github/workflows/site-deploy.yml"
156
164
  if integration["site_deploy_workflow"] != expected_workflow:
157
165
  errors.append("consumer integration deploy workflow must belong to the core provider")
166
+ workflow_sha = str(integration.get("site_deploy_workflow_sha", core_sha))
167
+ if re.fullmatch(r"[0-9a-f]{40}", workflow_sha) is None or workflow_sha == "0" * 40:
168
+ errors.append("consumer integration workflow SHA must be a nonzero full lowercase commit SHA")
158
169
  expected_pdf_prefix = f"{value['components']['manual_pdf']['image_repository']}@sha256:"
159
170
  manual_pdf_image = str(integration["manual_pdf_image"])
160
171
  if re.fullmatch(rf"{re.escape(expected_pdf_prefix)}[0-9a-f]{{64}}", manual_pdf_image) is None or manual_pdf_image.endswith("0" * 64):
@@ -162,6 +173,26 @@ def component_contract_semantic_errors(value: dict[str, Any]) -> list[str]:
162
173
  vegavisuals_sha = str(integration["vegavisuals_sha"])
163
174
  if re.fullmatch(r"[0-9a-f]{40}", vegavisuals_sha) is None or vegavisuals_sha == "0" * 40:
164
175
  errors.append("consumer integration Vega revision must be a nonzero full lowercase commit SHA")
176
+ deployment = value.get("deployment_contract")
177
+ if deployment is not None:
178
+ references = [row["reference"] for row in deployment["manual_pdf_workers"]]
179
+ if not 1 <= len(references) <= 64:
180
+ errors.append("deployment worker inventory must contain between 1 and 64 records")
181
+ if len(set(references)) != len(references):
182
+ errors.append("deployment worker references must be unique")
183
+ if manual_pdf_image not in references or value["components"]["manual_pdf"]["reference"] != manual_pdf_image:
184
+ errors.append("selected PDF worker must match a reviewed deployment record and the component reference")
185
+ for row in deployment["manual_pdf_workers"]:
186
+ if row["producer_sha"] == "0" * 40 or row["reference"].endswith("0" * 64):
187
+ errors.append("deployment worker identities must be nonzero")
188
+ migrations = deployment["caller_migrations"]
189
+ if len(migrations) > 32:
190
+ errors.append("deployment caller migration inventory exceeds 32 records")
191
+ if len({row["id"] for row in migrations}) != len(migrations) or len({(row["path"], row["baseline_sha256"], row["current_sha256"]) for row in migrations}) != len(migrations):
192
+ errors.append("deployment caller migrations must be unique")
193
+ for row in migrations:
194
+ if row["baseline_sha256"] == row["current_sha256"] or "0" * 64 in (row["baseline_sha256"], row["current_sha256"]):
195
+ errors.append("deployment caller migration requires distinct nonzero baseline and current hashes")
165
196
  return errors
166
197
 
167
198
 
@@ -194,7 +225,16 @@ def distribution_version() -> str:
194
225
 
195
226
  def consumer_integration() -> dict[str, Any]:
196
227
  """Return an isolated copy of the reviewed consumer runtime tuple."""
197
- return dict(_contract()["consumer_integration"])
228
+ result = dict(_contract()["consumer_integration"])
229
+ result.setdefault("site_deploy_workflow_sha", result["core_sha"])
230
+ return result
231
+
232
+
233
+ def deployment_contract() -> dict[str, Any]:
234
+ """Provider-owned identities and exact reviewed caller migrations."""
235
+ return json.loads(json.dumps(_contract().get("deployment_contract", {
236
+ "schema_version": 1, "manual_pdf_workers": [], "caller_migrations": [],
237
+ })))
198
238
 
199
239
 
200
240
  def component(component_id: str) -> dict[str, Any]: