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
8 changes: 6 additions & 2 deletions .github/workflows/release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,14 @@ jobs:
raise SystemExit("browser fixture commit contains no correction in the fixture owners")
if fixture != product:
subprocess.run(["git", "merge-base", "--is-ancestor", fixture, "origin/main"], check=True)
corrections = {}
for path in sorted(changed):
if git("ls-tree", fixture, "--", path).split()[0] != "100644":
entry = git("ls-tree", fixture, "--", path).split()
if not entry or entry[0] != "100644":
raise SystemExit("browser fixture correction must retain a regular source file")
Path(path).write_bytes(subprocess.check_output(["git", "show", fixture + ":" + path]))
corrections[path] = subprocess.check_output(["git", "show", fixture + ":" + path])
for path, content in corrections.items():
Path(path).write_bytes(content)
Path("dist/RELEASE-SOURCES.json").write_text(json.dumps({
"product_tag": os.environ["RELEASE_TAG"],
"product_commit": product,
Expand Down
8 changes: 5 additions & 3 deletions docs/product/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,11 @@ For a browser-fixture error discovered after an immutable tag was published,
maintainers may dispatch the release workflow from reviewed tooling with
`tag` set to that existing release and `browser_fixture_commit` set to a full,
merged commit SHA. Only the personal-workspace fixture, typed-actions,
goal-work-map and goal-workspace-m2 scenario files are extracted from that commit; its other changes are not
applied to the product checkout. A non-default commit must contain at least
one correction in these fixture owners. The wheel and sdist are built before
goal-work-map and goal-workspace-m2 scenario files are extracted from that commit;
its other changes are not applied to the product checkout. All candidate files
must remain regular source files before any correction is copied; deletion or a
symlink fails closed without partially applying the fixture correction.
A non-default commit must contain at least one correction in these fixture owners. The wheel and sdist are built before
those test corrections are applied; checksums are verified again after the
browser and managed-runtime tests, and the fixtures are restored. An empty
input uses the tagged fixtures. This does not skip browser validation, rebuild
Expand Down
128 changes: 127 additions & 1 deletion examples/release-artifacts-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@

from __future__ import annotations

import importlib.util
import ast
import gzip
import hashlib
import io
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import tomllib
from pathlib import Path

Expand Down Expand Up @@ -48,6 +53,126 @@ def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


def workflow_run(workflow: str, step: str) -> str:
"""Execute the shipped block, rather than a second implementation of it."""
section = workflow.split(f" - name: {step}\n", 1)[1]
lines = section.split(" run: |\n", 1)[1].splitlines()
body = []
for line in lines:
if line and not line.startswith(" "):
break
body.append(line)
return textwrap.dedent("\n".join(body)) + "\n"


def fixture_recovery_contract(workflow: str, identity: release_artifacts.ReleaseIdentity) -> None:
paths = (
"examples/personal-workspace-browser/fixture.mjs",
"examples/personal-workspace-browser/typed-actions.mjs",
"examples/personal-workspace-browser/goal-work-map.mjs",
"examples/personal-workspace-browser/goal-workspace-m2.mjs",
)
recover = workflow_run(workflow, "Resolve browser fixture correction without rebuilding product bytes")
restore = workflow_run(workflow, "Restore browser fixtures and bind verified package checksums")
source = recover.split("python - <<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
allowed = next(node.value for node in ast.walk(ast.parse(source))
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "allowed" for target in node.targets))
assert ast.literal_eval(allowed) == set(paths)
assert workflow.index("Build wheel and source distribution") < workflow.index("Resolve browser fixture correction")
assert workflow.index("Restore browser fixtures") < workflow.index("Attest release packages")
with tempfile.TemporaryDirectory(prefix="loopx-release-recovery-") as temporary:
root = Path(temporary)

def git(*args: str) -> str:
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()

def commit(message: str) -> str:
git("add", "--", *paths, "product.txt")
git("commit", "--quiet", "-m", message)
return git("rev-parse", "HEAD")

git("init", "--quiet")
git("config", "user.name", "Release fixture smoke")
git("config", "user.email", "fixture@example.invalid")
for path in paths:
(root / path).parent.mkdir(parents=True, exist_ok=True)
(root / path).write_text("tagged fixture\n")
(root / "product.txt").write_text("immutable product\n")
product = commit("product")
for path in paths:
(root / path).write_text("corrected fixture\n")
(root / "product.txt").write_text("unrelated product change\n")
correction = commit("merged fixture correction and unrelated product change")
git("update-ref", "refs/remotes/origin/main", correction)
git("checkout", "--quiet", "--detach", product)
packages = root / "dist/packages"
packages.mkdir(parents=True)
(packages / f"loopx-{identity.version}-py3-none-any.whl").write_bytes(b"immutable wheel\n")
(packages / f"loopx-{identity.version}.tar.gz").write_bytes(b"immutable sdist\n")
(root / "dist/SHA256SUMS").write_text(release_artifacts.render_manifest(tuple(sorted(packages.iterdir()))))
(root / "scripts").mkdir()
shutil.copyfile(SCRIPT, root / "scripts/release_artifacts.py")
shutil.copyfile(ROOT / "pyproject.toml", root / "pyproject.toml")
(root / "loopx").mkdir()
shutil.copyfile(ROOT / "loopx/__init__.py", root / "loopx/__init__.py")
artifacts = [*packages.iterdir(), root / "dist/SHA256SUMS"]
before = {path.name: digest(path) for path in artifacts}
env = {**os.environ, "RELEASE_TAG": identity.tag, "GITHUB_SHA": correction,
"GITHUB_WORKFLOW_REF": "fixture/release-artifacts.yml@reviewed"}

def execute(block: str, fixture: str = "") -> subprocess.CompletedProcess[str]:
return subprocess.run(["bash", "-euc", block], cwd=root,
env={**env, "BROWSER_FIXTURE_COMMIT": fixture},
capture_output=True, text=True)

for selected in ("", correction):
result = execute(recover, selected)
assert result.returncode == 0, result
record = json.loads((root / "dist/RELEASE-SOURCES.json").read_text())
assert record["product_commit"] == product
assert record["browser_fixture_commit"] == (selected or product)
assert record["browser_fixture_paths"] == (sorted(paths) if selected else [])
assert (root / "product.txt").read_text() == "immutable product\n"
assert all((root / path).read_text() == ("corrected fixture\n" if selected else "tagged fixture\n") for path in paths)
result = execute(restore)
assert result.returncode == 0, result
assert git("diff", "--name-only") == ""
assert {path.name: digest(path) for path in artifacts} == before
assert json.loads((root / "dist/RELEASE-SOURCES.json").read_text())["sha256"] == before

# Invalid candidates must fail before copying even an earlier valid path.
candidates = [(correction[:12], "full commit SHA"), ("f" * 40, "")]
(root / paths[-1]).write_text("unmerged correction\n")
unmerged = commit("unmerged fixture")
candidates.append((unmerged, ""))
git("checkout", "--quiet", "--detach", product)
(root / "product.txt").write_text("only unrelated change\n")
unrelated = commit("unrelated merged commit")
git("update-ref", "refs/remotes/origin/main", unrelated)
candidates.append((unrelated, "no correction"))
for kind in ("symlink", "deleted"):
git("checkout", "--quiet", "--detach", product)
(root / paths[0]).write_text("must not be partially copied\n")
(root / paths[-1]).unlink()
if kind == "symlink":
(root / paths[-1]).symlink_to("fixture.mjs")
invalid = commit(kind)
git("update-ref", "refs/remotes/origin/main", invalid)
candidates.append((invalid, "regular source file"))
# Keep each rejected candidate merged except the explicit unmerged case.
git("merge", "--quiet", "-s", "ours", "--allow-unrelated-histories", unrelated, "-m", "merged candidates")
git("merge", "--quiet", "-s", "ours", candidates[-2][0], "-m", "merged symlink candidate")
git("update-ref", "refs/remotes/origin/main", "HEAD")
git("checkout", "--quiet", "--detach", product)
for candidate, error in candidates:
result = execute(recover, candidate)
assert result.returncode != 0, candidate
assert error in result.stderr, result
assert git("diff", "--name-only") == "", candidate
assert {path.name: digest(path) for path in artifacts} == before


def main() -> int:
identity = release_artifacts.load_identity(ROOT)
assert identity.name == "loopx", identity
Expand Down Expand Up @@ -148,6 +273,7 @@ def main() -> int:
workflow = (ROOT / ".github" / "workflows" / "release-artifacts.yml").read_text(
encoding="utf-8"
)
fixture_recovery_contract(workflow, identity)
required_contract = (
"release:\n types: [published]",
"python scripts/release_artifacts.py validate-tag",
Expand Down
Loading