Skip to content
Open
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
68 changes: 68 additions & 0 deletions .github/workflows/backfill-releases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Backfill Releases

on:
workflow_dispatch:
inputs:
since:
description: Include PyPI releases at or after this version
required: false
default: "2.6.2"
versions:
description: Comma-separated explicit versions (overrides since)
required: false
default: ""
min_score:
description: Minimum PyPI fingerprint match score (0-1)
required: false
default: "0.95"
dry_run:
description: Preview matches without creating tags
required: true
default: true
type: boolean
confirm:
description: Set to I-have-reviewed-the-table to create tags when dry_run is false
required: false
default: ""

permissions:
contents: write

jobs:
backfill:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Fetch all branches
run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Plan backfill
id: plan
run: |
set -euo pipefail
args=(
--since "${{ inputs.since }}"
--min-score "${{ inputs.min_score }}"
)
if [ -n "${{ inputs.versions }}" ]; then
args+=(--versions "${{ inputs.versions }}")
fi
if [ "${{ inputs.dry_run }}" = "false" ]; then
args+=(--apply --confirm "${{ inputs.confirm }}")
fi
python scripts/backfill_release_tags.py "${args[@]}" | tee backfill-plan.txt

- name: Upload backfill plan
uses: actions/upload-artifact@v4
with:
name: backfill-plan
path: backfill-plan.txt
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Release

on:
push:
branches:
- version-2
paths:
- setup.py
workflow_dispatch:
inputs:
ref:
description: Branch or commit to release from
required: false
default: version-2

permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
ref: ${{ inputs.ref || github.ref }}

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Detect version bump
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
run: python scripts/release_utils.py detect-bump --event-name "$EVENT_NAME" --github-output

- name: Create tag and GitHub Release
if: steps.detect.outputs.should_release == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
python scripts/release_utils.py create-release \
--version "${{ steps.detect.outputs.version }}" \
--notes "${{ steps.detect.outputs.notes }}"
197 changes: 197 additions & 0 deletions scripts/backfill_release_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Backfill git tags and GitHub Releases from PyPI release history."""

from __future__ import annotations

import argparse
import sys
from dataclasses import dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))

from release_utils import ( # noqa: E402
create_release,
list_pypi_versions,
parse_readme_release_note,
resolve_commit_for_version,
tag_exists,
version_key,
)


@dataclass(frozen=True)
class BackfillRow:
version: str
commit: str | None
source: str
score: float
notes: str
action: str


def parse_versions(raw: str | None) -> list[str] | None:
if not raw:
return None
return [item.strip() for item in raw.split(",") if item.strip()]


def select_versions(
*,
since: str | None,
versions: list[str] | None,
) -> list[str]:
if versions:
return sorted(versions, key=version_key)
return list_pypi_versions(since)


def plan_backfill(
versions: list[str],
*,
min_score: float,
) -> list[BackfillRow]:
rows: list[BackfillRow] = []
for version in versions:
if tag_exists(version):
rows.append(
BackfillRow(
version=version,
commit=None,
source="existing-tag",
score=1.0,
notes=parse_readme_release_note(version) or "",
action="skip",
)
)
continue

try:
commit, source, score = resolve_commit_for_version(
version,
min_score=min_score,
)
except ValueError as exc:
rows.append(
BackfillRow(
version=version,
commit=None,
source=str(exc),
score=0.0,
notes=parse_readme_release_note(version) or "",
action="skip-error",
)
)
continue
notes = parse_readme_release_note(version) or "See README Version History"
if commit is None:
action = "skip-low-confidence"
else:
action = "create"
rows.append(
BackfillRow(
version=version,
commit=commit,
source=source,
score=score,
notes=notes,
action=action,
)
)
return rows

def print_table(rows: list[BackfillRow]) -> None:
headers = ("version", "commit", "source", "score", "action")
print(
f"{'version':<8} {'commit':<10} {'source':<18} {'score':<6} action"
)
print("-" * 60)
for row in rows:
commit = row.commit[:9] if row.commit else "-"
print(
f"{row.version:<8} {commit:<10} {row.source:<18} "
f"{row.score:<6.2f} {row.action}"
)


def apply_backfill(rows: list[BackfillRow], *, dry_run: bool) -> int:
created = 0
for row in rows:
if row.action != "create" or row.commit is None:
continue
if dry_run:
print(f"DRY RUN would create tag {row.version} at {row.commit}")
created += 1
continue
create_release(row.version, row.notes, row.commit)
print(f"Created tag and release {row.version} at {row.commit}")
created += 1
return created


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--since",
default="2.6.2",
help="Include PyPI releases at or after this version (default: 2.6.2)",
)
parser.add_argument(
"--versions",
help="Comma-separated explicit version list (overrides --since)",
)
parser.add_argument(
"--min-score",
type=float,
default=0.95,
help="Minimum fingerprint match score (default: 0.95)",
)
parser.add_argument(
"--apply",
action="store_true",
help="Create tags and GitHub Releases (default is dry-run)",
)
parser.add_argument(
"--confirm",
default="",
help='Required for --apply; must be "I-have-reviewed-the-table"',
)
return parser


def main() -> int:
args = build_parser().parse_args()
dry_run = not args.apply
if args.apply and args.confirm != "I-have-reviewed-the-table":
print(
'Refusing to apply without --confirm "I-have-reviewed-the-table"',
file=sys.stderr,
)
return 1

versions = select_versions(
since=args.since,
versions=parse_versions(args.versions),
)
rows = plan_backfill(versions, min_score=args.min_score)
print_table(rows)

creatable = sum(1 for row in rows if row.action == "create")
skipped = len(rows) - creatable
print()
print(f"Planned: {creatable} create, {skipped} skip")

if creatable == 0:
return 0

created = apply_backfill(rows, dry_run=dry_run)
if dry_run:
print(f"DRY RUN complete ({created} tag(s) would be created)")
else:
print(f"Applied {created} tag(s)")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading