From 143753345e6cf9432037839e16c121722886f4d1 Mon Sep 17 00:00:00 2001 From: yeongseon Date: Mon, 12 Oct 2026 01:28:03 +0900 Subject: [PATCH 1/2] ci(release): run the candidate gate on every build, resume included Builds are not reproducible, so a tagged resume (which rebuilds) could upload bytes no cookbook cell had seen. verify-candidate now runs after every successful build, with no tag_state skip; a `gh run rerun --failed` of the same run keeps its earlier, verified results and artifact. require-candidate: the report download continues on error so the check lists every failed reason, and the report must come from this repository (run.caller_repository == GITHUB_REPOSITORY). verify_dist.py reports an OSError as ::error:: instead of a traceback. Refs cubrid-lab/cubrid-cookbook-python#240 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish-pypi.yml | 36 +++++++--------- CHANGELOG.md | 9 ++-- RELEASING.md | 30 +++++++------ scripts/release_summary.py | 15 ++----- scripts/verify_dist.py | 5 ++- tests/test_release_summary.py | 17 ++++++-- tests/test_release_workflows.py | 68 +++++++++++++++++------------- tests/test_verify_dist.py | 11 +++++ 8 files changed, 106 insertions(+), 85 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 567caa4f..80cd4047 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -16,8 +16,7 @@ # release notes and SBOM; artifacts kept 14 days for recovery # verify-candidate cubrid-cookbook-python smoke-test.yml in candidate mode on # the `release-dist` wheel of this run, before anything is -# published (skipped only to complete a publish whose tag is -# already at the release commit) +# published; every run that builds runs it (resume included) # require-candidate status success, evidence `candidate artifact`, installed # == requested == the version, the verified wheel digest == the # build job's digest, and the report names this package, request, @@ -272,16 +271,14 @@ jobs: # The cookbook smoke test in candidate mode, on the exact `release-dist` bytes # this run built, before anything is tagged or uploaded (cubrid-cookbook-python # docs/internal/rc-verification-design.md, section 13). Same pin as - # verify-cookbook. Skipped only when completing a publish whose tag already - # points at the release commit: the version is then partly public, and the - # candidate gate already passed when that tag was created, unless the release - # was tagged before this gate existed. + # verify-cookbook. Never skipped after a successful build: builds are not + # reproducible, so a `resume` (which rebuilds) must verify its own bytes. A + # `gh run rerun --failed` of the same run keeps this job's earlier success, + # because it reuses the same build outputs and `release-dist` artifact. verify-candidate: name: Cookbook candidate verification needs: [detect, build] - if: >- - needs.build.result == 'success' && - !(needs.detect.outputs.publish == 'true' && needs.detect.outputs.tag_state == 'same') + if: needs.build.result == 'success' permissions: contents: read # all the called workflow needs uses: cubrid-lab/cubrid-cookbook-python/.github/workflows/smoke-test.yml@98866c2fff9ed4745bd0f63aec7d619c1830895e # main @@ -297,7 +294,8 @@ jobs: candidate_source_sha: ${{ needs.detect.outputs.sha }} # Enforces the candidate outputs and report; publish needs this job. A status - # function keeps a skipped verify-candidate (tagged resume) from skipping it. + # function keeps a failed verify-candidate from skipping it, so the failed + # checks are listed. require-candidate: name: Require a verified candidate needs: [detect, build, verify-candidate] @@ -308,9 +306,10 @@ jobs: steps: # The report names the package, source commit, artifact and files the # cookbook verified; the call outputs alone do not. It is an artifact of - # this run, so the download needs no token permission. + # this run, so the download needs no token permission. A missing report + # does not stop here: the check below then lists every failed check. - name: Download the candidate verification report - if: needs.verify-candidate.result != 'skipped' + continue-on-error: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-candidate-verification-${{ env.PACKAGE }}-rc-v${{ needs.detect.outputs.version }}-${{ github.run_id }} @@ -328,13 +327,7 @@ jobs: DIST_SHA256: ${{ needs.build.outputs.sha256 }} VERSION: ${{ needs.detect.outputs.version }} SHA: ${{ needs.detect.outputs.sha }} - PUBLISH: ${{ needs.detect.outputs.publish }} - TAG_STATE: ${{ needs.detect.outputs.tag_state }} run: | - if [ "$PUBLISH" = true ] && [ "$TAG_STATE" = same ] && [ "$RESULT" = skipped ]; then - echo "::notice::tag already at the release commit; completing a partial publish without the candidate gate" - exit 0 - fi python3 - <<'PY' import json, os, pathlib, sys env = os.environ @@ -384,6 +377,8 @@ jobs: and candidate.get("wheel") == wheels[0] and candidate.get("candidate_sha256") == expected, "report is from this run": str(run.get("id")) == env["GITHUB_RUN_ID"], + "report is from this repository": run.get("caller_repository") + == env["GITHUB_REPOSITORY"], } failed = [name for name, ok in checks.items() if not ok] if failed: @@ -395,9 +390,8 @@ jobs: publish: name: Tag, GitHub Release and PyPI needs: [detect, consistency, matrix, build, require-candidate] - # A status function is required: without one the condition is an implicit - # success() over every ancestor, and verify-candidate is skipped on a - # tagged resume. Only these explicit results gate publish. + # !cancelled() plus explicit results: only these jobs gate publish, and + # require-candidate must have succeeded in this run. if: >- !cancelled() && needs.detect.outputs.publish == 'true' && needs.consistency.result == 'success' && needs.matrix.result == 'success' && diff --git a/CHANGELOG.md b/CHANGELOG.md index 840c438a..a1c61732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -209,12 +209,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). the `build` job's `sha256` output), and `require-candidate`, which requires `status == success`, `evidence == candidate artifact`, installed == requested == the release version and the verified digest == the built wheel's digest, and - whose downloaded candidate report must name this package, request and run, + whose downloaded candidate report must name this package, request, run and + repository, `release-dist`, the release commit and the build's file map. `publish` needs `require-candidate` and now also checks `dist/` and `release-meta/` against the `build` job outputs `sha256` and the new - `meta_sha256` (`scripts/verify_dist.py`) before tagging. The gate is skipped - only to complete a publish whose tag already points at the release commit. A + `meta_sha256` (`scripts/verify_dist.py`) before tagging. The gate is never + skipped after a successful build, `resume` included: builds are not + reproducible, so every run that publishes verifies its own bytes (a + `gh run rerun --failed` of the same run keeps its earlier verification). A `dry-run` dispatch now verifies the freshly built wheel instead of the already-published version; the post-publication `verify-cookbook` is unchanged. Both cookbook calls use one pin, moved to `98866c2`. sqlalchemy-cubrid diff --git a/RELEASING.md b/RELEASING.md index 1e04911a..9ece535c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -161,8 +161,8 @@ and chained with explicit `needs:` (tags and Releases created with | `consistency` | `make release-check VERSION=X.Y.Z` at the SHA: `__version__`, CHANGELOG lint and dated section, `build` + `twine check`. | | `matrix` | The full Python × CUBRID matrix, TLS, EUC-KR, version-differential and official-differential lanes, and the Python 3.11/3.15 offline suite with the 95% coverage floor (`offline-endpoints`), and copies of the `ci.yml` `lint`, `typecheck`, `compat-check` (public API) and `repo-tooling-tests` jobs (#750): `integration-full.yml` called through `workflow_call` at the SHA. Its `full-matrix-result` job fails unless every one of these jobs succeeds. | | `build` | Builds the wheel and sdist **once**, `twine check`, wheel/sdist install smoke tests, extracts the release notes (the CHANGELOG section byte for byte plus one `**Full Changelog**` link to the previous CHANGELOG version; none for the first release or when the section already has a compare link), generates the SPDX SBOM and records SHA-256 hashes. Artifacts `release-dist` and `release-meta` are kept for 14 days. | -| `verify-candidate` | Calls the same pinned cookbook smoke test in **candidate mode** before anything is published: `mode: candidate`, `candidate_artifact: release-dist`, `candidate_hashes` = the `build` job's `sha256` output, `candidate_source_sha` = the release SHA, and `request_id: pycubrid-rc-vX.Y.Z-` (no run attempt, so `gh run rerun --failed` replaces only the failed cells). The cookbook checks the artifact's file set and every SHA-256, installs only that wheel and runs the release smoke on it. Skipped only to complete a publish whose tag already points at the release commit. | -| `require-candidate` | Fails unless the call succeeded and its outputs report `status == success`, `evidence == candidate artifact`, `installed_version == requested_version == X.Y.Z` and `candidate_sha256` equal to the wheel's entry in the `build` job's `sha256` map. It also downloads the candidate report `release-candidate-verification-` of this run and requires it to name this package, `request_id`, version and run, the artifact `release-dist`, the release commit as `source_sha`, exactly the `build` job's file map, and the same verified wheel and digest. Every value reaches the check through `env:` or the downloaded file, never through shell interpolation. It holds no permission. `publish` needs this job. | +| `verify-candidate` | Calls the same pinned cookbook smoke test in **candidate mode** before anything is published: `mode: candidate`, `candidate_artifact: release-dist`, `candidate_hashes` = the `build` job's `sha256` output, `candidate_source_sha` = the release SHA, and `request_id: pycubrid-rc-vX.Y.Z-` (no run attempt, so `gh run rerun --failed` replaces only the failed cells). The cookbook checks the artifact's file set and every SHA-256, installs only that wheel and runs the release smoke on it. It runs after every successful build, `resume` included; there is no skip. | +| `require-candidate` | Fails unless the call succeeded and its outputs report `status == success`, `evidence == candidate artifact`, `installed_version == requested_version == X.Y.Z` and `candidate_sha256` equal to the wheel's entry in the `build` job's `sha256` map. It also downloads the candidate report `release-candidate-verification-` of this run and requires it to name this package, `request_id`, version, run and calling repository, the artifact `release-dist`, the release commit as `source_sha`, exactly the `build` job's file map, and the same verified wheel and digest. Every value reaches the check through `env:` or the downloaded file, never through shell interpolation. It holds no permission. `publish` needs this job. | | `publish` | In the `pypi` environment: checks `dist/` and `release-meta/` against the `build` job outputs `sha256` and `meta_sha256` (`scripts/verify_dist.py`: exact file set, every digest) and re-checks `SHA256SUMS`, creates the annotated tag `vX.Y.Z` at the SHA (or accepts one already there), creates a **draft** GitHub Release titled exactly `vX.Y.Z` with the notes and `sbom.spdx.json` (an existing Release found on resume must already carry that title, or the job fails closed; it is never renamed), uploads the same artifact to PyPI through `scripts/pypi_duplicate_guard.py` and Trusted Publishing (OIDC), then publishes the Release. | | `verify-cookbook` | Calls the cookbook smoke test (`smoke-test.yml` of cubrid-cookbook-python) as a **reusable workflow** with `package=pycubrid`, `version=X.Y.Z` and a `request_id`; its jobs run inside this release run. | | `require-cookbook` | Fails unless the called workflow succeeded and its outputs report `status == success` with `installed_version == requested_version == X.Y.Z`. | @@ -172,10 +172,10 @@ Only `publish` has write access (`contents: write` for the tag and Release, `id-token: write` for PyPI); every other job reads (`require-candidate` and `require-cookbook` hold no permission at all). `publish` runs only when `consistency`, `matrix`, `build` and `require-candidate` all succeeded -(`!cancelled()` plus each explicit result, so a skipped `verify-candidate` on a -tagged resume does not skip `publish`). It uploads the same `release-dist` -bytes the cookbook verified, never a rebuild: the trust anchor is the `build` -job's outputs, which no later job of the run can change, not the artifacts. +in this run (`!cancelled()` plus each explicit result). It uploads the same +`release-dist` bytes this run's cookbook candidate call verified, never another +build's: the trust anchor is the `build` job's outputs, which no later job of +the run can change, not the artifacts. #### Candidate verification (before publish) @@ -205,13 +205,15 @@ copy. The other drivers come from PyPI, so a candidate that needs an unpublished release of another cubrid-lab package fails here: release dependent packages one after the other. -`verify-candidate` is skipped, and `require-candidate` passes with a notice, -only when `detect` reports `publish == true` and `tag_state == same` (a -`resume`, or a push whose tag already points at the release commit). The tag -is created only after `require-candidate` passed, so the version already -passed the gate (or predates it), and blocking the completion of a partly -public release would protect nobody. A `verify-only` dispatch builds nothing, -so it has no candidate. +The candidate gate is never skipped once `build` succeeded, whatever the tag +state. Builds are not reproducible (the same commit gives a wheel with another +SHA-256), so a `resume` dispatch, which rebuilds, must verify its own bytes +before it can upload them. To finish a partial publish with the bytes that +were already verified, use `gh run rerun --failed` of the **same** +run within the 14-day artifact retention: it keeps the earlier `build`, +`verify-candidate` and `require-candidate` results and the same `release-dist` +artifact, and only re-runs what failed. A `verify-only` dispatch builds +nothing and publishes nothing, so it has no candidate. #### Cookbook verification @@ -353,7 +355,7 @@ creates a new version, never moves a tag and never deletes anything. | `action` | Allowed when | Runs | | --- | --- | --- | -| `resume` | Dispatched from `main`; tag `vX.Y.Z` exists; its commit is on `main`; `__version__` and a dated CHANGELOG section at that commit equal `X.Y.Z`. | consistency → matrix → build → publish → verify at the tag commit (the tag exists, so the candidate gate is skipped). For an interrupted release whose run can no longer be rerun (for example the artifacts expired before anything reached PyPI). A rebuilt file that differs from one already on PyPI fails the guard. | +| `resume` | Dispatched from `main`; tag `vX.Y.Z` exists; its commit is on `main`; `__version__` and a dated CHANGELOG section at that commit equal `X.Y.Z`. | consistency → matrix → build → publish → verify at the tag commit (the rebuilt wheel goes through the candidate gate like any other build). For an interrupted release whose run can no longer be rerun (for example the artifacts expired before anything reached PyPI). A rebuilt file that differs from one already on PyPI fails the guard. | | `verify-only` | Same conditions as `resume`. | Only `verify-cookbook`, `require-cookbook` and `summary` for the already-published version, through the same reusable cookbook workflow. | | `dry-run` | Any branch; `X.Y.Z` must equal `__version__` at the dispatched commit and have a dated CHANGELOG section. | consistency → matrix → build → verify-candidate → require-candidate, **no** tag, Release or upload (`publish` needs `detect`'s `publish == true`, which a dry run never sets, and the `pypi` environment allows only `main`). The cookbook verifies the wheel this run built; the post-publication `verify-cookbook` does not run. | diff --git a/scripts/release_summary.py b/scripts/release_summary.py index d642c6dc..929c7750 100644 --- a/scripts/release_summary.py +++ b/scripts/release_summary.py @@ -13,8 +13,8 @@ only when both jobs succeeded and the outputs report ``status == success``, ``evidence == candidate artifact``, ``installed_version == requested_version`` == the release version and ``candidate_sha256`` == the wheel digest of the -``build`` job; it is ``skipped`` only when completing a publish whose tag is -already at the release commit. The cookbook verification is ``success`` only +``build`` job; any other result after a successful build, ``resume`` +included, is a failure. The cookbook verification is ``success`` only when the called smoke-test workflow (``verify-cookbook``) and ``require-cookbook`` succeeded and its outputs report ``status == success`` with ``installed_version == requested_version`` == the released version; a @@ -53,13 +53,6 @@ def verification_due(needs: dict[str, Any]) -> bool: return job(needs, "publish")[0] == "success" or detect.get("mode") == "verify-only" -def tagged_resume(needs: dict[str, Any]) -> bool: - """A publish completing at a tag already at the release commit skips the - candidate gate (mirrors the ``verify-candidate`` condition).""" - _, detect = job(needs, "detect") - return detect.get("publish") == "true" and detect.get("tag_state") == "same" - - def built_wheel_digest(needs: dict[str, Any]) -> str: """The SHA-256 of the single wheel in the build job's map ("" if not exactly one).""" _, build = job(needs, "build") @@ -76,14 +69,12 @@ def built_wheel_digest(needs: dict[str, Any]) -> str: def candidate(needs: dict[str, Any]) -> tuple[str, str]: - """Return (``success`` | ``failure`` | ``skipped`` | ``not run``, reason) for + """Return (``success`` | ``failure`` | ``not run``, reason) for the pre-publication candidate verification.""" result, outputs = job(needs, "verify-candidate") require_result, _ = job(needs, "require-candidate") if job(needs, "build")[0] != "success": return "not run", "" - if tagged_resume(needs) and result == "skipped" and require_result == "success": - return "skipped", "tag already at the release commit" version = job(needs, "detect")[1].get("version", "") status = outputs.get("status", "") evidence = outputs.get("evidence", "") diff --git a/scripts/verify_dist.py b/scripts/verify_dist.py index 62c7c5dd..aa925d01 100644 --- a/scripts/verify_dist.py +++ b/scripts/verify_dist.py @@ -15,7 +15,8 @@ are plain file names and whose values are 64-character lowercase hex SHA-256 digests. The directory must hold only regular files (no subdirectory, no symlink), its file names must equal the map keys exactly, and every file's -SHA-256 must equal its entry. Any other case exits 1 with an ``::error::``. +SHA-256 must equal its entry. Any other case, or an OS error while reading, +exits 1 with an ``::error::``. Standard library only. Kept identical across pycubrid, sqlalchemy-cubrid and cubrid-mcp-server; see RELEASING.md. @@ -86,7 +87,7 @@ def main(argv: list[str] | None = None) -> int: try: expected = parse_expected(os.environ.get(args.expected_env)) verify(args.dir, expected) - except VerifyError as exc: + except (VerifyError, OSError) as exc: print(f"::error::{args.dir}: {exc}") return 1 print(f"{args.dir}: {len(expected)} files match {args.expected_env}") diff --git a/tests/test_release_summary.py b/tests/test_release_summary.py index e5cb7c9b..2d57a7e7 100644 --- a/tests/test_release_summary.py +++ b/tests/test_release_summary.py @@ -242,13 +242,22 @@ def test_failed_require_candidate_is_a_failed_candidate() -> None: assert "require-candidate failure" in reason -def test_tagged_resume_skips_the_candidate_gate_only_there() -> None: - data = needs("resume", verify_candidate={"result": "skipped", "outputs": {}}) +def test_tagged_resume_never_skips_the_candidate_gate() -> None: + # A resume rebuilds, and builds are not reproducible: its bytes need their + # own candidate verification, whatever the tag state. + data = needs("resume") data["detect"]["outputs"]["tag_state"] = "same" - assert summary.candidate(data) == ("skipped", "tag already at the release commit") + assert summary.candidate(data)[0] == "success" assert summary.final_state(data) == "published and verified" - data["detect"]["outputs"]["tag_state"] = "absent" + data = needs( + "resume", + verify_candidate={"result": "skipped", "outputs": {}}, + require_candidate="failure", + publish="skipped", + ) + data["detect"]["outputs"]["tag_state"] = "same" assert summary.candidate(data)[0] == "failure" + assert summary.final_state(data).startswith("failed before publish in require-candidate") def test_candidate_not_run_without_a_build() -> None: diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 7ef1234f..b132edcb 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -192,16 +192,16 @@ def test_job_graph() -> None: ] for name in ("consistency", "matrix", "build"): assert jobs[name]["if"] == "needs.detect.outputs.build == 'true'", name - # The candidate gate runs after every successful build (dry run included) and - # is skipped only to complete a publish whose tag is already at the commit. - assert " ".join(jobs["verify-candidate"]["if"].split()) == ( - "needs.build.result == 'success' && " - "!(needs.detect.outputs.publish == 'true' && needs.detect.outputs.tag_state == 'same')" - ) - # A status function, so a skipped verify-candidate does not skip the gate. + # The candidate gate runs after every successful build: dry run, push and + # resume alike. Builds are not reproducible, so a resume (which rebuilds) + # never inherits an earlier run's verification; there is no tag-state skip. + assert jobs["verify-candidate"]["if"] == "needs.build.result == 'success'" + for name in ("verify-candidate", "require-candidate", "publish"): + assert "tag_state" not in str(jobs[name].get("if", "")), name + # A status function, so a failed verify-candidate still reaches the gate, + # which lists every failed check. assert jobs["require-candidate"]["if"] == "!cancelled() && needs.build.result == 'success'" - # !cancelled() plus explicit results: a skipped verify-candidate (tagged - # resume) must not skip publish, and only these results gate it. + # !cancelled() plus explicit results: only these results gate publish. assert " ".join(jobs["publish"]["if"].split()) == ( "!cancelled() && needs.detect.outputs.publish == 'true' && " "needs.consistency.result == 'success' && needs.matrix.result == 'success' && " @@ -403,7 +403,10 @@ def test_require_candidate_reads_the_report_of_this_request() -> None: assert require["permissions"] == {} download, _ = require_steps() assert str(download["uses"]).startswith("actions/download-artifact@") - assert download["if"] == "needs.verify-candidate.result != 'skipped'" + # A missing report must not hide the diagnostics: the check step still runs + # and fails on it with every other failed check. + assert "if" not in download + assert download["continue-on-error"] is True # Named from this run's own request_id, never from a call output. request_id = RELEASE["jobs"]["verify-candidate"]["with"]["request_id"] assert download["with"] == { @@ -429,18 +432,15 @@ def test_require_candidate_parses_the_outputs_through_env() -> None: "DIST_SHA256": "${{ needs.build.outputs.sha256 }}", "VERSION": "${{ needs.detect.outputs.version }}", "SHA": DETECT_SHA, - "PUBLISH": "${{ needs.detect.outputs.publish }}", - "TAG_STATE": "${{ needs.detect.outputs.tag_state }}", } run = step["run"] assert "${{" not in run - # The only pass without a verified candidate: completing a tagged publish. - lines = [line.strip() for line in run.splitlines()] - skip = 'if [ "$PUBLISH" = true ] && [ "$TAG_STATE" = same ] && [ "$RESULT" = skipped ]; then' - assert lines.index(skip) == 0 - assert lines[2] == "exit 0" and lines[3] == "fi" - assert run.count("exit 0") == 1 + # No pass without a verified candidate: no early exit, no skip branch. + assert run.startswith("python3 - <<'PY'\n") + assert "exit 0" not in run and "skipped" not in run for check in ( + 'run.get("caller_repository")', + 'env["GITHUB_REPOSITORY"]', 'hashes = json.loads(env["DIST_SHA256"])', 'candidate.get("files") == hashes', 'candidate.get("source_sha") == env["SHA"]', @@ -622,9 +622,8 @@ def test_blocked_publication_fails_preparation_before_release_please() -> None: "DIST_SHA256": json.dumps(DIST), "VERSION": "1.9.0", "SHA": SOURCE_SHA, - "PUBLISH": "true", - "TAG_STATE": "absent", } +REPOSITORY = "cubrid-lab/example" def good_report() -> dict[str, Any]: @@ -637,7 +636,7 @@ def good_report() -> dict[str, Any]: "status": "success", "reasons": [], "evidence": "candidate artifact", - "run": {"id": RUN_ID, "attempt": "1"}, + "run": {"id": RUN_ID, "attempt": "1", "caller_repository": REPOSITORY}, "candidate": { "artifact": "release-dist", "wheel": f"{PACKAGE_UNDERSCORE}-1.9.0-py3-none-any.whl", @@ -661,6 +660,7 @@ def run_require_candidate( "PATH": os.environ["PATH"], "PACKAGE": RELEASE["env"]["PACKAGE"], "GITHUB_RUN_ID": RUN_ID, + "GITHUB_REPOSITORY": REPOSITORY, **GOOD_CANDIDATE, **overrides, } @@ -686,8 +686,9 @@ def test_require_candidate_passes_the_built_wheel(tmp_path: Path) -> None: pytest.param({"CANDIDATE_SHA256": "0" * 64}, id="bad-digest"), pytest.param({"CANDIDATE_SHA256": ""}, id="no-digest"), pytest.param({"RESULT": "failure"}, id="call-failed"), - pytest.param({"RESULT": "skipped"}, id="skipped-without-tag"), - pytest.param({"RESULT": "skipped", "PUBLISH": "false", "TAG_STATE": "same"}, id="dry-run"), + pytest.param({"RESULT": "skipped"}, id="call-skipped"), + pytest.param({"RESULT": "cancelled"}, id="call-cancelled"), + pytest.param({"GITHUB_REPOSITORY": "someone/fork"}, id="other-repository"), pytest.param({"STATUS": "failure"}, id="status-failure"), pytest.param({"EVIDENCE": "published package"}, id="published-evidence"), pytest.param({"INSTALLED": "1.8.0"}, id="installed-differs"), @@ -734,6 +735,8 @@ def _report(path: str, value: Any) -> dict[str, Any]: pytest.param(_report("status", "failure"), id="report-failure"), pytest.param(_report("evidence", "published package"), id="report-published"), pytest.param(_report("run.id", "1"), id="report-other-run"), + pytest.param(_report("run.caller_repository", "someone/fork"), id="report-other-repo"), + pytest.param(_report("run.caller_repository", None), id="report-no-repo"), pytest.param(_report("candidate", None), id="report-no-candidate"), pytest.param(_report("candidate.artifact", "other-dist"), id="report-other-artifact"), pytest.param(_report("candidate.source_sha", "d" * 40), id="report-other-source"), @@ -750,9 +753,16 @@ def test_require_candidate_fails_closed_on_the_report(tmp_path: Path, report: An assert "::error::Candidate verification failed" in result.stdout, result.stderr -def test_require_candidate_passes_a_tagged_resume_without_a_call(tmp_path: Path) -> None: - result = run_require_candidate( - tmp_path, None, RESULT="skipped", TAG_STATE="same", CANDIDATE_SHA256="" - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "without the candidate gate" in result.stdout +def test_require_candidate_never_passes_without_a_call(tmp_path: Path) -> None: + # A skipped call (formerly the tagged-resume pass) fails like any other. + result = run_require_candidate(tmp_path, None, RESULT="skipped", CANDIDATE_SHA256="") + assert result.returncode != 0, result.stdout + assert "::error::Candidate verification failed" in result.stdout, result.stderr + + +def test_missing_report_lists_every_failed_check(tmp_path: Path) -> None: + result = run_require_candidate(tmp_path, None) + assert result.returncode != 0 + (error,) = [line for line in result.stdout.splitlines() if line.startswith("::error::")] + for check in ("report names this package", "report files == build job map"): + assert check in error, error diff --git a/tests/test_verify_dist.py b/tests/test_verify_dist.py index 7a317029..51415ecf 100644 --- a/tests/test_verify_dist.py +++ b/tests/test_verify_dist.py @@ -113,3 +113,14 @@ def test_bad_expected_map_fails(dist: Path, monkeypatch: pytest.MonkeyPatch, raw def test_unset_environment_variable_fails(dist: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("EXPECTED", raising=False) assert verify_dist.main(["--dir", str(dist), "--expected-env", "EXPECTED"]) == 1 + + +def test_os_error_fails_with_an_error_line( + dist: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def unreadable(self: Path) -> bytes: + raise PermissionError(13, "Permission denied", str(self)) + + monkeypatch.setattr(Path, "read_bytes", unreadable) + assert run(dist, digests(CONTENT), monkeypatch) == 1 + assert "::error::" in capsys.readouterr().out From 1922c7e1a6c6fee9cbb45a2252f7ad546ea028f6 Mon Sep 17 00:00:00 2001 From: yeongseon Date: Mon, 12 Oct 2026 01:45:40 +0900 Subject: [PATCH 2/2] ci(release): require the candidate report download itself to succeed With continue-on-error, download-artifact can extract the report and only then fail its digest check, so the extracted JSON could still satisfy the checks. require-candidate now also requires steps.download.outcome == success, while still listing every other failed check. Refs cubrid-lab/cubrid-cookbook-python#240 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish-pypi.yml | 9 +++++++-- RELEASING.md | 2 +- tests/test_release_workflows.py | 6 ++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 80cd4047..7d30cc9b 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -306,9 +306,12 @@ jobs: steps: # The report names the package, source commit, artifact and files the # cookbook verified; the call outputs alone do not. It is an artifact of - # this run, so the download needs no token permission. A missing report - # does not stop here: the check below then lists every failed check. + # this run, so the download needs no token permission. A failed download + # does not stop here: the check below requires its outcome to be success + # (the action can extract files before it fails its digest check) and + # lists every other failed check too. - name: Download the candidate verification report + id: download continue-on-error: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -327,6 +330,7 @@ jobs: DIST_SHA256: ${{ needs.build.outputs.sha256 }} VERSION: ${{ needs.detect.outputs.version }} SHA: ${{ needs.detect.outputs.sha }} + DOWNLOAD_OUTCOME: ${{ steps.download.outcome }} run: | python3 - <<'PY' import json, os, pathlib, sys @@ -354,6 +358,7 @@ jobs: f" candidate_sha256={env['CANDIDATE_SHA256']} wheel={wheels} artifact={env['ARTIFACT']}" ) checks = { + "report download succeeded": env["DOWNLOAD_OUTCOME"] == "success", "called workflow succeeded": env["RESULT"] == "success", "status is success": env["STATUS"] == "success", "evidence is the candidate artifact": env["EVIDENCE"] == "candidate artifact", diff --git a/RELEASING.md b/RELEASING.md index 9ece535c..f7917f24 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -162,7 +162,7 @@ and chained with explicit `needs:` (tags and Releases created with | `matrix` | The full Python × CUBRID matrix, TLS, EUC-KR, version-differential and official-differential lanes, and the Python 3.11/3.15 offline suite with the 95% coverage floor (`offline-endpoints`), and copies of the `ci.yml` `lint`, `typecheck`, `compat-check` (public API) and `repo-tooling-tests` jobs (#750): `integration-full.yml` called through `workflow_call` at the SHA. Its `full-matrix-result` job fails unless every one of these jobs succeeds. | | `build` | Builds the wheel and sdist **once**, `twine check`, wheel/sdist install smoke tests, extracts the release notes (the CHANGELOG section byte for byte plus one `**Full Changelog**` link to the previous CHANGELOG version; none for the first release or when the section already has a compare link), generates the SPDX SBOM and records SHA-256 hashes. Artifacts `release-dist` and `release-meta` are kept for 14 days. | | `verify-candidate` | Calls the same pinned cookbook smoke test in **candidate mode** before anything is published: `mode: candidate`, `candidate_artifact: release-dist`, `candidate_hashes` = the `build` job's `sha256` output, `candidate_source_sha` = the release SHA, and `request_id: pycubrid-rc-vX.Y.Z-` (no run attempt, so `gh run rerun --failed` replaces only the failed cells). The cookbook checks the artifact's file set and every SHA-256, installs only that wheel and runs the release smoke on it. It runs after every successful build, `resume` included; there is no skip. | -| `require-candidate` | Fails unless the call succeeded and its outputs report `status == success`, `evidence == candidate artifact`, `installed_version == requested_version == X.Y.Z` and `candidate_sha256` equal to the wheel's entry in the `build` job's `sha256` map. It also downloads the candidate report `release-candidate-verification-` of this run and requires it to name this package, `request_id`, version, run and calling repository, the artifact `release-dist`, the release commit as `source_sha`, exactly the `build` job's file map, and the same verified wheel and digest. Every value reaches the check through `env:` or the downloaded file, never through shell interpolation. It holds no permission. `publish` needs this job. | +| `require-candidate` | Fails unless the call succeeded and its outputs report `status == success`, `evidence == candidate artifact`, `installed_version == requested_version == X.Y.Z` and `candidate_sha256` equal to the wheel's entry in the `build` job's `sha256` map. It also downloads the candidate report `release-candidate-verification-` of this run (the download itself must succeed; a failed one does not stop the check, so every failed reason is listed) and requires it to name this package, `request_id`, version, run and calling repository, the artifact `release-dist`, the release commit as `source_sha`, exactly the `build` job's file map, and the same verified wheel and digest. Every value reaches the check through `env:` or the downloaded file, never through shell interpolation. It holds no permission. `publish` needs this job. | | `publish` | In the `pypi` environment: checks `dist/` and `release-meta/` against the `build` job outputs `sha256` and `meta_sha256` (`scripts/verify_dist.py`: exact file set, every digest) and re-checks `SHA256SUMS`, creates the annotated tag `vX.Y.Z` at the SHA (or accepts one already there), creates a **draft** GitHub Release titled exactly `vX.Y.Z` with the notes and `sbom.spdx.json` (an existing Release found on resume must already carry that title, or the job fails closed; it is never renamed), uploads the same artifact to PyPI through `scripts/pypi_duplicate_guard.py` and Trusted Publishing (OIDC), then publishes the Release. | | `verify-cookbook` | Calls the cookbook smoke test (`smoke-test.yml` of cubrid-cookbook-python) as a **reusable workflow** with `package=pycubrid`, `version=X.Y.Z` and a `request_id`; its jobs run inside this release run. | | `require-cookbook` | Fails unless the called workflow succeeded and its outputs report `status == success` with `installed_version == requested_version == X.Y.Z`. | diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index b132edcb..1711a8a8 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -407,6 +407,7 @@ def test_require_candidate_reads_the_report_of_this_request() -> None: # and fails on it with every other failed check. assert "if" not in download assert download["continue-on-error"] is True + assert download["id"] == "download" # Named from this run's own request_id, never from a call output. request_id = RELEASE["jobs"]["verify-candidate"]["with"]["request_id"] assert download["with"] == { @@ -432,6 +433,7 @@ def test_require_candidate_parses_the_outputs_through_env() -> None: "DIST_SHA256": "${{ needs.build.outputs.sha256 }}", "VERSION": "${{ needs.detect.outputs.version }}", "SHA": DETECT_SHA, + "DOWNLOAD_OUTCOME": "${{ steps.download.outcome }}", } run = step["run"] assert "${{" not in run @@ -622,6 +624,7 @@ def test_blocked_publication_fails_preparation_before_release_please() -> None: "DIST_SHA256": json.dumps(DIST), "VERSION": "1.9.0", "SHA": SOURCE_SHA, + "DOWNLOAD_OUTCOME": "success", } REPOSITORY = "cubrid-lab/example" @@ -689,6 +692,9 @@ def test_require_candidate_passes_the_built_wheel(tmp_path: Path) -> None: pytest.param({"RESULT": "skipped"}, id="call-skipped"), pytest.param({"RESULT": "cancelled"}, id="call-cancelled"), pytest.param({"GITHUB_REPOSITORY": "someone/fork"}, id="other-repository"), + # The action can extract the report before failing its digest check. + pytest.param({"DOWNLOAD_OUTCOME": "failure"}, id="download-failed-after-extract"), + pytest.param({"DOWNLOAD_OUTCOME": ""}, id="download-not-run"), pytest.param({"STATUS": "failure"}, id="status-failure"), pytest.param({"EVIDENCE": "published package"}, id="published-evidence"), pytest.param({"INSTALLED": "1.8.0"}, id="installed-differs"),