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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/dropin-035-v0260-tag-ci-repair.yml
Original file line number Diff line number Diff line change
@@ -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/**
201 changes: 201 additions & 0 deletions tests/test_psych_design_public_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib.util
import io
import json
import subprocess
import tarfile
import zipfile
from pathlib import Path
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading
Loading