From fd38d51eee4df15bbaf6c81343613ae88b8b5a69 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:29:44 +0800 Subject: [PATCH] fix(release): restore every admitted browser fixture Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .github/workflows/release-artifacts.yml | 29 ++++-- docs/product/release-readiness.md | 8 +- examples/release-artifacts-smoke.py | 126 +++++++++++++++++++++++- 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index b08a48c52b..d34b74769b 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -43,6 +43,13 @@ jobs: if: github.repository == 'loopx-project/loopx' runs-on: ubuntu-latest timeout-minutes: 20 + env: + # One fixture-only allowlist owns both extraction and restoration. + RELEASE_BROWSER_FIXTURE_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 permissions: contents: read id-token: write @@ -238,21 +245,20 @@ jobs: raise SystemExit("browser fixture correction requires a full commit SHA") if git("rev-parse", fixture + "^{commit}") != fixture: raise SystemExit("browser fixture correction must name a commit") - allowed = { - "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", - } + allowed = set(os.environ["RELEASE_BROWSER_FIXTURE_PATHS"].splitlines()) changed = set(git("diff", "--name-only", product, fixture, "--", *sorted(allowed)).splitlines()) if fixture != product and not changed: 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, @@ -364,7 +370,12 @@ jobs: - name: Restore browser fixtures and bind verified package checksums run: | - git restore -- examples/personal-workspace-browser/fixture.mjs examples/personal-workspace-browser/typed-actions.mjs examples/personal-workspace-browser/goal-work-map.mjs + set -euo pipefail + browser_fixture_paths=() + while IFS= read -r path; do + browser_fixture_paths+=("${path}") + done <<< "${RELEASE_BROWSER_FIXTURE_PATHS}" + git restore -- "${browser_fixture_paths[@]}" python scripts/release_artifacts.py verify-checksums --dist-dir dist/packages --checksum-file dist/SHA256SUMS python - <<'PY' import hashlib diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index f4b803e45c..6315cdfdc3 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -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. Extraction and +restoration share one fixture allowlist. All candidate files must remain regular +source files before any correction is copied; deletion or a symlink fails closed. +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 diff --git a/examples/release-artifacts-smoke.py b/examples/release-artifacts-smoke.py index 59dcb027af..d5bef97966 100644 --- a/examples/release-artifacts-smoke.py +++ b/examples/release-artifacts-smoke.py @@ -3,14 +3,18 @@ from __future__ import annotations -import importlib.util 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 @@ -48,6 +52,125 @@ 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", + ) + declared = workflow.split(" RELEASE_BROWSER_FIXTURE_PATHS: |-\n", 1)[1] + declared = declared.split(" permissions:", 1)[0] + assert tuple(textwrap.dedent(declared).splitlines()) == paths + recover = workflow_run(workflow, "Resolve browser fixture correction without rebuilding product bytes") + restore = workflow_run(workflow, "Restore browser fixtures and bind verified package checksums") + 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_BROWSER_FIXTURE_PATHS": "\n".join(paths), + "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 @@ -148,6 +271,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",