diff --git a/.boringcache.toml b/.boringcache.toml new file mode 100644 index 0000000..07d218f --- /dev/null +++ b/.boringcache.toml @@ -0,0 +1 @@ +workspace = "ruby/ruby" diff --git a/.github/workflows/build-ruby.yml b/.github/workflows/build-ruby.yml index 7d29fb8..c406976 100644 --- a/.github/workflows/build-ruby.yml +++ b/.github/workflows/build-ruby.yml @@ -7,11 +7,6 @@ on: - "versions.yml" - ".github/workflows/build-ruby.yml" - "scripts/**" - pull_request: - paths: - - "versions.yml" - - ".github/workflows/build-ruby.yml" - - "scripts/**" schedule: # Build weekly on Sundays at 02:00 UTC - cron: "0 2 * * 0" @@ -27,12 +22,33 @@ 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_RESTORE_TOKEN: ${{ secrets.BORINGCACHE_RESTORE_TOKEN }} + BORINGCACHE_SAVE_TOKEN: ${{ secrets.BORINGCACHE_SAVE_TOKEN }} BORINGCACHE_DEFAULT_WORKSPACE: ${{ secrets.BORINGCACHE_DEFAULT_WORKSPACE || 'ruby/ruby' }} jobs: + token-check: + name: BoringCache Token Check + runs-on: ubuntu-latest + steps: + - name: Require save and restore tokens + run: | + if [ -z "${BORINGCACHE_RESTORE_TOKEN}" ]; then + echo "BORINGCACHE_RESTORE_TOKEN is required for cache lookup/restore." >&2 + exit 1 + fi + + if [ -z "${BORINGCACHE_SAVE_TOKEN}" ]; then + echo "BORINGCACHE_SAVE_TOKEN is required for cache saves." >&2 + exit 1 + fi + prepare: + needs: token-check runs-on: ubuntu-latest outputs: matrix: ${{ steps.setup.outputs.matrix }} @@ -181,7 +197,8 @@ jobs: docker run --rm \ -v "$PWD:/workspace" \ -w /workspace \ - -e BORINGCACHE_API_TOKEN="${BORINGCACHE_API_TOKEN}" \ + -e BORINGCACHE_RESTORE_TOKEN="${BORINGCACHE_RESTORE_TOKEN}" \ + -e BORINGCACHE_SAVE_TOKEN="${BORINGCACHE_SAVE_TOKEN}" \ -e BORINGCACHE_DEFAULT_WORKSPACE="${BORINGCACHE_DEFAULT_WORKSPACE}" \ ${{ matrix.container }} \ /bin/sh -c ' @@ -317,8 +334,6 @@ jobs: echo "Using native ${{ matrix.arch }} runner for ${{ matrix.platform }}" make ci-build RUBY_VERSION=${{ matrix.version }} PLATFORM=${{ matrix.platform }} ARCH=${{ matrix.arch }} VARIANTS="${{ matrix.variant_group }}" ' - env: - BORINGCACHE_API_TOKEN: ${{ secrets.BORINGCACHE_API_TOKEN }} - name: Check if cache exists (Unix) id: cache-check @@ -460,5 +475,18 @@ jobs: tail -n +11 | \ while read entry; do echo "Deleting old entry: $entry" - boringcache delete "$WORKSPACE" "$entry" || true + boringcache delete --no-platform --no-git "$WORKSPACE" "$entry" || true done + + build-status: + name: Build Status + needs: [token-check, prepare, build-ruby, cleanup] + runs-on: ubuntu-latest + if: always() + steps: + - name: Verify required jobs + run: | + test "${{ needs.token-check.result }}" = "success" + test "${{ needs.prepare.result }}" = "success" + test "${{ needs.build-ruby.result }}" = "success" + test "${{ needs.cleanup.result }}" = "success" diff --git a/.github/workflows/check-ruby-versions.yml b/.github/workflows/check-ruby-versions.yml index 0e5c270..111049d 100644 --- a/.github/workflows/check-ruby-versions.yml +++ b/.github/workflows/check-ruby-versions.yml @@ -5,8 +5,11 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read + +concurrency: + group: ruby-version-check + cancel-in-progress: false jobs: check-versions: @@ -14,108 +17,36 @@ jobs: 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 " + author: "boringcache-bot " + title: "chore: update Ruby versions" + body-path: version-update-summary.txt branch: auto/ruby-version-bump delete-branch: true diff --git a/.github/workflows/validate-ruby.yml b/.github/workflows/validate-ruby.yml new file mode 100644 index 0000000..9d633e5 --- /dev/null +++ b/.github/workflows/validate-ruby.yml @@ -0,0 +1,68 @@ +name: Ruby Config Validation + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Ruby Config Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate configuration files + run: | + set -euo pipefail + + ruby -e 'require "yaml"; %w[versions.yml variants.yml .github/workflows/build-ruby.yml .github/workflows/check-ruby-versions.yml .github/workflows/validate-ruby.yml].each { |path| YAML.load_file(path); puts "ok #{path}" }' + + - name: Validate scripts + run: | + set -euo pipefail + + python3 -m py_compile scripts/update-ruby-versions.py + bash -n scripts/build-ruby-variants.sh + bash -n scripts/configure-platform.sh + + - name: Ensure cache builds never run on pull requests + run: | + set -euo pipefail + + python3 - <<'PY' + from pathlib import Path + + workflow = Path(".github/workflows/build-ruby.yml") + in_on_block = False + offenders = [] + + for number, line in enumerate(workflow.read_text().splitlines(), start=1): + if line.startswith("on:"): + in_on_block = True + continue + + if in_on_block and line and not line.startswith((" ", "\t")): + in_on_block = False + + if in_on_block and line.lstrip().startswith("pull_request:"): + offenders.append(number) + + if offenders: + lines = ", ".join(str(number) for number in offenders) + raise SystemExit(f"{workflow} must not run cache-producing builds on pull_request (line {lines})") + PY + + if grep -R "BORINGCACHE_API_TOKEN" .github/workflows/build-ruby.yml scripts; then + echo "Use split BORINGCACHE_RESTORE_TOKEN and BORINGCACHE_SAVE_TOKEN secrets instead." >&2 + exit 1 + fi + + - name: Check version updater idempotence + run: | + set -euo pipefail + + python3 scripts/update-ruby-versions.py + git diff --exit-code -- versions.yml diff --git a/scripts/build-ruby-variants.sh b/scripts/build-ruby-variants.sh index ca3f16f..ea56a7f 100755 --- a/scripts/build-ruby-variants.sh +++ b/scripts/build-ruby-variants.sh @@ -55,7 +55,7 @@ build_variant() { configure_variant "$variant" # Set up Ruby configure options with variant-specific options - RUBY_CONFIGURE_OPTS="--enable-shared --enable-load-relative --with-static-linked-ext --enable-frozen-string-literal --enable-pthread --enable-debug-env --enable-rubygems $VARIANT_OPTS" + RUBY_CONFIGURE_OPTS="--enable-shared --enable-load-relative --with-static-linked-ext --enable-frozen-string-literal --enable-pthread --enable-debug-env --enable-rubygems --disable-install-doc $VARIANT_OPTS" # Set platform-specific environment variables for macOS local BUILD_CPPFLAGS="${CPPFLAGS:-}" diff --git a/scripts/update-ruby-versions.py b/scripts/update-ruby-versions.py new file mode 100644 index 0000000..9da3847 --- /dev/null +++ b/scripts/update-ruby-versions.py @@ -0,0 +1,208 @@ +#!/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()) + tail_lines = original_lines[end:] + while tail_lines and tail_lines[0].strip() == "": + tail_lines = tail_lines[1:] + + updated_lines = ( + original_lines[:start] + + format_versions_block(updated_versions) + + tail_lines + ) + + 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) diff --git a/versions.yml b/versions.yml index 930bcd8..6f030a7 100644 --- a/versions.yml +++ b/versions.yml @@ -1,20 +1,25 @@ # Ruby versions to build and cache (only officially supported versions) -# Policy: Current major + 3 minor versions, last 3 patch versions each +# Policy: Supported minor series, last 3 patch versions each # Reference: https://endoflife.date/ruby versions: # Ruby 4.0.x series (Stable - Normal Maintenance until 2029-03-31) - - version: "4.0.2" + - version: "4.0.4" status: "stable" priority: "high" maintenance: "normal" eol_date: "2029-03-31" - - version: "4.0.1" + - version: "4.0.3" status: "stable" priority: "medium" maintenance: "normal" eol_date: "2029-03-31" + - version: "4.0.2" + status: "stable" + priority: "low" + maintenance: "normal" + eol_date: "2029-03-31" - # Ruby 3.4.x series (Normal Maintenance until 2028-03-31) + # Ruby 3.4.x series (Stable - Normal Maintenance until 2028-03-31) - version: "3.4.9" status: "stable" priority: "high" @@ -31,35 +36,23 @@ versions: maintenance: "normal" eol_date: "2028-03-31" - # Ruby 3.3.x series (Normal Maintenance until 2027-03-31) - - version: "3.3.10" + # Ruby 3.3.x series (Stable - Normal Maintenance until 2027-03-31) + - version: "3.3.11" status: "stable" priority: "high" maintenance: "normal" eol_date: "2027-03-31" - - version: "3.3.9" + - version: "3.3.10" status: "stable" priority: "medium" maintenance: "normal" eol_date: "2027-03-31" - - version: "3.3.8" + - version: "3.3.9" status: "stable" priority: "low" maintenance: "normal" eol_date: "2027-03-31" - # Ruby 3.2.x series (Security Maintenance until 2026-03-31) - - version: "3.2.10" - status: "stable" - priority: "medium" - maintenance: "security" - eol_date: "2026-03-31" - - version: "3.2.9" - status: "stable" - priority: "low" - maintenance: "security" - eol_date: "2026-03-31" - # Platform configurations (matching CLI platforms exactly) # Only includes non-EOL operating systems platforms: