Files
redash/bin/release
Arik Fraimovich 1cbb635be4 Add release script, remove old release_manager and get_changes
New bin/release script that automates the full release process:
- Major releases: creates branch, generates changelog, updates version,
  creates GitHub release, triggers Docker build, opens changelog PR
- Patch releases: same flow on existing release branch
- Individual subcommands for testing each step independently

Removes bin/release_manager.py and bin/get_changes.py which used an
older RC-based release workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:54:42 +02:00

641 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Redash Release Script
Versioning: CalVer YY.M.PATCH (e.g., 26.3.0, 26.12.1)
Major Release (bin/release major)
=================================
Run from the master branch. Steps:
1. Validate state (on master, clean worktree, branch/tag don't exist yet)
2. Create release branch (e.g., v26.3) from master
3. Generate changelog from merged PRs since last release, open $EDITOR
for review, and write to CHANGELOG.md
4. Update version in package.json, redash/__init__.py, pyproject.toml
5. Commit and push the release branch
6. Create GitHub release with auto-generated notes
7. Trigger Docker image build (preview-image.yml with dockerRepository=redash)
8. Create a PR (changelog/v26.3.0 -> master) with just the CHANGELOG.md
update, avoiding version file conflicts
Patch Release (bin/release patch)
=================================
Run from an existing release branch (e.g., v26.3) after cherry-picking fixes.
1. Validate state (on release branch, has new commits since last tag)
2. Generate changelog from commits since last tag, open $EDITOR for
review, and write to CHANGELOG.md
3. Bump patch version (e.g., 26.3.0 -> 26.3.1)
4. Commit and push
5. Create GitHub release with auto-generated notes
6. Trigger Docker image build
7. Create a PR (changelog/v26.3.1 -> master) with just the CHANGELOG.md
update
Subcommands (for testing individual steps)
==========================================
bin/release check-major Validate state for a major release
bin/release check-patch Validate state for a patch release
bin/release changelog <version> [--since-tag TAG] Generate changelog entries
bin/release update-version <version> Update version in all files
bin/release update-changelog <version> Generate changelog and open in editor
bin/release create-release <tag> --target <branch> [--prev-tag TAG]
bin/release build-image --ref <ref> Trigger Docker image build
bin/release changelog-pr <version> --release-branch <branch>
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
REPO = "getredash/redash"
VERSION_FILES = ["package.json", "redash/__init__.py", "pyproject.toml"]
CHANGELOG = "CHANGELOG.md"
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def run(cmd, check=True, dry_run=False):
"""Run a shell command, return stdout stripped."""
if dry_run:
print(f" [dry-run] {cmd}")
return ""
print(f" $ {cmd}")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if check and result.returncode != 0:
print(f" ERROR: {result.stderr.strip()}")
sys.exit(1)
return result.stdout.strip()
def confirm(message):
response = input(f"\n{message} [y/N] ")
return response.lower() in ("y", "yes")
# ---------------------------------------------------------------------------
# Version helpers
# ---------------------------------------------------------------------------
def read_version():
"""Read version from package.json."""
with open("package.json") as f:
return json.load(f)["version"]
def normalize_version(version):
"""Strip -dev suffix and leading zeros from month. '26.03.0-dev' -> '26.3.0'"""
version = re.sub(r"-dev$", "", version)
parts = version.split(".")
if len(parts) != 3:
print(f"Error: unexpected version format: {version}")
sys.exit(1)
return f"{int(parts[0])}.{int(parts[1])}.{int(parts[2])}"
def bump_patch(version):
"""'26.3.0' -> '26.3.1'"""
parts = version.split(".")
parts[2] = str(int(parts[2]) + 1)
return ".".join(parts)
def branch_name(version):
"""'26.3.0' -> 'v26.3'"""
parts = version.split(".")
return f"v{parts[0]}.{parts[1]}"
def tag_name(version):
"""'26.3.0' -> 'v26.3.0'"""
return f"v{version}"
# ---------------------------------------------------------------------------
# Subcommands (individually testable steps)
# ---------------------------------------------------------------------------
def cmd_check_major():
"""Validate the repo state for a major release. Prints the release plan."""
status = run("git diff --stat HEAD")
if status:
print("FAIL: uncommitted changes to tracked files.")
return False
run("git fetch origin")
branch = run("git symbolic-ref --short HEAD 2>/dev/null", check=False)
head = run("git rev-parse HEAD")
origin_master = run("git rev-parse origin/master")
if branch != "master" and head != origin_master:
print(f"FAIL: not on master (on '{branch or 'detached HEAD'}')")
return False
if head != origin_master:
print("FAIL: local master is behind origin/master.")
return False
dev_version = read_version()
if not dev_version.endswith("-dev"):
print(f"FAIL: version '{dev_version}' doesn't end with '-dev'")
return False
version = normalize_version(dev_version)
branch_ref = branch_name(version)
tag = tag_name(version)
prev_tag = get_previous_release_tag()
if run(f"git ls-remote --heads origin {branch_ref}", check=False):
print(f"FAIL: branch '{branch_ref}' already exists on origin.")
return False
if run(f"git ls-remote --tags origin refs/tags/{tag}", check=False):
print(f"FAIL: tag '{tag}' already exists on origin.")
return False
print(f"OK: ready for major release")
print(f" {dev_version} -> {version}")
print(f" Branch: {branch_ref}")
print(f" Tag: {tag}")
if prev_tag:
print(f" Previous release: {prev_tag}")
return True
def cmd_check_patch():
"""Validate the repo state for a patch release. Prints the release plan."""
status = run("git diff --stat HEAD")
if status:
print("FAIL: uncommitted changes to tracked files.")
return False
run("git fetch origin")
branch = run("git symbolic-ref --short HEAD 2>/dev/null", check=False)
if not re.match(r"^v\d+\.\d+$", branch):
print(f"FAIL: not on a release branch (on '{branch}').")
return False
current = normalize_version(read_version())
new_version = bump_patch(current)
tag = tag_name(new_version)
prev_tag = tag_name(current)
if run(f"git ls-remote --tags origin refs/tags/{tag}", check=False):
print(f"FAIL: tag '{tag}' already exists on origin.")
return False
commits = run(f"git log {prev_tag}..HEAD --oneline", check=False)
if not commits:
print(f"FAIL: no new commits since {prev_tag}.")
return False
print(f"OK: ready for patch release")
print(f" {current} -> {new_version}")
print(f" Branch: {branch}")
print(f" Tag: {tag}")
print(f"\n Changes since {prev_tag}:")
for line in commits.split("\n"):
print(f" {line}")
return True
def cmd_update_version(version):
"""Update version in all three files."""
print(f"Updating version to '{version}'...")
# package.json
with open("package.json") as f:
pkg = json.load(f)
pkg["version"] = version
with open("package.json", "w") as f:
json.dump(pkg, f, indent=2)
f.write("\n")
# redash/__init__.py
with open("redash/__init__.py") as f:
content = f.read()
content = re.sub(r'__version__ = ".*?"', f'__version__ = "{version}"', content)
with open("redash/__init__.py", "w") as f:
f.write(content)
# pyproject.toml
with open("pyproject.toml") as f:
content = f.read()
content = re.sub(r'^version = ".*?"', f'version = "{version}"', content, flags=re.MULTILINE)
with open("pyproject.toml", "w") as f:
f.write(content)
print(f" Updated {', '.join(VERSION_FILES)}")
def get_previous_release_tag():
"""Get the most recent release tag (not pre-release)."""
result = run(
f"gh release list --repo {REPO} --exclude-drafts --exclude-pre-releases "
f"--limit 1 --json tagName --jq '.[0].tagName'",
check=False,
)
return result if result else None
def get_release_date(tag):
"""Get the publish date of a release tag (YYYY-MM-DD)."""
result = run(
f"gh release view {tag} --repo {REPO} --json publishedAt --jq '.publishedAt'",
check=False,
)
return result[:10] if result else None
def cmd_changelog(version, since_tag=None, from_commits=False, prev_tag=None):
"""Generate and print changelog entries.
Two modes:
- from merged PRs since a tag's release date (major releases)
- from commits since a tag (patch releases, --from-commits)
"""
if from_commits and prev_tag:
return _changelog_from_commits(prev_tag)
else:
return _changelog_from_prs(since_tag)
def _changelog_from_prs(since_tag):
"""Generate changelog entries from merged PRs since a tag's release date."""
since_date = get_release_date(since_tag) if since_tag else None
if not since_date:
if since_tag:
since_date = run(f"git log -1 --format=%cs {since_tag}", check=False)
if not since_date:
print("Warning: could not determine date for previous release.")
return []
jq_expr = '.[] | "* \\(.title) ([#\\(.number)](https://github.com/getredash/redash/pull/\\(.number)))"'
result = run(
f"gh pr list --repo {REPO} --state merged "
f'--search "merged:>={since_date}" '
f"--json number,title --limit 500 "
f"--jq '{jq_expr}'",
check=False,
)
entries = result.split("\n") if result else []
for e in entries:
print(e)
return entries
def _changelog_from_commits(prev_tag):
"""Generate changelog entries from commits since a tag (for patch releases)."""
log = run(f"git log {prev_tag}..HEAD --pretty=format:%s", check=False)
if not log:
return []
entries = []
for subject in log.split("\n"):
match = re.search(r"\(#(\d+)\)", subject)
if match:
pr_num = match.group(1)
title = re.sub(r"\s*\(#\d+\)$", "", subject)
entries.append(f"* {title} ([#{pr_num}](https://github.com/{REPO}/pull/{pr_num}))")
else:
entries.append(f"* {subject}")
for e in entries:
print(e)
return entries
def cmd_update_changelog(version, entries):
"""Write changelog entries into CHANGELOG.md, opening $EDITOR for review first."""
if not entries:
print("No changelog entries to add.")
return
# Write draft to temp file for editing
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", prefix="changelog-", delete=False) as f:
f.write(f"# Edit changelog entries for {version}\n")
f.write(f"# Lines starting with # will be removed.\n")
f.write(f"# Save and close the editor to continue. Empty file aborts.\n\n")
f.write("\n".join(entries) + "\n")
tmpfile = f.name
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
print(f" Opening {editor} to review changelog entries...")
subprocess.run([editor, tmpfile])
with open(tmpfile) as f:
edited = f.read()
os.unlink(tmpfile)
# Strip comment lines
lines = [l for l in edited.split("\n") if not l.startswith("#")]
edited = "\n".join(lines).strip()
if not edited:
print(" Changelog edit was empty, skipping CHANGELOG.md update.")
return
# Prepend to CHANGELOG.md
with open(CHANGELOG) as f:
existing = f.read()
header_end = existing.index("\n") + 1
updated = existing[:header_end] + f"\n## {version}\n\n{edited}\n\n" + existing[header_end:].lstrip("\n")
with open(CHANGELOG, "w") as f:
f.write(updated)
print(f" Updated {CHANGELOG}")
def cmd_create_release(tag, target, prev_tag=None, dry_run=False):
"""Create a GitHub release."""
gh_notes_args = f"--generate-notes --notes-start-tag {prev_tag}" if prev_tag else '--notes ""'
run(
f"gh release create {tag} --target {target} --title {tag} {gh_notes_args}",
dry_run=dry_run,
)
print(f" Created release {tag}")
def cmd_build_image(ref, dry_run=False):
"""Trigger the Docker image build workflow."""
run(
f"gh workflow run preview-image.yml --ref {ref} -f dockerRepository=redash",
dry_run=dry_run,
)
print(f" Triggered build for ref '{ref}'")
def cmd_changelog_pr(version, release_branch, dry_run=False):
"""Create a PR to bring the changelog update back to master.
Creates a temporary branch from master with just the CHANGELOG.md
change from the release branch, avoiding version file conflicts.
"""
pr_branch = f"changelog/{tag_name(version)}"
tag = tag_name(version)
run("git checkout master", dry_run=dry_run)
run("git pull origin master", dry_run=dry_run)
run(f"git checkout -b {pr_branch}", dry_run=dry_run)
run(f"git checkout {release_branch} -- {CHANGELOG}", dry_run=dry_run)
run(f"git add {CHANGELOG}", dry_run=dry_run)
run(f'git commit -m "Update CHANGELOG.md for {tag}"', dry_run=dry_run)
run(f"git push --set-upstream origin {pr_branch}", dry_run=dry_run)
run(
f'gh pr create --title "Update CHANGELOG.md for {tag}" '
f'--body "Brings the changelog entries from the {tag} release back to master." '
f"--base master --head {pr_branch}",
dry_run=dry_run,
)
print(f" Created PR to merge changelog into master")
# Return to the release branch
run(f"git checkout {release_branch}", dry_run=dry_run)
# ---------------------------------------------------------------------------
# Full release flows (compose subcommands)
# ---------------------------------------------------------------------------
def do_major(dry_run=False):
if not cmd_check_major():
sys.exit(1)
dev_version = read_version()
version = normalize_version(dev_version)
branch_ref = branch_name(version)
tag = tag_name(version)
prev_tag = get_previous_release_tag()
if not dry_run and not confirm("Proceed with major release?"):
print("Aborted.")
sys.exit(0)
# 1. Create release branch
print(f"\n1. Creating release branch '{branch_ref}'...")
run(f"git checkout -b {branch_ref}", dry_run=dry_run)
# 2. Generate and update changelog
print("\n2. Updating changelog...")
if dry_run:
print(f" [dry-run] Generate changelog entries since {prev_tag}")
print(f" [dry-run] Open $EDITOR for review")
print(f" [dry-run] Update {CHANGELOG}")
else:
entries = _changelog_from_prs(prev_tag)
cmd_update_changelog(version, entries)
# 3. Update version files
print(f"\n3. Updating version to '{version}'...")
if dry_run:
print(f" [dry-run] Update version in {', '.join(VERSION_FILES)}")
else:
cmd_update_version(version)
# 4. Commit and push
print("\n4. Committing and pushing...")
run(f"git add {' '.join(VERSION_FILES)} {CHANGELOG}", dry_run=dry_run)
run(f'git commit -m "{version} release"', dry_run=dry_run)
run(f"git push --set-upstream origin {branch_ref}", dry_run=dry_run)
# 5. Create GitHub release
print(f"\n5. Creating GitHub release '{tag}'...")
cmd_create_release(tag, branch_ref, prev_tag=prev_tag, dry_run=dry_run)
# 6. Trigger Docker build
print(f"\n6. Triggering Docker image build...")
cmd_build_image(branch_ref, dry_run=dry_run)
# 7. Open PR to bring changelog back to master
print(f"\n7. Creating PR to update changelog on master...")
cmd_changelog_pr(version, branch_ref, dry_run=dry_run)
print(f"\n{'[DRY RUN] ' if dry_run else ''}Release {tag} complete!")
print(f"\nNext steps:")
print(f" - Monitor Docker build: gh run list --workflow=preview-image.yml")
print(f" - Edit release notes: https://github.com/{REPO}/releases/tag/{tag}")
print(f" - Merge the changelog PR")
print(f" - Update website docs")
print(f" - Post announcement in discussions")
def do_patch(dry_run=False):
if not cmd_check_patch():
sys.exit(1)
branch = run("git symbolic-ref --short HEAD 2>/dev/null", check=False)
current = normalize_version(read_version())
new_version = bump_patch(current)
tag = tag_name(new_version)
prev_tag = tag_name(current)
if not dry_run and not confirm("Proceed with patch release?"):
print("Aborted.")
sys.exit(0)
# 1. Generate and update changelog
print("\n1. Updating changelog...")
if dry_run:
print(f" [dry-run] Generate changelog entries from commits since {prev_tag}")
print(f" [dry-run] Open $EDITOR for review")
print(f" [dry-run] Update {CHANGELOG}")
else:
entries = _changelog_from_commits(prev_tag)
cmd_update_changelog(new_version, entries)
# 2. Update version files
print(f"\n2. Updating version to '{new_version}'...")
if dry_run:
print(f" [dry-run] Update version in {', '.join(VERSION_FILES)}")
else:
cmd_update_version(new_version)
# 3. Commit and push
print("\n3. Committing and pushing...")
run(f"git add {' '.join(VERSION_FILES)} {CHANGELOG}", dry_run=dry_run)
run(f'git commit -m "{new_version} release"', dry_run=dry_run)
run(f"git push origin {branch}", dry_run=dry_run)
# 4. Create GitHub release
print(f"\n4. Creating GitHub release '{tag}'...")
cmd_create_release(tag, branch, prev_tag=prev_tag, dry_run=dry_run)
# 5. Trigger Docker build
print(f"\n5. Triggering Docker image build...")
cmd_build_image(branch, dry_run=dry_run)
# 6. Open PR to bring changelog back to master
print(f"\n6. Creating PR to update changelog on master...")
cmd_changelog_pr(new_version, branch, dry_run=dry_run)
print(f"\n{'[DRY RUN] ' if dry_run else ''}Patch release {tag} complete!")
print(f"\nNext steps:")
print(f" - Monitor Docker build: gh run list --workflow=preview-image.yml")
print(f" - Edit release notes: https://github.com/{REPO}/releases/tag/{tag}")
print(f" - Merge the changelog PR")
print(f" - Update website docs if needed")
print(f" - Post announcement in discussions")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Redash Release Script",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
sub = parser.add_subparsers(dest="command")
# Full release flows
p_major = sub.add_parser("major", help="Full major release from master")
p_major.add_argument("--dry-run", action="store_true")
p_patch = sub.add_parser("patch", help="Full patch release on release branch")
p_patch.add_argument("--dry-run", action="store_true")
# Individual steps
sub.add_parser("check-major", help="Validate state for a major release")
sub.add_parser("check-patch", help="Validate state for a patch release")
p_ver = sub.add_parser("update-version", help="Update version in all files")
p_ver.add_argument("version", help="Version string (e.g., 26.3.0)")
p_cl = sub.add_parser("changelog", help="Generate changelog entries (prints to stdout)")
p_cl.add_argument("version", help="Version string")
p_cl.add_argument("--since-tag", help="Previous release tag to find PRs since")
p_cl.add_argument("--from-commits", action="store_true", help="Generate from commits instead of PRs")
p_cl.add_argument("--prev-tag", help="Previous tag for commit-based changelog")
p_ucl = sub.add_parser("update-changelog", help="Generate changelog and write to CHANGELOG.md")
p_ucl.add_argument("version", help="Version string")
p_ucl.add_argument("--since-tag", help="Previous release tag (for major)")
p_ucl.add_argument("--from-commits", action="store_true", help="Generate from commits (for patch)")
p_ucl.add_argument("--prev-tag", help="Previous tag for commit-based changelog")
p_rel = sub.add_parser("create-release", help="Create a GitHub release")
p_rel.add_argument("tag", help="Tag name (e.g., v26.3.0)")
p_rel.add_argument("--target", required=True, help="Target branch")
p_rel.add_argument("--prev-tag", help="Previous tag for auto-generated notes")
p_rel.add_argument("--dry-run", action="store_true")
p_build = sub.add_parser("build-image", help="Trigger Docker image build")
p_build.add_argument("--ref", required=True, help="Git ref to build from")
p_build.add_argument("--dry-run", action="store_true")
p_clpr = sub.add_parser("changelog-pr", help="Create PR to bring changelog back to master")
p_clpr.add_argument("version", help="Version string (e.g., 26.3.0)")
p_clpr.add_argument("--release-branch", required=True, help="Release branch name (e.g., v26.3)")
p_clpr.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if not os.path.exists("package.json"):
print("Error: run from the Redash repository root.")
sys.exit(1)
if args.command in (
"major",
"patch",
"check-major",
"check-patch",
"changelog",
"create-release",
"build-image",
"changelog-pr",
):
run("gh --version")
if args.command == "major":
do_major(dry_run=args.dry_run)
elif args.command == "patch":
do_patch(dry_run=args.dry_run)
elif args.command == "check-major":
if not cmd_check_major():
sys.exit(1)
elif args.command == "check-patch":
if not cmd_check_patch():
sys.exit(1)
elif args.command == "update-version":
cmd_update_version(args.version)
elif args.command == "changelog":
cmd_changelog(args.version, since_tag=args.since_tag, from_commits=args.from_commits, prev_tag=args.prev_tag)
elif args.command == "update-changelog":
if args.from_commits and args.prev_tag:
entries = _changelog_from_commits(args.prev_tag)
else:
entries = _changelog_from_prs(args.since_tag)
cmd_update_changelog(args.version, entries)
elif args.command == "create-release":
cmd_create_release(args.tag, args.target, prev_tag=args.prev_tag, dry_run=args.dry_run)
elif args.command == "build-image":
cmd_build_image(args.ref, dry_run=args.dry_run)
elif args.command == "changelog-pr":
cmd_changelog_pr(args.version, args.release_branch, dry_run=args.dry_run)
if __name__ == "__main__":
main()