Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .github/workflows/build-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ on:
default: "ubuntu-22.04,ubuntu-24.04,ubuntu-25.04,debian-bookworm,alpine,arch,macos,windows"
type: string

permissions:
contents: read

env:
BORINGCACHE_API_TOKEN: ${{ secrets.BORINGCACHE_API_TOKEN }}
BORINGCACHE_DEFAULT_WORKSPACE: ${{ secrets.BORINGCACHE_DEFAULT_WORKSPACE || 'ruby/ruby' }}
Expand Down Expand Up @@ -462,3 +465,15 @@ jobs:
echo "Deleting old entry: $entry"
boringcache delete "$WORKSPACE" "$entry" || true
done

build-status:
name: Build Status
needs: [prepare, build-ruby, cleanup]
runs-on: ubuntu-latest
if: always()
steps:
- name: Verify required jobs
run: |
test "${{ needs.prepare.result }}" = "success"
test "${{ needs.build-ruby.result }}" = "success"
test "${{ needs.cleanup.result }}" = "success"
131 changes: 31 additions & 100 deletions .github/workflows/check-ruby-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,117 +5,48 @@ on:
workflow_dispatch:

permissions:
contents: write
pull-requests: write
contents: read

concurrency:
group: ruby-version-check
cancel-in-progress: false

jobs:
check-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Check for new Ruby releases
run: |
pip install pyyaml requests

python3 << 'EOF'
import yaml, requests, re
from datetime import date

# Fetch ruby-build definitions
resp = requests.get("https://api.github.com/repos/rbenv/ruby-build/contents/share/ruby-build")
definitions = [f["name"] for f in resp.json() if re.match(r"^\d+\.\d+\.\d+$", f["name"])]

with open("versions.yml") as f:
config = yaml.safe_load(f)

current = {v["version"] for v in config["versions"]}

# Check each tracked minor series for newer patches
tracked_series = set()
for v in config["versions"]:
parts = v["version"].split(".")
tracked_series.add(f"{parts[0]}.{parts[1]}")

updates = []
for series in tracked_series:
available = sorted(
[d for d in definitions if d.startswith(series + ".")],
key=lambda x: [int(p) for p in x.split(".")],
reverse=True
)
if available and available[0] not in current:
updates.append(available[0])

if updates:
print(f"New versions available: {', '.join(updates)}")
with open("new_versions.txt", "w") as f:
f.write("\n".join(updates))
else:
print("All versions up to date")
EOF

- name: Update versions.yml
if: hashFiles('new_versions.txt') != ''
id: update
run: |
python3 << 'EOF'
import yaml
from datetime import date

with open("versions.yml") as f:
config = yaml.safe_load(f)

with open("new_versions.txt") as f:
new_versions = f.read().strip().split("\n")

for new_ver in new_versions:
parts = new_ver.split(".")
series = f"{parts[0]}.{parts[1]}"

# Find existing entries for this series
series_entries = [v for v in config["versions"] if v["version"].startswith(series + ".")]
if series_entries:
# Use first entry as template
template = series_entries[0].copy()
template["version"] = new_ver
template["priority"] = "high"

# Demote existing priorities
for entry in series_entries:
if entry["priority"] == "high":
entry["priority"] = "medium"
elif entry["priority"] == "medium":
entry["priority"] = "low"

# Remove oldest if more than 3
if len(series_entries) >= 3:
oldest = series_entries[-1]
config["versions"].remove(oldest)

# Insert new version at the top of its series
idx = config["versions"].index(series_entries[0])
config["versions"].insert(idx, template)

# Remove EOL versions
today = date.today()
config["versions"] = [
v for v in config["versions"]
if date.fromisoformat(v["eol_date"]) > today
]

with open("versions.yml", "w") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
EOF
python3 scripts/update-ruby-versions.py | tee version-update-summary.txt

if git diff --quiet -- versions.yml; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Require bot token
if: steps.update.outputs.changed == 'true'
env:
BOT_PUBLIC_GITHUB_TOKEN: ${{ secrets.BOT_PUBLIC_GITHUB_TOKEN }}
run: |
if [ -z "${BOT_PUBLIC_GITHUB_TOKEN}" ]; then
echo "BOT_PUBLIC_GITHUB_TOKEN is required to open the Ruby version update PR." >&2
exit 1
fi

- name: Create PR
if: hashFiles('new_versions.txt') != ''
- name: Create version update PR
if: steps.update.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v8
with:
commit-message: "Update Ruby versions"
title: "chore: bump Ruby patch versions"
body: |
New Ruby versions detected. This PR updates `versions.yml` with the latest patches.

The build workflow will automatically trigger on merge.
token: ${{ secrets.BOT_PUBLIC_GITHUB_TOKEN }}
commit-message: "chore: update Ruby versions"
committer: "boringcache-bot <boringcache-bot@users.noreply.github.com>"
author: "boringcache-bot <boringcache-bot@users.noreply.github.com>"
title: "chore: update Ruby versions"
body-path: version-update-summary.txt
branch: auto/ruby-version-bump
delete-branch: true
204 changes: 204 additions & 0 deletions scripts/update-ruby-versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Update versions.yml with the newest supported Ruby patch releases."""

from __future__ import annotations

import json
import re
import sys
import urllib.request
from copy import deepcopy
from datetime import date
from pathlib import Path


VERSIONS_FILE = Path("versions.yml")
RUBY_BUILD_API = "https://api.github.com/repos/rbenv/ruby-build/contents/share/ruby-build"
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
VERSION_RE = re.compile(r"^\s*-\s+version:\s*[\"']?([^\"']+)[\"']?\s*$")
KEY_VALUE_RE = re.compile(r"^\s{4}([A-Za-z_][\w-]*):\s*(.+?)\s*$")


def semver_key(version: str) -> tuple[int, int, int]:
return tuple(int(part) for part in version.split(".")) # type: ignore[return-value]


def series_for(version: str) -> str:
major, minor, _patch = version.split(".")
return f"{major}.{minor}"


def unquote(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value


def find_versions_block(lines: list[str]) -> tuple[int, int]:
try:
start = next(index for index, line in enumerate(lines) if line.strip() == "versions:")
except StopIteration as exc:
raise ValueError("versions.yml is missing a top-level versions: block") from exc

end = len(lines)
for index in range(start + 1, len(lines)):
line = lines[index]
if line and not line.startswith((" ", "#")) and line.strip().endswith(":"):
end = index
while end > start + 1 and (
lines[end - 1].strip() == "" or lines[end - 1].startswith("#")
):
end -= 1
break
return start, end


def parse_versions(block_lines: list[str]) -> list[dict[str, str]]:
versions: list[dict[str, str]] = []
current: dict[str, str] | None = None

for line in block_lines:
version_match = VERSION_RE.match(line)
if version_match:
current = {"version": version_match.group(1)}
versions.append(current)
continue

if current is None:
continue

key_value_match = KEY_VALUE_RE.match(line)
if key_value_match:
key, value = key_value_match.groups()
current[key] = unquote(value)

return versions


def fetch_ruby_build_versions() -> list[str]:
with urllib.request.urlopen(RUBY_BUILD_API, timeout=30) as response:
payload = json.load(response)

versions = [item["name"] for item in payload if SEMVER_RE.match(item.get("name", ""))]
return sorted(versions, key=semver_key, reverse=True)


def maintenance_label(value: str) -> str:
return f"{value.capitalize()} Maintenance"


def format_versions_block(versions: list[dict[str, str]]) -> list[str]:
lines = ["versions:\n"]
grouped: dict[str, list[dict[str, str]]] = {}

for version in versions:
grouped.setdefault(series_for(version["version"]), []).append(version)

for series in sorted(grouped, key=lambda item: tuple(int(part) for part in item.split(".")), reverse=True):
entries = sorted(grouped[series], key=lambda item: semver_key(item["version"]), reverse=True)
first = entries[0]
lines.append(
f" # Ruby {series}.x series "
f"({first['status'].capitalize()} - {maintenance_label(first['maintenance'])} "
f"until {first['eol_date']})\n"
)

for entry in entries:
lines.extend(
[
f' - version: "{entry["version"]}"\n',
f' status: "{entry["status"]}"\n',
f' priority: "{entry["priority"]}"\n',
f' maintenance: "{entry["maintenance"]}"\n',
f' eol_date: "{entry["eol_date"]}"\n',
]
)
lines.append("\n")

return lines


def update_versions(versions: list[dict[str, str]], available: list[str]) -> tuple[list[dict[str, str]], list[str]]:
today = date.today()
supported = [
deepcopy(version)
for version in versions
if date.fromisoformat(version["eol_date"]) > today
]
removed = sorted(
{version["version"] for version in versions} - {version["version"] for version in supported},
key=semver_key,
reverse=True,
)

available_by_series: dict[str, list[str]] = {}
for version in available:
available_by_series.setdefault(series_for(version), []).append(version)

updated: list[dict[str, str]] = []
added: list[str] = []
tracked_series = sorted(
{series_for(version["version"]) for version in supported},
key=lambda item: tuple(int(part) for part in item.split(".")),
reverse=True,
)

for series in tracked_series:
series_entries = [
version for version in supported if series_for(version["version"]) == series
]
series_entries.sort(key=lambda item: semver_key(item["version"]), reverse=True)

latest = available_by_series.get(series, [series_entries[0]["version"]])[0]
if latest not in {entry["version"] for entry in series_entries}:
template = deepcopy(series_entries[0])
template["version"] = latest
series_entries.insert(0, template)
added.append(latest)

series_entries.sort(key=lambda item: semver_key(item["version"]), reverse=True)
for priority, entry in zip(["high", "medium", "low"], series_entries):
entry["priority"] = priority

updated.extend(series_entries[:3])

messages = []
if added:
messages.append(f"Added Ruby versions: {', '.join(added)}")
if removed:
messages.append(f"Removed EOL Ruby versions: {', '.join(removed)}")
if not messages:
messages.append("Ruby versions are already up to date.")

return updated, messages


def main() -> int:
original_lines = VERSIONS_FILE.read_text().splitlines(keepends=True)
start, end = find_versions_block(original_lines)
versions = parse_versions(original_lines[start:end])

if not versions:
raise ValueError("versions.yml has no Ruby versions to update")

updated_versions, messages = update_versions(versions, fetch_ruby_build_versions())
updated_lines = (
original_lines[:start]
+ format_versions_block(updated_versions)
+ original_lines[end:]
)

VERSIONS_FILE.write_text("".join(updated_lines))

for message in messages:
print(message)
return 0


if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)
Loading
Loading