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
41 changes: 20 additions & 21 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -308,9 +306,13 @@ 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 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
if: needs.verify-candidate.result != 'skipped'
id: download
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 }}
Expand All @@ -328,13 +330,8 @@ 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 }}
DOWNLOAD_OUTCOME: ${{ steps.download.outcome }}
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
Expand All @@ -361,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",
Expand All @@ -384,6 +382,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:
Expand All @@ -395,9 +395,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' &&
Expand Down
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,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
Expand Down
30 changes: 16 additions & 14 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<run id>` (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-<request_id>` 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-<run id>` (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-<request_id>` 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`. |
Expand All @@ -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)

Expand Down Expand Up @@ -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 <run-id> --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

Expand Down Expand Up @@ -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. |

Expand Down
15 changes: 3 additions & 12 deletions scripts/release_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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", "")
Expand Down
5 changes: 3 additions & 2 deletions scripts/verify_dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down
Loading
Loading