From 655c5871ea1789afea1c037c1b566a7be02b2bc6 Mon Sep 17 00:00:00 2001 From: maksyuki Date: Thu, 15 Oct 2026 02:57:30 +0800 Subject: [PATCH] feat(tests): implement historical publication fixture preparation and validation tests --- .github/GUIDE.md | 9 ++- .github/workflows/quality.yml | 3 + scripts/setup_helpers.py | 19 +++++ tests/README.md | 7 ++ tests/publication_history.py | 37 ++++++++- tests/test_publication_history.py | 125 ++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/test_publication_history.py diff --git a/.github/GUIDE.md b/.github/GUIDE.md index 46027c76..c687826b 100644 --- a/.github/GUIDE.md +++ b/.github/GUIDE.md @@ -13,10 +13,17 @@ to merge-commit integration. These documents do not configure branch protection. `workflows/quality.yml` runs the quality gate. It restores the locked IHP130 and Tiny ICS55 PDKs (including the locked SRAM, IO and PLL views), third-party simulation models, and APU/NPU references and installs the -locked Verilator, Icarus, sv2v, and Yosys tools before the complete Pytest +locked Verilator, Icarus, sv2v, Yosys and RISC-V GNU tools before the complete Pytest suite. The reference cache includes the NPU MLPerf Tiny models, MFCC inputs, TensorFlow/gemmlowp oracle sources, and decoded VWW corpus; setup still verifies every locked revision and checksum before tests run. + +The quality checkout retains repository history. After preparing current RTL +dependencies, `python3 tests/publication_history.py prepare` fetches any missing +dependency commits named by the immutable publication fixtures' historical +locks. It preserves active dependency HEADs and working trees; fixture export +and Pytest remain offline. + `workflows/regression-smoke.yml`, `workflows/regression-ihp130.yml`, `workflows/regression-gf180.yml`, `workflows/regression-ics55.yml`, `workflows/regression-sky130.yml`, and diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c4e9a8c0..53f140b4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -91,6 +91,9 @@ jobs: python3 rtl/managed/clusterip/setup.py python3 rtl/ip/setup.py + - name: Prepare historical publication dependencies + run: python3 tests/publication_history.py prepare + - name: Set up locked IHP130 PDK run: make CONFIG=configs/ci/ihp130.mk setup-pdk diff --git a/scripts/setup_helpers.py b/scripts/setup_helpers.py index 87d14c12..aa376827 100644 --- a/scripts/setup_helpers.py +++ b/scripts/setup_helpers.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import os +import re import subprocess import tempfile import time @@ -28,6 +29,24 @@ def git_output(repo: Path, *args: str) -> str: ).strip() +def ensure_git_commit(url: str, destination: Path, revision: str) -> None: + """Fetch a locked historical object without switching the active checkout.""" + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("historical dependency requires a full Git commit revision") + destination = destination.resolve() + if not (destination / ".git").exists(): + raise RuntimeError(f"prepare the current dependency checkout first: {destination}") + try: + git_output(destination, "cat-file", "-e", f"{revision}^{{commit}}") + except subprocess.CalledProcessError: + # No checkout/refspec: preserve HEAD, index, worktree and FETCH_HEAD. + run(("git", "fetch", "--no-tags", "--no-write-fetch-head", "--depth", "1", + url, revision), cwd=destination) + if git_output(destination, "rev-parse", f"{revision}^{{commit}}") != revision: + raise RuntimeError(f"historical dependency commit mismatch: {destination}: {revision}") + print(f"[dependency] historical object available: {destination.name}: {revision}") + + def ensure_git_repo( url: str, destination: Path, diff --git a/tests/README.md b/tests/README.md index 7d9c2426..5edfefaf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -116,3 +116,10 @@ python3 -m pytest -q These tests complement, but do not replace, firmware builds, RTL simulation, synthesis, timing, or hardware validation. + +Historical Tiny publication fixtures also need the main repository history +and dependency commit objects named by their archived locks. After ordinary +dependency setup, run `python3 tests/publication_history.py prepare` before +Pytest on a fresh checkout. This only fetches missing immutable Git objects; +it does not switch current managed checkouts or alter either lock. Historical +fixture export remains offline and fails if required inputs are unavailable. diff --git a/tests/publication_history.py b/tests/publication_history.py index fd45f9ed..a0df5bb5 100644 --- a/tests/publication_history.py +++ b/tests/publication_history.py @@ -1,4 +1,4 @@ -"""Materialize immutable, locally available publication test inputs. +"""Prepare historical Git objects, then export offline publication fixtures. This is a test fixture, never a publication promotion or a current-RTL verdict. Each exported tree keeps its own adapter, technical contract, tests and lock. @@ -8,12 +8,35 @@ from __future__ import annotations import io import json import subprocess +import sys import tarfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from scripts.setup_helpers import ensure_git_commit, git_output # noqa: E402 + ORIGINAL_BOOK = "2805ea91833a6bede32f3a82df3d17d20eb5ec77" P5_CONTRACT = "b3b30a72cd6ef5340c28e5044c96cd13258ec5a6" +HISTORICAL_REVISIONS = (ORIGINAL_BOOK, P5_CONTRACT) +HISTORICAL_SOURCES = ("cluster_common", "cluster_archinfo", "cluster_pwm", + "cluster_rtc", "cluster_wdg", "hazard3") + + +def prepare(root: Path = ROOT) -> None: + """Restore each fixture's locked objects, never switch active dependencies.""" + root = root.resolve() + inputs = set() + for revision in HISTORICAL_REVISIONS: + lock = json.loads(git_output(root, "show", f"{revision}:dependencies/dependencies.lock.json")) + for name in HISTORICAL_SOURCES: + source = lock["sources"][name] + destination = (root / source["destination"]).resolve() + if destination == root or not destination.is_relative_to(root): + raise ValueError(f"historical dependency destination escapes checkout: {name}") + inputs.add((source["url"], destination, source["revision"])) + for url, destination, revision in sorted(inputs): + ensure_git_commit(url, destination, revision) def export(repository: Path, revision: str, destination: Path, paths: list[str] = ()) -> None: @@ -32,9 +55,17 @@ def snapshot(destination: Path, revision: str) -> Path: "configs", "docs", "dependencies", "LICENSE", "Makefile", "README.md", "tests"]) lock = json.loads((destination / "dependencies/dependencies.lock.json").read_text()) - for name in ("cluster_common", "cluster_archinfo", "cluster_pwm", "cluster_rtc", - "cluster_wdg", "hazard3"): + for name in HISTORICAL_SOURCES: source = lock["sources"][name] export(ROOT / source["destination"], source["revision"], destination / source["destination"], ["hdl"] if name == "hazard3" else []) return destination + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("prepare",)) + parser.parse_args() + prepare() diff --git a/tests/test_publication_history.py b/tests/test_publication_history.py new file mode 100644 index 00000000..d528003d --- /dev/null +++ b/tests/test_publication_history.py @@ -0,0 +1,125 @@ +"""Cold shallow dependencies must support immutable publication snapshots.""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +import yaml + +import publication_history as history +from scripts import setup_helpers + + +def git(repository: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(repository), *args], text=True).strip() + + +def init(repository: Path) -> None: + repository.mkdir(parents=True) + git(repository, "init") + git(repository, "config", "user.name", "Fixture") + git(repository, "config", "user.email", "fixture@example.com") + git(repository, "config", "commit.gpgsign", "false") + + +def commit(repository: Path, path: str, content: str) -> str: + target = repository / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + git(repository, "add", path) + git(repository, "commit", "-m", "fixture") + return git(repository, "rev-parse", "HEAD") + + +@pytest.fixture +def shallow_dependency(tmp_path: Path): + upstream = tmp_path / "upstream" + init(upstream) + old = commit(upstream, "value.txt", "historical interface\n") + current = commit(upstream, "value.txt", "current interface\n") + checkout = tmp_path / "project/rtl/managed/pwm" + setup_helpers.ensure_git_repo(upstream.as_uri(), checkout, current) + assert git(checkout, "rev-parse", "--is-shallow-repository") == "true" + assert subprocess.run(["git", "-C", str(checkout), "cat-file", "-e", old + "^{commit}"], + capture_output=True).returncode != 0 + return upstream, checkout, old, current + + +def test_prepare_uses_historical_lock_and_preserves_active_checkout( + shallow_dependency, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + upstream, checkout, old, current = shallow_dependency + root = tmp_path / "project" + # The dependency directory already exists; initialize only the parent repo. + git(root, "init") + git(root, "config", "user.name", "Fixture") + git(root, "config", "user.email", "fixture@example.com") + git(root, "config", "commit.gpgsign", "false") + spec = {"url": upstream.as_uri(), "destination": "rtl/managed/pwm", "revision": old} + lock_path = "dependencies/dependencies.lock.json" + historical = commit(root, lock_path, json.dumps({"sources": {"cluster_pwm": spec}})) + spec["revision"] = current + commit(root, lock_path, json.dumps({"sources": {"cluster_pwm": spec}})) + lock_before = (root / lock_path).read_bytes() + monkeypatch.setattr(history, "HISTORICAL_REVISIONS", (historical,)) + monkeypatch.setattr(history, "HISTORICAL_SOURCES", ("cluster_pwm",)) + + # Reproduce CI: root history exists, but the old PWM object does not. + missing = subprocess.run(["git", "-C", str(checkout), "archive", old], capture_output=True) + assert missing.returncode != 0 and b"not a tree object" in missing.stderr + (checkout / "value.txt").write_text("user staged edit\n") + git(checkout, "add", "value.txt") + (checkout / "value.txt").write_text("user unstaged edit\n") + (checkout / "note.txt").write_text("user untracked file\n") + status_before = git(checkout, "status", "--porcelain") + index_before = git(checkout, "write-tree") + fetch_before = (checkout / ".git/FETCH_HEAD").read_bytes() + + history.prepare(root) + assert git(checkout, "rev-parse", "HEAD") == current + assert git(checkout, "write-tree") == index_before + assert git(checkout, "status", "--porcelain") == status_before + assert (checkout / ".git/FETCH_HEAD").read_bytes() == fetch_before + assert (checkout / "value.txt").read_text() == "user unstaged edit\n" + assert (checkout / "note.txt").read_text() == "user untracked file\n" + assert (root / lock_path).read_bytes() == lock_before + history.export(checkout, old, tmp_path / "historical-export") + assert (tmp_path / "historical-export/value.txt").read_text() == "historical interface\n" + + def no_fetch(*args, **kwargs): + pytest.fail("cached historical commits must be usable offline") + + monkeypatch.setattr(setup_helpers, "run", no_fetch) + history.prepare(root) + + +def test_failed_historical_fetch_preserves_checkout(shallow_dependency, tmp_path: Path) -> None: + _, checkout, old, current = shallow_dependency + with pytest.raises(subprocess.CalledProcessError): + setup_helpers.ensure_git_commit((tmp_path / "missing-upstream").as_uri(), checkout, old) + assert git(checkout, "rev-parse", "HEAD") == current + assert git(checkout, "status", "--porcelain") == "" + assert (checkout / "value.txt").read_text() == "current interface\n" + + +@pytest.mark.parametrize("revision", ["main", "1234", "x" * 40, "--all"]) +def test_historical_fetch_rejects_unpinned_revision(tmp_path: Path, revision: str) -> None: + with pytest.raises(ValueError, match="full Git commit"): + setup_helpers.ensure_git_commit("unused", tmp_path / "uncreated", revision) + assert not (tmp_path / "uncreated").exists() + + +def test_quality_prepares_history_before_offline_pytest() -> None: + root = Path(__file__).resolve().parents[1] + workflow = yaml.safe_load((root / ".github/workflows/quality.yml").read_text()) + steps = workflow["jobs"]["scripts"]["steps"] + checkout = next(step for step in steps if step["name"] == "Check out repository") + assert checkout["with"]["fetch-depth"] == 0 + dependencies = next(step for step in steps if step["name"] == "Set up locked RTL dependencies") + preparation = next(step for step in steps + if step.get("run") == "python3 tests/publication_history.py prepare") + tests = next(step for step in steps if step.get("run") == "python3 -m pytest -q") + assert steps.index(dependencies) < steps.index(preparation) < steps.index(tests) + assert "if" not in preparation and not preparation.get("continue-on-error", False)