Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## 0.2.0 (candidate) — 2026-07-22
## 0.2.0 — 2026-08-10

- Publish the checksum-pinned Capture v1.0.0 asset for direct `capture` profile installation.

- Add `scriptorium resume`, backed by Provenance's bounded, read-only Context
Capsule. It restores approved project state, explicitly labels auto-applied
Expand Down
5 changes: 3 additions & 2 deletions src/scriptorium/assets/component-catalog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ delivery = "release-asset"
directory = "capture"
owner = "provenance"
artifact_name = "scriptorium-local-conversation-export-1.0.0.zip"
artifact_status = "source-build-only"
artifact_sha256 = "1a85ec282f54bc9cff0c53bb595d7fb5ed37c5ce0121cb036b9fc80574998db8"
artifact_status = "published"
artifact_url = "https://github.com/foxsplendid/Provenance/releases/download/capture-v1.0.0/scriptorium-local-conversation-export-1.0.0.zip"
artifact_sha256 = "9517abd5d1482ae7a5b8b9b015dc8770aa00c2bac1278b2d061e36ad8cbe5270"
20 changes: 20 additions & 0 deletions src/scriptorium/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
REVISION_RE = re.compile(r"^[0-9a-f]{40}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
DIRECTORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
ARTIFACT_URL_RE = re.compile(
r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/releases/download/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$"
)
STABILITIES = {"stable", "candidate", "experimental"}
DELIVERIES = {"source", "python-source", "workspace-source", "release-asset"}
ARTIFACT_STATUSES = {"source-build-only", "published"}


class ComponentCatalogError(RuntimeError):
Expand All @@ -36,6 +40,7 @@ class Component:
owner: str | None = None
artifact_name: str | None = None
artifact_status: str | None = None
artifact_url: str | None = None
artifact_sha256: str | None = None


Expand Down Expand Up @@ -69,6 +74,7 @@ def _load_component(component_id: str, raw: object) -> Component:
"owner",
"artifact_name",
"artifact_status",
"artifact_url",
"artifact_sha256",
}
extra = set(data) - allowed
Expand All @@ -89,22 +95,34 @@ def _load_component(component_id: str, raw: object) -> Component:
owner = data.get("owner")
artifact_name = data.get("artifact_name")
artifact_status = data.get("artifact_status")
artifact_url = data.get("artifact_url")
artifact_sha256 = data.get("artifact_sha256")
for name, value in (
("owner", owner),
("artifact_name", artifact_name),
("artifact_status", artifact_status),
("artifact_url", artifact_url),
("artifact_sha256", artifact_sha256),
):
if value is not None and (not isinstance(value, str) or not value.strip()):
raise ComponentCatalogError(f"{where}.{name} must be a non-empty string")
if delivery == "release-asset" and not artifact_name:
raise ComponentCatalogError(f"{where}.artifact_name is required")
if delivery == "release-asset" and artifact_status not in ARTIFACT_STATUSES:
raise ComponentCatalogError(f"{where}.artifact_status is invalid")
if delivery == "release-asset" and (
not isinstance(artifact_sha256, str)
or not SHA256_RE.fullmatch(artifact_sha256)
):
raise ComponentCatalogError(f"{where}.artifact_sha256 is required")
if artifact_url is not None and (
not ARTIFACT_URL_RE.fullmatch(artifact_url)
or not isinstance(artifact_name, str)
or not artifact_url.endswith(f"/{artifact_name}")
):
raise ComponentCatalogError(f"{where}.artifact_url is invalid")
if delivery == "release-asset" and artifact_status == "published" and artifact_url is None:
raise ComponentCatalogError(f"{where}.artifact_url is required for a published asset")
directory = _required_string(data, "directory", where=where)
if not DIRECTORY_RE.fullmatch(directory) or directory in {".", ".."}:
raise ComponentCatalogError(f"{where}.directory must be one safe path segment")
Expand All @@ -120,6 +138,7 @@ def _load_component(component_id: str, raw: object) -> Component:
owner=owner,
artifact_name=artifact_name,
artifact_status=artifact_status,
artifact_url=artifact_url,
artifact_sha256=artifact_sha256,
)

Expand Down Expand Up @@ -189,6 +208,7 @@ def build_component_report(profile: str | None = None) -> dict[str, object]:
"owner": component.owner,
"artifact_name": component.artifact_name,
"artifact_status": component.artifact_status,
"artifact_url": component.artifact_url,
"artifact_sha256": component.artifact_sha256,
"repository": component.repository,
"revision": component.revision,
Expand Down
77 changes: 75 additions & 2 deletions src/scriptorium/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
import zipfile
from pathlib import Path
from pathlib import PurePosixPath
from tempfile import TemporaryDirectory
from urllib.error import URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from uuid import uuid4

from . import __version__
Expand All @@ -29,6 +33,8 @@
MAX_ASSET_ENTRIES = 100
MAX_ASSET_BYTES = 10 * 1024 * 1024
COMMAND_TIMEOUT_SECONDS = 300
DOWNLOAD_TIMEOUT_SECONDS = 60
DOWNLOAD_CHUNK_BYTES = 64 * 1024


class InstallError(RuntimeError):
Expand Down Expand Up @@ -157,6 +163,63 @@ def _file_sha256(path: Path) -> str:
return digest.hexdigest()


def _download_release_asset(component: Component, destination: Path) -> None:
if component.artifact_url is None:
raise InstallError("release asset is unpublished", code="artifact_unpublished")
request = Request(
component.artifact_url,
headers={"User-Agent": f"scriptorium/{__version__}"},
)
try:
with urlopen(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
final = urlsplit(response.geturl())
hostname = final.hostname or ""
if (
final.scheme != "https"
or (
hostname != "github.com"
and not hostname.endswith(".githubusercontent.com")
)
or final.username is not None
or final.password is not None
or final.port not in {None, 443}
):
raise InstallError(
"release asset redirected outside the trusted host boundary",
code="asset_download",
)
declared_size = response.headers.get("Content-Length")
if declared_size is not None:
try:
parsed_size = int(declared_size)
except ValueError as exc:
raise InstallError(
"release asset size is invalid", code="asset_download"
) from exc
if parsed_size < 0 or parsed_size > MAX_ASSET_BYTES:
raise InstallError(
"release asset is too large", code="asset_invalid"
)
total = 0
with destination.open("xb") as stream:
while True:
chunk = response.read(DOWNLOAD_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > MAX_ASSET_BYTES:
raise InstallError(
"release asset is too large", code="asset_invalid"
)
stream.write(chunk)
except InstallError:
raise
except (OSError, URLError, ValueError) as exc:
raise InstallError(
"release asset download failed", code="asset_download"
) from exc


def _validate_local_asset(component: Component, asset: Path) -> Path:
requested = _absolute(asset)
if _is_linklike(requested) or not requested.is_file():
Expand Down Expand Up @@ -525,8 +588,18 @@ def execute_install(
continue
if component.delivery == "release-asset":
if asset is None:
raise InstallError("release-asset download is unavailable", code="artifact_unpublished")
_install_release_asset(component, _validate_local_asset(component, asset), destination)
with TemporaryDirectory(prefix="scriptorium-asset-") as temporary:
downloaded = Path(temporary) / str(component.artifact_name)
_download_release_asset(component, downloaded)
_install_release_asset(
component,
_validate_local_asset(component, downloaded),
destination,
)
else:
_install_release_asset(
component, _validate_local_asset(component, asset), destination
)
else:
_install_source(component, destination)
_write_environment_scripts(resolved_target, components)
Expand Down
39 changes: 26 additions & 13 deletions tests/test_cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock

from scriptorium import cli
from scriptorium.installer import InstallError


class InstallCliTests(unittest.TestCase):
Expand All @@ -30,23 +32,34 @@ def test_core_preview_is_zero_write_json(self):
self.assertEqual(report["writes"], "none")
self.assertFalse(target.exists())

def test_capture_run_fails_without_creating_target_until_asset_exists(self):
def test_capture_run_marks_target_failed_when_download_fails(self):
with tempfile.TemporaryDirectory() as temporary:
target = Path(temporary) / "capture"
code, report, stderr = self.invoke(
[
"install",
"capture",
"--target",
str(target),
"--run",
"--json",
]
)

def failing_download(_component, _destination):
raise InstallError("release asset download failed", code="asset_download")

with mock.patch(
"scriptorium.installer._download_release_asset",
side_effect=failing_download,
):
code, report, stderr = self.invoke(
[
"install",
"capture",
"--target",
str(target),
"--run",
"--json",
]
)
self.assertEqual(code, 2)
self.assertEqual(stderr, "")
self.assertEqual(report["errors"], [{"code": "artifact_unpublished"}])
self.assertFalse(target.exists())
self.assertEqual(report["errors"], [{"code": "asset_download"}])
marker = json.loads(
(target / ".scriptorium-install.json").read_text(encoding="utf-8")
)
self.assertEqual(marker["state"], "failed")

def test_unknown_profile_is_a_stable_json_error(self):
with tempfile.TemporaryDirectory() as temporary:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def test_catalog_keeps_capture_owned_by_provenance(self):
capture = catalog.components["capture"]
self.assertEqual(capture.owner, "provenance")
self.assertEqual(capture.delivery, "release-asset")
self.assertEqual(capture.artifact_status, "published")
self.assertTrue(capture.artifact_url.endswith(capture.artifact_name))
self.assertNotIn("capture", catalog.profiles["core"])

def test_core_profile_selects_only_contract_and_memory_core(self):
Expand Down
34 changes: 28 additions & 6 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,39 @@ def test_preview_writes_nothing(self):
)
self.assertFalse(target.exists())

def test_capture_preview_is_honest_about_unpublished_asset(self):
def test_capture_preview_reports_published_asset_as_available(self):
with tempfile.TemporaryDirectory() as temporary:
target = Path(temporary) / "capture"
report = plan_install(profile="capture", target=target)
self.assertEqual(report["status"], "action-required")
self.assertEqual(report["summary"]["unavailable"], 1)
self.assertFalse(report["actions"][0]["available"])
with self.assertRaisesRegex(InstallError, "unpublished"):
execute_install(profile="capture", target=target)
self.assertEqual(report["status"], "planned")
self.assertEqual(report["summary"]["unavailable"], 0)
self.assertTrue(report["actions"][0]["available"])
self.assertFalse(target.exists())

def test_published_capture_download_installs_without_provenance(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
asset = root / "source.zip"
with zipfile.ZipFile(asset, "w") as archive:
archive.writestr("manifest.json", '{"manifest_version": 3}')
catalog = self.capture_catalog(asset)
target = root / "capture-only"

def fake_download(_component, destination):
destination.write_bytes(asset.read_bytes())

with mock.patch(
"scriptorium.installer._download_release_asset",
side_effect=fake_download,
):
report = execute_install(
profile="capture", target=target, catalog=catalog
)

self.assertEqual(report["status"], "installed")
self.assertTrue((target / "capture" / "unpacked" / "manifest.json").is_file())
self.assertFalse((target / "Provenance").exists())

def test_downloaded_capture_asset_installs_without_cloning_provenance(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
Expand Down