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.
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import copy
7
+ import hashlib
7
8
  import json
8
9
  import re
9
10
  from pathlib import Path
@@ -43,7 +44,14 @@ REVIEWED_ACTIONS = {
43
44
  "docker/metadata-action": "c299e40c65443455700f0fdfc63efafe5b349051",
44
45
  "docker/setup-buildx-action": "8d2750c68a42422c14e847fe6c8ac0403b4cbd6f",
45
46
  "ruby/setup-ruby": "95ef2b042f9d7a56d8268cba8559e2842e2ad01b",
47
+ "rubygems/configure-rubygems-credentials": "dc5a8d8553e6ee01fc26761a49e99e733d17954a",
46
48
  }
49
+ PYPI_PUBLISH_IMAGE = (
50
+ "docker://ghcr.io/pypa/gh-action-pypi-publish"
51
+ "@sha256:a68d05519f6d7e47372aeaddab80b851b69afa89be179ec41775c72c4e3ab2d5"
52
+ )
53
+ # Canonical JSON digest of the complete unprivileged verification job.
54
+ PACKAGE_PUBLISH_VERIFY_POLICY_SHA256 = "e8d99c35040770a2b00d3c62497da637739e794310b253d1893078d048d51c48"
47
55
  IMAGE_WORKFLOWS = {
48
56
  "compute-images.yml",
49
57
  "docker-image.yml",
@@ -51,7 +59,13 @@ IMAGE_WORKFLOWS = {
51
59
  "web-capture-image.yml",
52
60
  }
53
61
  PROMOTED_CORE_IMAGE_WORKFLOWS = {"compute-images.yml", "docker-image.yml", "web-capture-image.yml"}
54
- FULLY_PINNED_WORKFLOWS = IMAGE_WORKFLOWS | {"ci.yml", "package-prepare.yml", "site-deploy.yml", "site-release.yml"}
62
+ FULLY_PINNED_WORKFLOWS = IMAGE_WORKFLOWS | {
63
+ "ci.yml",
64
+ "package-prepare.yml",
65
+ "package-publish.yml",
66
+ "site-deploy.yml",
67
+ "site-release.yml",
68
+ }
55
69
 
56
70
 
57
71
  class WorkflowLoader(yaml.SafeLoader):
@@ -221,6 +235,12 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
221
235
  uses = step.get("uses")
222
236
  if not isinstance(uses, str) or uses.startswith("./"):
223
237
  continue
238
+ if uses.startswith("docker://"):
239
+ if name in FULLY_PINNED_WORKFLOWS and re.fullmatch(
240
+ r"docker://[^@]+@sha256:[0-9a-f]{64}", uses
241
+ ) is None:
242
+ errors.append(f"{name}:{job_name}: container action must use an immutable digest: {uses}")
243
+ continue
224
244
  action, separator, revision = uses.rpartition("@")
225
245
  if not separator:
226
246
  errors.append(f"{name}:{job_name}: action has no revision: {uses}")
@@ -228,8 +248,9 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
228
248
  reviewed = REVIEWED_ACTIONS.get(action)
229
249
  if reviewed and revision != reviewed:
230
250
  errors.append(f"{name}:{job_name}: {action} must use reviewed SHA {reviewed}")
231
- if name in FULLY_PINNED_WORKFLOWS and not FULL_SHA.fullmatch(revision):
232
- errors.append(f"{name}:{job_name}: action must use a full commit SHA: {uses}")
251
+ if name in FULLY_PINNED_WORKFLOWS:
252
+ if not FULL_SHA.fullmatch(revision):
253
+ errors.append(f"{name}:{job_name}: action must use a full commit SHA: {uses}")
233
254
  if action == "actions/checkout" and step.get("with", {}).get("persist-credentials") is not False:
234
255
  errors.append(f"{name}:{job_name}: checkout must set persist-credentials: false")
235
256
 
@@ -607,9 +628,17 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
607
628
  "MCP_IMAGE": (
608
629
  "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
609
630
  "${{ needs.build-candidates.outputs.mcp_digest }}"
610
- )
631
+ ),
632
+ "MCP_SMOKE_MANUAL_PDF_IMAGE": (
633
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@"
634
+ "${{ needs.build-candidates.outputs.manual_pdf_digest }}"
635
+ ),
611
636
  },
612
- ["mcp-smoke-prebuilt", 'MCP_IMAGE="$MCP_IMAGE"'],
637
+ [
638
+ "mcp-smoke-prebuilt",
639
+ 'MCP_IMAGE="$MCP_IMAGE"',
640
+ 'MCP_SMOKE_MANUAL_PDF_IMAGE="$MCP_SMOKE_MANUAL_PDF_IMAGE"',
641
+ ],
613
642
  ),
614
643
  "Build docs with exact runtime": (
615
644
  {
@@ -1027,6 +1056,24 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
1027
1056
  manual_pdf_tests = str(_named_step(ci_docker, "Test all manual PDF integrations").get("run") or "")
1028
1057
  if "-m unittest discover -s test/manual_pdf -p 'test_*_integration.py'" not in manual_pdf_tests:
1029
1058
  errors.append("ci.yml: manual PDF image must run every test_*_integration.py suite")
1059
+ mcp_smoke = _named_step(ci_docker, "Smoke test MCP")
1060
+ mcp_smoke_run = str(mcp_smoke.get("run") or "")
1061
+ if (
1062
+ mcp_smoke.get("env") != {
1063
+ "MCP_IMAGE": "unaltraweb-mcp:ci",
1064
+ "MCP_SMOKE_MANUAL_PDF_IMAGE": "unaltraweb-manual-pdf:ci",
1065
+ }
1066
+ or "continue-on-error" in mcp_smoke
1067
+ or not all(
1068
+ marker in mcp_smoke_run
1069
+ for marker in [
1070
+ "mcp-smoke-prebuilt",
1071
+ 'MCP_IMAGE="$MCP_IMAGE"',
1072
+ 'MCP_SMOKE_MANUAL_PDF_IMAGE="$MCP_SMOKE_MANUAL_PDF_IMAGE"',
1073
+ ]
1074
+ )
1075
+ ):
1076
+ errors.append("ci.yml: MCP smoke must pair MCP and manual PDF images built from the same checkout")
1030
1077
 
1031
1078
  package = workflows.get("package-prepare.yml", {})
1032
1079
  package_text = json.dumps(package)
@@ -1041,6 +1088,244 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
1041
1088
  if package.get("permissions", {}).get("contents") != "read" or len(package.get("permissions", {})) != 1:
1042
1089
  errors.append("package-prepare.yml: candidate preparation must have read-only repository permissions")
1043
1090
 
1091
+ package_publish = workflows.get("package-publish.yml")
1092
+ if package_publish is None:
1093
+ errors.append("missing trusted language-package publication workflow: package-publish.yml")
1094
+ else:
1095
+ if set(package_publish) != {"name", "on", "permissions", "concurrency", "jobs"}:
1096
+ errors.append("package-publish.yml: workflow contains authority outside the reviewed structure")
1097
+ publish_on = package_publish.get("on", {})
1098
+ if set(publish_on) != {"workflow_dispatch"}:
1099
+ errors.append("package-publish.yml: package publication must be workflow_dispatch-only")
1100
+ publish_inputs = publish_on.get("workflow_dispatch", {}).get("inputs", {})
1101
+ expected_publish_inputs = {
1102
+ "publisher_sha": {"required": True, "type": "string"},
1103
+ "release_tag": {"required": True, "type": "string"},
1104
+ "release_tag_object": {"required": True, "type": "string"},
1105
+ "receipt_sha256": {"required": True, "type": "string"},
1106
+ "package_run_id": {"required": True, "type": "string"},
1107
+ "publish_target": {
1108
+ "required": True,
1109
+ "type": "choice",
1110
+ "default": "all",
1111
+ "options": ["all", "pypi", "rubygems"],
1112
+ },
1113
+ }
1114
+ if not isinstance(publish_inputs, dict) or set(publish_inputs) != set(expected_publish_inputs):
1115
+ errors.append("package-publish.yml: dispatch input inventory differs from policy")
1116
+ else:
1117
+ for input_name, expected in expected_publish_inputs.items():
1118
+ actual = publish_inputs.get(input_name, {})
1119
+ if any(actual.get(key) != value for key, value in expected.items()):
1120
+ errors.append(f"package-publish.yml: {input_name} input differs from policy")
1121
+ if package_publish.get("permissions") != {}:
1122
+ errors.append("package-publish.yml: workflow must grant no default permissions")
1123
+ if package_publish.get("concurrency") != {
1124
+ "group": "package-publish-${{ inputs.release_tag }}",
1125
+ "cancel-in-progress": False,
1126
+ }:
1127
+ errors.append("package-publish.yml: publication must use release-scoped non-cancelling concurrency")
1128
+
1129
+ publish_jobs = package_publish.get("jobs", {})
1130
+ if set(publish_jobs) != {"verify", "publish-pypi", "publish-rubygems"}:
1131
+ errors.append("package-publish.yml: workflow must contain only verify and two registry jobs")
1132
+ verify_job = publish_jobs.get("verify", {})
1133
+ verify_policy_sha256 = hashlib.sha256(
1134
+ json.dumps(verify_job, sort_keys=True, separators=(",", ":")).encode()
1135
+ ).hexdigest()
1136
+ if verify_policy_sha256 != PACKAGE_PUBLISH_VERIFY_POLICY_SHA256:
1137
+ errors.append("package-publish.yml: verification job differs from the exact reviewed structure")
1138
+ if set(verify_job) != {"runs-on", "timeout-minutes", "permissions", "steps"}:
1139
+ errors.append("package-publish.yml: verification job structure differs from policy")
1140
+ if verify_job.get("runs-on") != "ubuntu-24.04" or verify_job.get("timeout-minutes") != 15:
1141
+ errors.append("package-publish.yml: verification job must use the reviewed hosted runner boundary")
1142
+ if verify_job.get("permissions") != {"actions": "read", "contents": "read"}:
1143
+ errors.append("package-publish.yml: verification job must have only actions/content read access")
1144
+ verify_steps = verify_job.get("steps", [])
1145
+ verify_names = [str(step.get("name") or "") for step in verify_steps if isinstance(step, dict)]
1146
+ expected_verify_names = [
1147
+ "Validate publication request",
1148
+ "Checkout reviewed publisher",
1149
+ "Checkout exact release tag",
1150
+ "Validate exact release checkout",
1151
+ "Authorize package candidate run",
1152
+ "Download recorded package artifact",
1153
+ "Verify and stage recorded package candidates",
1154
+ "Upload verified wheel",
1155
+ "Upload verified gem",
1156
+ ]
1157
+ if verify_names != expected_verify_names:
1158
+ errors.append("package-publish.yml: verification step inventory or order differs from policy")
1159
+ verify_text = json.dumps(verify_job)
1160
+ for marker in [
1161
+ "refs/heads/${DEFAULT_BRANCH}",
1162
+ "PUBLISHER_SHA",
1163
+ "GITHUB_SHA",
1164
+ "RELEASE_TAG_OBJECT",
1165
+ "RECEIPT_SHA256",
1166
+ "verify_package_publish.py release",
1167
+ "--receipt-sha256",
1168
+ "/actions/runs/${PACKAGE_RUN_ID}",
1169
+ "/actions/runs/${PACKAGE_RUN_ID}/artifacts?per_page=100",
1170
+ "release-candidates.json",
1171
+ "verify_package_publish.py authorize",
1172
+ "verify_package_publish.py stage",
1173
+ "artifact-ids",
1174
+ "overwrite",
1175
+ ]:
1176
+ if marker not in verify_text:
1177
+ errors.append(f"package-publish.yml: missing unprivileged package verification gate {marker}")
1178
+ for forbidden in ["id-token", "gem push", "twine upload", "gh-action-pypi-publish", "configure-rubygems-credentials"]:
1179
+ if forbidden in verify_text:
1180
+ errors.append(f"package-publish.yml: verification job contains publication authority {forbidden}")
1181
+ verify_run_text = _run_text(verify_job)
1182
+ for forbidden in ["pip install", "make -C release-source", "make --no-print-directory -C release-source", "release-source/scripts/"]:
1183
+ if forbidden in verify_run_text:
1184
+ errors.append(f"package-publish.yml: verification job must not execute release source: {forbidden}")
1185
+
1186
+ publisher_checkout = _named_step(verify_job, "Checkout reviewed publisher")
1187
+ release_checkout = _named_step(verify_job, "Checkout exact release tag")
1188
+ if publisher_checkout.get("with") != {
1189
+ "ref": "${{ github.sha }}",
1190
+ "path": ".package-publisher",
1191
+ "persist-credentials": False,
1192
+ }:
1193
+ errors.append("package-publish.yml: publisher checkout must use the reviewed workflow SHA")
1194
+ if release_checkout.get("with") != {
1195
+ "fetch-depth": 0,
1196
+ "ref": "refs/tags/${{ inputs.release_tag }}",
1197
+ "path": "release-source",
1198
+ "persist-credentials": False,
1199
+ }:
1200
+ errors.append("package-publish.yml: release checkout must use the exact tag with full history")
1201
+ expected_release_validation = (
1202
+ "python3 .package-publisher/scripts/verify_package_publish.py release \\\n"
1203
+ " --repository-dir release-source \\\n"
1204
+ " --receipt release-source/release-candidates.json \\\n"
1205
+ " --release-tag \"$RELEASE_TAG\" \\\n"
1206
+ " --tag-object \"$RELEASE_TAG_OBJECT\" \\\n"
1207
+ " --receipt-sha256 \"$RECEIPT_SHA256\" \\\n"
1208
+ " --default-branch \"$DEFAULT_BRANCH\""
1209
+ )
1210
+ if str(_named_step(verify_job, "Validate exact release checkout").get("run") or "").strip() != expected_release_validation:
1211
+ errors.append("package-publish.yml: release validation must use only the reviewed publisher verifier")
1212
+ download_candidate = _named_step(verify_job, "Download recorded package artifact")
1213
+ if download_candidate.get("with") != {
1214
+ "artifact-ids": "${{ steps.authorize.outputs.artifact-id }}",
1215
+ "github-token": "${{ github.token }}",
1216
+ "repository": "${{ github.repository }}",
1217
+ "run-id": "${{ inputs.package_run_id }}",
1218
+ "path": "incoming",
1219
+ "merge-multiple": True,
1220
+ }:
1221
+ errors.append("package-publish.yml: candidate download must use the authorized artifact ID and run")
1222
+ expected_stage = (
1223
+ "python3 .package-publisher/scripts/verify_package_publish.py stage \\\n"
1224
+ " --receipt release-source/release-candidates.json \\\n"
1225
+ " --release-tag \"$RELEASE_TAG\" \\\n"
1226
+ " --receipt-sha256 \"$RECEIPT_SHA256\" \\\n"
1227
+ " --candidate-dir incoming \\\n"
1228
+ " --output-dir verified"
1229
+ )
1230
+ if str(_named_step(verify_job, "Verify and stage recorded package candidates").get("run") or "").strip() != expected_stage:
1231
+ errors.append("package-publish.yml: candidate staging command differs from policy")
1232
+ expected_verified_uploads = {
1233
+ "Upload verified wheel": {
1234
+ "name": "verified-pypi-${{ inputs.release_tag }}-${{ github.run_id }}",
1235
+ "path": "verified/pypi/",
1236
+ "if-no-files-found": "error",
1237
+ "compression-level": 0,
1238
+ "overwrite": False,
1239
+ "retention-days": 7,
1240
+ },
1241
+ "Upload verified gem": {
1242
+ "name": "verified-rubygems-${{ inputs.release_tag }}-${{ github.run_id }}",
1243
+ "path": "verified/rubygems/",
1244
+ "if-no-files-found": "error",
1245
+ "compression-level": 0,
1246
+ "overwrite": False,
1247
+ "retention-days": 7,
1248
+ },
1249
+ }
1250
+ for step_name, expected_with in expected_verified_uploads.items():
1251
+ if _named_step(verify_job, step_name).get("with") != expected_with:
1252
+ errors.append(f"package-publish.yml: {step_name} must preserve the verified same-run artifact")
1253
+
1254
+ expected_registry_jobs = {
1255
+ "publish-pypi": {
1256
+ "needs": "verify",
1257
+ "if": "${{ inputs.publish_target == 'all' || inputs.publish_target == 'pypi' }}",
1258
+ "runs-on": "ubuntu-24.04",
1259
+ "timeout-minutes": 10,
1260
+ "environment": "pypi",
1261
+ "permissions": {"actions": "read", "id-token": "write"},
1262
+ "steps": [
1263
+ {
1264
+ "name": "Download verified wheel",
1265
+ "uses": "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093",
1266
+ "with": {
1267
+ "name": "verified-pypi-${{ inputs.release_tag }}-${{ github.run_id }}",
1268
+ "path": "dist",
1269
+ },
1270
+ },
1271
+ {
1272
+ "name": "Publish exact wheel to PyPI",
1273
+ "uses": PYPI_PUBLISH_IMAGE,
1274
+ "env": {
1275
+ "INPUT_USER": "__token__",
1276
+ "INPUT_PASSWORD": "",
1277
+ "INPUT_REPOSITORY_URL": "https://upload.pypi.org/legacy/",
1278
+ "INPUT_PACKAGES_DIR": "dist",
1279
+ "INPUT_VERIFY_METADATA": "true",
1280
+ "INPUT_SKIP_EXISTING": "false",
1281
+ "INPUT_VERBOSE": "true",
1282
+ "INPUT_PRINT_HASH": "true",
1283
+ "INPUT_ATTESTATIONS": "true",
1284
+ },
1285
+ },
1286
+ ],
1287
+ },
1288
+ "publish-rubygems": {
1289
+ "needs": "verify",
1290
+ "if": "${{ inputs.publish_target == 'all' || inputs.publish_target == 'rubygems' }}",
1291
+ "runs-on": "ubuntu-24.04",
1292
+ "timeout-minutes": 10,
1293
+ "environment": "rubygems",
1294
+ "permissions": {"actions": "read", "id-token": "write"},
1295
+ "steps": [
1296
+ {
1297
+ "name": "Download verified gem",
1298
+ "uses": "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093",
1299
+ "with": {
1300
+ "name": "verified-rubygems-${{ inputs.release_tag }}-${{ github.run_id }}",
1301
+ "path": "gem-dist",
1302
+ },
1303
+ },
1304
+ {
1305
+ "name": "Configure short-lived RubyGems credentials",
1306
+ "uses": "rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a",
1307
+ },
1308
+ {
1309
+ "name": "Publish exact gem to RubyGems",
1310
+ "shell": "bash",
1311
+ "run": (
1312
+ "set -euo pipefail\n"
1313
+ "shopt -s nullglob\n"
1314
+ "candidates=(gem-dist/*.gem)\n"
1315
+ "if [[ \"${#candidates[@]}\" -ne 1 ]]; then\n"
1316
+ " printf 'Expected exactly one verified gem, found %s\\n' \"${#candidates[@]}\" >&2\n"
1317
+ " exit 1\n"
1318
+ "fi\n"
1319
+ "gem push --host https://rubygems.org \"${candidates[0]}\"\n"
1320
+ ),
1321
+ },
1322
+ ],
1323
+ },
1324
+ }
1325
+ for job_name, expected_job in expected_registry_jobs.items():
1326
+ if publish_jobs.get(job_name) != expected_job:
1327
+ errors.append(f"package-publish.yml:{job_name}: privileged registry job differs from exact policy")
1328
+
1044
1329
  deploy = workflows.get("site-deploy.yml")
1045
1330
  if deploy is None:
1046
1331
  errors.append("missing reusable latest site workflow: site-deploy.yml")