diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcf73e3..b4eacce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,6 +236,21 @@ jobs: # The Drop-in 034 source checker verifies the frozen candidate tag. fetch-depth: 0 + - name: Restore annotated v0.26.0 tag when present + run: | + $ErrorActionPreference = "Stop" + $remoteTag = @(git ls-remote --tags origin "refs/tags/v0.26.0") + if ($LASTEXITCODE -ne 0) { throw "Unable to inspect remote v0.26.0 tag" } + if ($remoteTag.Count -gt 0) { + git fetch --force origin "refs/tags/v0.26.0:refs/tags/v0.26.0" + if ($LASTEXITCODE -ne 0) { throw "Unable to restore annotated v0.26.0 tag" } + $objectType = (git cat-file -t "refs/tags/v0.26.0").Trim() + if ($objectType -ne "tag") { + throw "Expected annotated v0.26.0 tag object, found $objectType" + } + "PYSTATSV1_WINDOWS_CI_ANNOTATED_TAG_OK tag=v0.26.0" + } + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/dropin-035-v0260-tag-ci-repair.yml b/.github/workflows/dropin-035-v0260-tag-ci-repair.yml new file mode 100644 index 0000000..111ce0d --- /dev/null +++ b/.github/workflows/dropin-035-v0260-tag-ci-repair.yml @@ -0,0 +1,97 @@ +name: Drop-in 035 v0.26.0 tag CI repair validation + +# The original v0.26.0 push CI run checked out the annotated release tag through +# actions/checkout@v4, which can materialize a commit-shaped local tag ref for a +# tag-triggered checkout. This manual workflow preserves the immutable release +# tag and reruns the exact failed Windows job after explicitly restoring the +# annotated tag object from origin. +on: + workflow_dispatch: + inputs: + release_tag: + description: "Immutable annotated release tag to validate" + required: true + default: "v0.26.0" + type: string + +jobs: + windows-py310-tag-repair: + name: windows-py310-tag-repair + runs-on: windows-latest + timeout-minutes: 20 + env: + PYTHONIOENCODING: utf-8 + RELEASE_TAG: ${{ inputs.release_tag }} + defaults: + run: + shell: pwsh + + steps: + - name: Checkout exact release tag + uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + fetch-tags: true + + - name: Restore and verify annotated release tag object + run: | + $ErrorActionPreference = "Stop" + if ($env:RELEASE_TAG -ne "v0.26.0") { + throw "Drop-in 035 Repair 1 validates exactly v0.26.0" + } + + git fetch --force origin "refs/tags/$($env:RELEASE_TAG):refs/tags/$($env:RELEASE_TAG)" + + $objectType = (git cat-file -t "refs/tags/$($env:RELEASE_TAG)").Trim() + if ($objectType -ne "tag") { + throw "Expected annotated tag object, found $objectType" + } + + $taggedCommit = (git rev-parse "refs/tags/$($env:RELEASE_TAG)^{commit}").Trim() + $checkedOutCommit = (git rev-parse HEAD).Trim() + if ($taggedCommit -ne $checkedOutCommit) { + throw "Release tag does not resolve to checked-out source" + } + + "PYSTATSV1_DROPIN_035_REPAIR1_ANNOTATED_TAG_OK tag=$($env:RELEASE_TAG) commit=$taggedCommit" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + + - name: Install make (Chocolatey) + run: choco install make -y + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if (Test-Path requirements.txt) { pip install -r requirements.txt } else { pip install numpy pandas statsmodels matplotlib scipy } + if (Test-Path requirements-dev.txt) { pip install -r requirements-dev.txt } else { pip install ruff pytest } + pip install -e . + + - name: Lint + run: | + ruff --version + make lint + + - name: Build docs + run: make docs + + - name: Tests + run: make test + + - name: Tiny Chapter 13 smoke + run: make ch13-ci + + - name: Upload artifacts (plots & data) + if: always() + uses: actions/upload-artifact@v4 + with: + name: dropin-035-v0260-tag-repair-artifacts + if-no-files-found: ignore + path: | + data/synthetic/** + outputs/** diff --git a/tests/test_psych_design_public_release.py b/tests/test_psych_design_public_release.py index 09fd13a..826c105 100644 --- a/tests/test_psych_design_public_release.py +++ b/tests/test_psych_design_public_release.py @@ -3,6 +3,7 @@ import importlib.util import io import json +import subprocess import tarfile import zipfile from pathlib import Path @@ -40,6 +41,17 @@ "tools/verify_pystatsv1_public_release.py", ] +POST_TAG_REPAIR_PATHS = [ + ".github/workflows/ci.yml", + ".github/workflows/dropin-035-v0260-tag-ci-repair.yml", + "tests/test_psych_design_public_release.py", + "tools/check_psych_design_public_release.py", +] +PUBLIC_RECEIPT_RELATIVE = ( + "release_proofs/psych_design_v0_1/evidence/" + "PSYCH_DESIGN_PUBLIC_RELEASE.json" +) + def load(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) @@ -224,6 +236,151 @@ def test_technical_readiness_checker_accepts_completed_dropin_034() -> None: checker().verify_technical_readiness() +def _scope_git_stub(module, post_tag_paths: list[str]): + release_commit = "a" * 40 + parent = load(AUTHORIZATION)["release_identity"]["approved_parent_commit"] + + def fake_git(*args: str, check: bool = True) -> str: + del check + if args == ("cat-file", "-e", f"{parent}^{{commit}}"): + return "" + if args == ("diff", "--name-only", "HEAD"): + return "" + if args == ("ls-files", "--others", "--exclude-standard"): + return "" + if args == ("tag", "--list"): + return module.EXPECTED_RELEASE_TAG + if args == ( + "cat-file", + "-t", + f"refs/tags/{module.EXPECTED_RELEASE_TAG}", + ): + return "tag" + if args == ( + "rev-parse", + f"{module.EXPECTED_RELEASE_TAG}^{{commit}}", + ): + return release_commit + if args == ("diff", "--name-only", f"{parent}..{release_commit}"): + return "\n".join(EXPECTED_PATHS) + if args == ("diff", "--name-only", f"{release_commit}..HEAD"): + return "\n".join(post_tag_paths) + raise AssertionError(f"unexpected git call: {args}") + + return fake_git + + +def _patch_release_scope_git( + module, + monkeypatch: pytest.MonkeyPatch, + post_tag_paths: list[str], + receipt_status: str, +) -> None: + receipt = load(PUBLIC_RECEIPT) + receipt["status"] = receipt_status + monkeypatch.setattr(module, "_has_git", lambda: True) + monkeypatch.setattr(module, "_git", _scope_git_stub(module, post_tag_paths)) + monkeypatch.setattr(module, "load", lambda _path: receipt) + monkeypatch.setattr( + module.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0), + ) + + +def test_pending_post_tag_path_policy_is_exact_and_bounded() -> None: + module = checker() + receipt = load(PUBLIC_RECEIPT) + receipt["status"] = "owner_authorized_pending_publication" + assert module.expected_post_tag_paths(receipt) == POST_TAG_REPAIR_PATHS + + +def test_verified_post_tag_path_policy_adds_only_public_receipt() -> None: + module = checker() + receipt = load(PUBLIC_RECEIPT) + receipt["status"] = "public_release_verified" + assert module.expected_post_tag_paths(receipt) == sorted( + POST_TAG_REPAIR_PATHS + [PUBLIC_RECEIPT_RELATIVE] + ) + + +def test_release_source_scope_accepts_exact_pending_post_tag_repairs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + _patch_release_scope_git( + module, + monkeypatch, + POST_TAG_REPAIR_PATHS, + "owner_authorized_pending_publication", + ) + module.verify_release_source_paths(load(AUTHORIZATION)) + + +def test_release_source_scope_rejects_extra_post_tag_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + _patch_release_scope_git( + module, + monkeypatch, + POST_TAG_REPAIR_PATHS + ["src/pystatsv1/cli.py"], + "owner_authorized_pending_publication", + ) + with pytest.raises( + module.PublicReleaseCheckError, + match="post-tag path scope differs", + ): + module.verify_release_source_paths(load(AUTHORIZATION)) + + +def test_release_source_scope_rejects_missing_governed_repair_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + _patch_release_scope_git( + module, + monkeypatch, + POST_TAG_REPAIR_PATHS[:-1], + "owner_authorized_pending_publication", + ) + with pytest.raises( + module.PublicReleaseCheckError, + match="post-tag path scope differs", + ): + module.verify_release_source_paths(load(AUTHORIZATION)) + + +def test_release_source_scope_accepts_verified_public_receipt_delta( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + _patch_release_scope_git( + module, + monkeypatch, + POST_TAG_REPAIR_PATHS + [PUBLIC_RECEIPT_RELATIVE], + "public_release_verified", + ) + module.verify_release_source_paths(load(AUTHORIZATION)) + + +def test_release_source_scope_rejects_public_receipt_before_verification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + _patch_release_scope_git( + module, + monkeypatch, + POST_TAG_REPAIR_PATHS + [PUBLIC_RECEIPT_RELATIVE], + "owner_authorized_pending_publication", + ) + with pytest.raises( + module.PublicReleaseCheckError, + match="post-tag path scope differs", + ): + module.verify_release_source_paths(load(AUTHORIZATION)) + + def _write_wheel(path: Path, module, *, asset: bytes | None = None, version: str = "0.26.0") -> None: asset = ASSET.read_bytes() if asset is None else asset with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: @@ -461,3 +618,47 @@ def test_public_receipt_checker_rejects_open_non_pypi_gate( monkeypatch.setattr(module, "verify_release_tag", lambda *_args, **_kwargs: "c" * 40) with pytest.raises(module.PublicReleaseCheckError, match="opens closed gate"): module.verify_pending_public_receipt() + + +def test_dropin_035_tag_ci_repair_workflow_preserves_immutable_annotated_tag() -> None: + workflow = ( + ROOT / ".github/workflows/dropin-035-v0260-tag-ci-repair.yml" + ).read_text(encoding="utf-8") + + assert "workflow_dispatch:" in workflow + assert 'default: "v0.26.0"' in workflow + assert "windows-py310-tag-repair:" in workflow + assert "ref: ${{ inputs.release_tag }}" in workflow + assert "fetch-depth: 0" in workflow + assert "fetch-tags: true" in workflow + assert ( + 'git fetch --force origin "refs/tags/$($env:RELEASE_TAG):refs/tags/$($env:RELEASE_TAG)"' + in workflow + ) + assert 'git cat-file -t "refs/tags/$($env:RELEASE_TAG)"' in workflow + assert 'git rev-parse "refs/tags/$($env:RELEASE_TAG)^{commit}"' in workflow + assert "git rev-parse HEAD" in workflow + assert "PYSTATSV1_DROPIN_035_REPAIR1_ANNOTATED_TAG_OK" in workflow + assert "make lint" in workflow + assert "make docs" in workflow + assert "make test" in workflow + assert "make ch13-ci" in workflow + assert "gh-action-pypi-publish" not in workflow + assert "git tag -f" not in workflow + assert "git push --force" not in workflow + + +def test_windows_ci_restores_annotated_v0260_tag_before_full_tests() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + windows_job = workflow.split(" windows-py310:", maxsplit=1)[1] + restore = windows_job.split(" - name: Set up Python", maxsplit=1)[0] + + assert "Restore annotated v0.26.0 tag when present" in restore + assert 'git ls-remote --tags origin "refs/tags/v0.26.0"' in restore + assert ( + 'git fetch --force origin "refs/tags/v0.26.0:refs/tags/v0.26.0"' + in restore + ) + assert 'git cat-file -t "refs/tags/v0.26.0"' in restore + assert 'if ($objectType -ne "tag")' in restore + assert "PYSTATSV1_WINDOWS_CI_ANNOTATED_TAG_OK" in restore diff --git a/tools/check_psych_design_public_release.py b/tools/check_psych_design_public_release.py index 2daf603..3ecb7ac 100755 --- a/tools/check_psych_design_public_release.py +++ b/tools/check_psych_design_public_release.py @@ -44,6 +44,16 @@ "book_binding_authorized", "real_data_authorized", ) +POST_TAG_CI_REPAIR_PATHS = ( + ".github/workflows/ci.yml", + ".github/workflows/dropin-035-v0260-tag-ci-repair.yml", + "tests/test_psych_design_public_release.py", + "tools/check_psych_design_public_release.py", +) +PUBLIC_RECEIPT_RELATIVE = ( + "release_proofs/psych_design_v0_1/evidence/" + "PSYCH_DESIGN_PUBLIC_RELEASE.json" +) class PublicReleaseCheckError(RuntimeError): @@ -189,23 +199,79 @@ def verify_frozen_asset() -> None: fail("governed companion asset hash changed") +def expected_post_tag_paths(receipt: dict[str, Any]) -> list[str]: + status = receipt.get("status") + paths = set(POST_TAG_CI_REPAIR_PATHS) + if status == "public_release_verified": + paths.add(PUBLIC_RECEIPT_RELATIVE) + elif status != "owner_authorized_pending_publication": + fail("public-release receipt has an invalid lifecycle status") + return sorted(paths) + + def verify_release_source_paths(auth: dict[str, Any]) -> None: expected = sorted(auth.get("allowed_release_commit_paths", [])) if len(expected) != len(set(expected)) or len(expected) != 13: fail("authorized path list is invalid") if not _has_git(): return + parent = auth["release_identity"]["approved_parent_commit"] _git("cat-file", "-e", f"{parent}^{{commit}}") - committed = _git("diff", "--name-only", f"{parent}..HEAD").splitlines() working = _git("diff", "--name-only", "HEAD").splitlines() untracked = _git("ls-files", "--others", "--exclude-standard").splitlines() - actual = sorted(set(committed + working + untracked)) - if actual != expected: - fail( - "release-source path scope differs from authorization; " - f"expected={expected}, actual={actual}" + tags = set(_git("tag", "--list").splitlines()) + + if EXPECTED_RELEASE_TAG not in tags: + committed = _git("diff", "--name-only", f"{parent}..HEAD").splitlines() + actual = sorted(set(committed + working + untracked)) + if actual != expected: + fail( + "release-source path scope differs from authorization; " + f"expected={expected}, actual={actual}" + ) + else: + if _git("cat-file", "-t", f"refs/tags/{EXPECTED_RELEASE_TAG}") != "tag": + fail("v0.26.0 must be an annotated tag") + release_commit = _git("rev-parse", f"{EXPECTED_RELEASE_TAG}^{{commit}}") + tagged_paths = sorted( + path + for path in _git( + "diff", "--name-only", f"{parent}..{release_commit}" + ).splitlines() + if path ) + if tagged_paths != expected: + fail("v0.26.0 does not resolve to the authorized source diff") + completed = subprocess.run( + [ + "git", + "-C", + str(ROOT), + "merge-base", + "--is-ancestor", + release_commit, + "HEAD", + ], + check=False, + ) + if completed.returncode != 0: + fail("v0.26.0 release commit is not an ancestor of HEAD") + + committed_after_tag = _git( + "diff", "--name-only", f"{release_commit}..HEAD" + ).splitlines() + actual_post_tag = sorted( + set(committed_after_tag + working + untracked) + ) + expected_post_tag = expected_post_tag_paths(load(PUBLIC_RECEIPT)) + if actual_post_tag != expected_post_tag: + fail( + "post-tag path scope differs from governed repair lifecycle; " + f"expected={expected_post_tag}, actual={actual_post_tag}" + ) + actual = sorted(set(tagged_paths + actual_post_tag)) + forbidden_prefixes = ( "src/", "psych_design_companion/",