Each of the three was merged on evidence I could not reach myself. This replaces that trust with a test that runs on every platform, on every CI run. 1. Windows lock (#1261). The no-sentinel fix was unverifiable on POSIX — msvcrt is None, so the branch never executes and a regression would be invisible to Linux and macOS CI. tests/test_windows_lock_no_sentinel.py swaps the module-level fcntl/msvcrt handles for stubs and drives the Windows branch anywhere, asserting behaviour (an empty file stays empty, the first ledger append succeeds and verify_chain passes) rather than 'was locking() called'. Restoring the sentinel in all three lock sites fails 3 of the 4 tests, including the first-append regression itself. 2. Dividend cash column (#1356). A dividend row has no quantity and no price, so 'amount or qty * price' collapses to 0.0 when the export books the payout under a cash-movement heading instead of the fill column. That is a silent zero — the shape #1207 item 15 is about. Dividend rows now also try 发生金额 / 资金发生额 / 红利金额 / 实发金额 / 税后金额 and Futu's net-amount columns, across all four parsers; anything still zero is counted into a new dividend_rows_missing_cash profile field so the caller can SEE that the journal was read but the money was not. 3. Docker action majors (#1359). The v4/v6/v7 bump had never executed in this repository: docker-build.yml is workflow_dispatch only, total_runs=0. Every input the workflow passes was checked against each new major's action.yml at the pinned SHA (13 inputs, all present; the two setup actions take none). A validate-build job now runs the SAME pins with push: false on any change to the Dockerfile, this workflow, or pyproject, and the publish job is gated to workflow_dispatch so the new triggers cannot publish an image. That gate is one 'if:' line, so it is pinned too — removing it, flipping validate-build to push: true, or unpinning an action each fail a test. The file comment claiming these majors were 'a deliberate, separately testable bump, not a drive-by change here' contradicted the file after #1359 and is replaced by what is actually true now.
117 lines
4.5 KiB
Python
117 lines
4.5 KiB
Python
"""The Docker publish job must stay manual; the validation job must never push.
|
|
|
|
`docker-build.yml` gained `pull_request` / `push` triggers so the pinned
|
|
docker/* actions and the Dockerfile are exercised by CI rather than discovered
|
|
broken by whoever is mid-release. That is only safe while the publishing job
|
|
stays gated to `workflow_dispatch`: without the gate, those new triggers would
|
|
publish an image to GHCR and Docker Hub on every commit that touches the
|
|
Dockerfile.
|
|
|
|
The guard is one `if:` expression, which is exactly the kind of line a later
|
|
edit drops silently — nothing else in the repository would notice. These tests
|
|
are offline and read the workflow as data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
_WORKFLOW = Path(__file__).resolve().parents[2] / ".github" / "workflows" / "docker-build.yml"
|
|
|
|
|
|
def _workflow() -> dict:
|
|
parsed = yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8"))
|
|
assert isinstance(parsed, dict), "workflow did not parse as a mapping"
|
|
return parsed
|
|
|
|
|
|
def _triggers(parsed: dict) -> dict:
|
|
# PyYAML parses a bare `on:` key as the boolean True.
|
|
return parsed[True] if True in parsed else parsed["on"]
|
|
|
|
|
|
def _push_flags(job: dict) -> list:
|
|
return [
|
|
step.get("with", {}).get("push")
|
|
for step in job["steps"]
|
|
if "build-push-action" in str(step.get("uses", ""))
|
|
]
|
|
|
|
|
|
def test_publishing_job_is_workflow_dispatch_only() -> None:
|
|
"""A commit touching the Dockerfile must not be able to publish an image."""
|
|
jobs = _workflow()["jobs"]
|
|
publishing = {
|
|
name: job for name, job in jobs.items() if True in _push_flags(job)
|
|
}
|
|
assert publishing, "no publishing job found — did the push flag change?"
|
|
for name, job in publishing.items():
|
|
assert job.get("if") == "github.event_name == 'workflow_dispatch'", (
|
|
f"job {name!r} pushes images but is not gated to workflow_dispatch; "
|
|
"with the pull_request/push triggers present this publishes on "
|
|
"every Dockerfile change"
|
|
)
|
|
|
|
|
|
def test_validation_job_never_pushes() -> None:
|
|
"""The job that runs on ordinary commits must build only."""
|
|
jobs = _workflow()["jobs"]
|
|
assert "validate-build" in jobs, "the no-push validation job was removed"
|
|
flags = _push_flags(jobs["validate-build"])
|
|
assert flags, "validate-build no longer runs build-push-action"
|
|
assert all(flag is False for flag in flags), (
|
|
f"validate-build must pass push: false, got {flags}"
|
|
)
|
|
|
|
|
|
def test_validation_job_uses_the_same_action_pins_as_the_publish_job() -> None:
|
|
"""A validation that ran different versions would prove nothing.
|
|
|
|
The point of this job is to exercise the pins the release build uses, so a
|
|
drift between the two makes the check decorative.
|
|
"""
|
|
jobs = _workflow()["jobs"]
|
|
|
|
def pins(job: dict) -> set[str]:
|
|
return {
|
|
str(step["uses"])
|
|
for step in job["steps"]
|
|
if str(step.get("uses", "")).startswith("docker/")
|
|
}
|
|
|
|
validate = pins(jobs["validate-build"])
|
|
publish = pins(jobs["build-and-push"])
|
|
shared = {p.split("@")[0] for p in validate} & {p.split("@")[0] for p in publish}
|
|
assert shared, "the two jobs share no docker/* actions"
|
|
for action in shared:
|
|
v = {p for p in validate if p.startswith(action + "@")}
|
|
p_ = {p for p in publish if p.startswith(action + "@")}
|
|
assert v == p_, f"{action} is pinned differently: validate={v} publish={p_}"
|
|
|
|
|
|
def test_every_docker_action_is_pinned_to_a_sha() -> None:
|
|
"""A moving tag would silently change what the release build runs."""
|
|
parsed = _workflow()
|
|
for name, job in parsed["jobs"].items():
|
|
for step in job["steps"]:
|
|
uses = str(step.get("uses", ""))
|
|
if not uses.startswith("docker/"):
|
|
continue
|
|
ref = uses.split("@", 1)[1]
|
|
assert len(ref) == 40 and all(
|
|
c in "0123456789abcdef" for c in ref
|
|
), f"{name}: {uses} is not pinned to a full commit SHA"
|
|
|
|
|
|
def test_triggers_include_the_paths_that_can_break_the_image() -> None:
|
|
"""Without these paths the validation job never runs on the real changes."""
|
|
triggers = _triggers(_workflow())
|
|
for event in ("pull_request", "push"):
|
|
assert event in triggers, f"{event} trigger missing"
|
|
paths = set(triggers[event]["paths"])
|
|
assert {"Dockerfile", ".github/workflows/docker-build.yml"} <= paths, (
|
|
f"{event} paths do not cover the Dockerfile and this workflow: {paths}"
|
|
)
|