From c1f6b9c5012e73f951b45a2a4dda9bda0a049d3f Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Mon, 28 Sep 2026 22:43:58 -0500 Subject: [PATCH] release_manager: pin release tag to the build commit Pass --target $GITHUB_SHA on release create, re-assert it on the draft before publish (also covers resumed drafts), and check the published tag ref afterward. Previously the tag landed on the tip of main at publish time, e.g. dbt-oss-v2.0.5.post1 -> be80b15 vs build 6d43252. --- .github/scripts/release_manager.py | 40 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release_manager.py b/.github/scripts/release_manager.py index 639c29d..de6aa65 100644 --- a/.github/scripts/release_manager.py +++ b/.github/scripts/release_manager.py @@ -204,7 +204,7 @@ def fetch_build_job_log(repo: str, run_id: str, job_name: str, tag: str, assets_ def release_transaction(repo: str, rel: str, sha: str, tag: str, title_prefix: str, assets_dir: Path, signer_workflow: str, dry_run: bool, - upstream_tag: str | None = None) -> str | None: + build_sha: str, upstream_tag: str | None = None) -> str | None: """ Draft -> upload -> verify asset set -> (publish | delete). Same code path for real releases and dry runs; they only diverge at the final @@ -216,6 +216,13 @@ def release_transaction(repo: str, rel: str, sha: str, tag: str, title_prefix: s than silently overwriting something someone may have already verified and pinned. An incomplete draft (died mid-upload) is safely resumed. + The release tag is pinned to `build_sha`, the commit this run (and so + the wheel's provenance) was built from. Without an explicit target, + GitHub creates the tag at the tip of the default branch at publish + time, which may be a later commit than the one that built the wheel. + Releases are immutable once published, so the target is set and + asserted while it is still a draft, in dry runs too. + Returns the published release URL, or None if nothing was published (dry run, or an already-published release with matching assets). """ @@ -246,7 +253,8 @@ def release_transaction(repo: str, rel: str, sha: str, tag: str, title_prefix: s f"Verify with: gh attestation verify --repo {repo} " f"--signer-workflow {signer_workflow}") create_cmd = ["gh", "release", "create", rel, "--repo", repo, "--draft", - "--title", title, "--notes", notes] + "--title", title, "--notes", notes, + "--target", build_sha] if dry_run: create_cmd.insert(create_cmd.index("--draft") + 1, "--prerelease") run_cmd(create_cmd) @@ -266,6 +274,16 @@ def release_transaction(repo: str, rel: str, sha: str, tag: str, title_prefix: s print(f"expected: {expected}") sys.exit(1) + # Also covers a resumed draft, which may have been created earlier + # without a target (or against a branch name). Runs before the + # dry-run branch so dry runs exercise it. + run_cmd(["gh", "release", "edit", rel, "--repo", repo, "--target", build_sha]) + target = run_cmd_json(["gh", "release", "view", rel, "--repo", repo, + "--json", "targetCommitish"])["targetCommitish"] + if target != build_sha: + print(f"::error::release target {target} != build commit {build_sha} - not publishing") + sys.exit(1) + if dry_run: print(f"Dry run verified end-to-end - deleting draft {rel}, nothing published") run_cmd(["gh", "release", "delete", rel, "--repo", repo, "--yes"], check=False) @@ -274,6 +292,13 @@ def release_transaction(repo: str, rel: str, sha: str, tag: str, title_prefix: s run_cmd(["gh", "release", "edit", rel, "--repo", repo, "--draft=false"]) url = json.loads(run_cmd(["gh", "release", "view", rel, "--repo", repo, "--json", "url"]).stdout)["url"] print(f"Published {rel} -> {url}") + + # Alarm only: the release is immutable now, so a mismatch can't be + # fixed, but it must not pass silently. + ref = run_cmd_json(["gh", "api", f"repos/{repo}/git/ref/tags/{rel}"]) + if ref["object"]["type"] == "commit" and ref["object"]["sha"] != build_sha: + print(f"::error::published tag {rel} points at {ref['object']['sha']}, not build commit {build_sha}") + sys.exit(1) return url @@ -291,12 +316,19 @@ def main() -> None: p.add_argument("--wheel-dir", default="dist") p.add_argument("--assets-dir", default="release-assets") p.add_argument("--build-job-name", default="build") + p.add_argument("--build-sha", default=None, + help="Commit the release tag must point at (the commit the wheels were built from). " + "Default: $GITHUB_SHA") p.add_argument("--dry-run", action="store_true") p.add_argument("--force-new-post", action="store_true", help="Publish under the next free .postN even if a published release already has these exact wheel names") args = p.parse_args() run_id = os.environ["GITHUB_RUN_ID"] + build_sha = args.build_sha or os.environ.get("GITHUB_SHA", "") + if len(build_sha) != 40 or any(c not in "0123456789abcdef" for c in build_sha): + print(f"::error::build commit must be a full 40-char SHA, got {build_sha!r}") + sys.exit(1) wheel_dir = Path(args.wheel_dir) assets_dir = Path(args.assets_dir) @@ -320,7 +352,7 @@ def main() -> None: url = release_transaction(args.repo, rel, args.sha, tag, args.release_title_prefix, assets_dir, args.signer_workflow, args.dry_run, - upstream_tag=args.upstream_tag) + build_sha=build_sha, upstream_tag=args.upstream_tag) write_outputs(tag, url) @@ -335,4 +367,4 @@ def write_outputs(tag: str, url: str | None) -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file