diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 38c2ebd..e44f9b7 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -29,10 +29,23 @@ on: permissions: contents: write pull-requests: write + issues: write security-events: write packages: read actions: write +concurrency: + group: maintenance-${{ github.ref }} + cancel-in-progress: false + +env: + ARTIFACT_RETENTION_DAYS: "30" + WORKFLOW_RUN_RETENTION_DAYS: "90" + # Security patch versions to monitor (single source of truth) + SECURITY_PATCH_FLAT: "flat@5.0.2" + SECURITY_PATCH_SHAJS: "sha.js@2.4.12" + SECURITY_PATCH_WS: "ws@8.17.1" + jobs: version-tracking-update: name: Update Version Tracking @@ -109,106 +122,197 @@ jobs: printf "%s=%s\n" "$name" "$value" >> "$GITHUB_OUTPUT" } - # Check GitHub Actions Runner releases - LATEST_RUNNER=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/v//') + # Check GitHub Actions Runner releases (with error handling) + LATEST_RUNNER=$(curl -sf --max-time 30 https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/v//' || echo "") + if [[ -z "$LATEST_RUNNER" || "$LATEST_RUNNER" == "null" ]]; then + echo "::warning::Failed to fetch latest runner version from GitHub API" + LATEST_RUNNER="unknown" + fi write_output "latest-runner" "$LATEST_RUNNER" - # Check Node.js LTS versions - LATEST_NODE=$(curl -s https://nodejs.org/dist/index.json | jq -r '.[0].version' | sed 's/v//' | cut -d'.' -f1) + # Check Node.js LTS versions (with error handling) + LATEST_NODE=$(curl -sf --max-time 30 https://nodejs.org/dist/index.json | jq -r '.[0].version' | sed 's/v//' | cut -d'.' -f1 || echo "") + if [[ -z "$LATEST_NODE" || "$LATEST_NODE" == "null" ]]; then + echo "::warning::Failed to fetch latest Node.js version" + LATEST_NODE="unknown" + fi write_output "latest-node-major" "$LATEST_NODE" # Compare versions and set update flags - if [[ "${{ steps.extract-versions.outputs.runner-version }}" != "$LATEST_RUNNER" ]]; then + if [[ "$LATEST_RUNNER" != "unknown" ]] && [[ "${{ steps.extract-versions.outputs.runner-version }}" != "$LATEST_RUNNER" ]]; then write_output "runner-needs-update" "true" fi - - name: Update VERSION_OVERVIEW.md - if: inputs.force_update == true || steps.check-updates.outputs.runner-needs-update == 'true' + - name: Report version status + if: always() run: | - echo "Updating version documentation..." + echo "## Version Status Report" + echo "Current runner: ${{ steps.extract-versions.outputs.runner-version }}" + echo "Latest runner: ${{ steps.check-updates.outputs.latest-runner }}" + echo "Current Node: ${{ steps.extract-versions.outputs.node-version }}" + echo "Latest Node: ${{ steps.check-updates.outputs.latest-node-major }}" + + if [[ "${{ steps.check-updates.outputs.runner-needs-update }}" == "true" ]]; then + echo "::notice::GitHub Actions Runner update available: ${{ steps.check-updates.outputs.latest-runner }}" + fi - # Update the VERSION_OVERVIEW.md with current versions - if [[ -f "docs/VERSION_OVERVIEW.md" ]]; then - # Update last updated date - sed -i "s/\*\*Last Updated\*\*:.*/\*\*Last Updated\*\*: $(date '+%B %d, %Y')/" docs/VERSION_OVERVIEW.md + - name: Manage version update issues + if: always() + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const assignee = 'GrammaTonic'; + const labelName = 'version-update'; + + // Current and latest versions from prior steps + const currentRunner = '${{ steps.extract-versions.outputs.runner-version }}'; + const latestRunner = '${{ steps.check-updates.outputs.latest-runner }}'; + const currentNode = '${{ steps.extract-versions.outputs.node-version }}'; + const latestNode = '${{ steps.check-updates.outputs.latest-node-major }}'; + const needsUpdate = '${{ steps.check-updates.outputs.runner-needs-update }}' === 'true'; + + // Ensure the label exists + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, + color: '1d76db', + description: 'Automated version update tracking' + }); + console.log(`Created label "${labelName}"`); + } - # Update GitHub Actions Runner version if changed - if [[ "${{ steps.check-updates.outputs.runner-needs-update }}" == "true" ]]; then - echo "GitHub Actions Runner update available: ${{ steps.check-updates.outputs.latest-runner }}" - # In a real scenario, this would update the version references - fi - fi + // Fetch all open issues with our label + const { data: existingIssues } = await github.rest.issues.listForRepo({ + owner, repo, + labels: labelName, + state: 'open', + per_page: 100 + }); + + // --- Runner version --- + const runnerTitle = `Update GitHub Actions Runner to ${latestRunner}`; + + if (needsUpdate && latestRunner !== 'unknown') { + // Check for duplicate + const duplicate = existingIssues.find(i => i.title === runnerTitle); + if (duplicate) { + console.log(`Issue already exists: #${duplicate.number} — ${runnerTitle}`); + } else { + const { data: issue } = await github.rest.issues.create({ + owner, repo, + title: runnerTitle, + body: [ + '## GitHub Actions Runner Update Available', + '', + `| | Version |`, + `|---|---|`, + `| **Current** | ${currentRunner} |`, + `| **Latest** | ${latestRunner} |`, + '', + '### Steps', + '1. Update `ARG RUNNER_VERSION` in `docker/Dockerfile`', + '2. Rebuild and test all runner variants', + '3. Update `docs/VERSION_OVERVIEW.md`', + '', + '*Auto-created by maintenance workflow*' + ].join('\n'), + labels: [labelName], + assignees: [assignee] + }); + console.log(`Created issue #${issue.number}: ${runnerTitle}`); + } + } + + // --- Close issues for versions already applied --- + for (const issue of existingIssues) { + const title = issue.title; + + // Runner: close if current version matches the version in the issue title + if (title.startsWith('Update GitHub Actions Runner to ')) { + const targetVersion = title.replace('Update GitHub Actions Runner to ', ''); + if (targetVersion === currentRunner) { + await github.rest.issues.update({ + owner, repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed' + }); + console.log(`Closed issue #${issue.number} — runner already at ${currentRunner}`); + } + } + + // Node: close if current major matches + if (title.startsWith('Update Node.js to ')) { + const targetMajor = title.replace('Update Node.js to ', '').replace('.x', ''); + const currentMajor = currentNode.replace('.x', ''); + if (targetMajor === currentMajor) { + await github.rest.issues.update({ + owner, repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed' + }); + console.log(`Closed issue #${issue.number} — Node.js already at ${currentNode}`); + } + } + } update-docker-base-images: - name: Update Docker Base Images + name: Audit Docker Base Images runs-on: ubuntu-latest if: inputs.update_type == 'all' || inputs.update_type == 'docker-images' || github.event_name == 'schedule' steps: - name: Checkout code uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Check for Ubuntu base image updates + - name: Check Docker base image versions run: | - echo "Checking for Docker base image updates..." - - # Check current Ubuntu version in Dockerfiles - current_ubuntu=$(grep -E '^FROM ubuntu:' docker/Dockerfile | head -1 | cut -d':' -f2) - echo "Current Ubuntu version: $current_ubuntu" - - # Check for newer Ubuntu LTS versions - echo "Checking for newer Ubuntu LTS releases..." + echo "::notice::Docker base image updates are managed by Dependabot (see .github/dependabot.yml)" + echo "Current Docker base images:" - # In practice, you might use Docker Hub API or vulnerability scanning - echo "Current base image: ubuntu:$current_ubuntu" - echo "Base image security status: ✅ Using supported LTS version" + for dockerfile in docker/Dockerfile docker/Dockerfile.chrome docker/Dockerfile.chrome-go; do + if [[ -f "$dockerfile" ]]; then + base_image=$(grep -E '^FROM ' "$dockerfile" | head -1 | cut -d' ' -f2) + echo " $(basename "$dockerfile"): $base_image" + fi + done - - name: Check for security updates in base images + - name: Verify security patches in Dockerfiles run: | - # Run security scan on base images - echo "Scanning base images for security updates..." - - # Check both standard and Chrome Dockerfiles - for dockerfile in docker/Dockerfile docker/Dockerfile.chrome; do - if [[ -f "$dockerfile" ]]; then - echo "Checking $dockerfile for security updates..." - base_image=$(grep -E '^FROM' "$dockerfile" | head -1 | cut -d' ' -f2) - echo "Base image: $base_image" + echo "Verifying security patches are present in Dockerfiles..." - # In practice, you'd pull and scan the image - echo "Security scan would be performed here" + for patch in "$SECURITY_PATCH_FLAT" "$SECURITY_PATCH_SHAJS" "$SECURITY_PATCH_WS"; do + if grep -q "$patch" docker/Dockerfile* 2>/dev/null; then + echo " ✅ $patch applied" + else + echo "::warning::Security patch may be missing: $patch" fi done update-github-actions: - name: Update GitHub Actions + name: Audit GitHub Actions Versions runs-on: ubuntu-latest if: inputs.update_type == 'all' || inputs.update_type == 'github-actions' || github.event_name == 'schedule' steps: - name: Checkout code uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Check for action updates + - name: Audit current action versions run: | - echo "Checking for GitHub Actions updates..." + echo "::notice::GitHub Actions version updates are managed by Dependabot (see .github/dependabot.yml)" + echo "## Actions currently in use:" - # Extract current actions and versions find .github/workflows -name "*.yml" -o -name "*.yaml" | \ - xargs grep -h "uses:" | \ - sort | uniq | \ - while read -r line; do - echo "Found action: $line" + xargs grep -hE '^\s+uses:' | \ + sed 's/.*uses:\s*//' | \ + sort -u | while read -r action; do + echo " - $action" done - - name: Update actions if needed - run: | - # This would contain logic to update GitHub Actions to latest versions - # For now, just a placeholder - echo "No GitHub Actions updates required at this time" - security-vulnerability-monitoring: name: Enhanced Security Monitoring runs-on: ubuntu-latest @@ -224,7 +328,9 @@ jobs: uses: ./.github/actions/install-trivy - name: Run comprehensive security scan + id: trivy-scan uses: aquasecurity/trivy-action@0.34.1 + continue-on-error: true with: scan-type: "fs" scan-ref: "." @@ -233,6 +339,7 @@ jobs: skip-setup-trivy: true - name: Upload security scan results + if: steps.trivy-scan.outcome == 'success' uses: github/codeql-action/upload-sarif@v4 continue-on-error: true with: @@ -245,9 +352,9 @@ jobs: # Check if known security fixes are still applied security_patches=( - "flat@5.0.2:VDB-216777/CVE-2020-36632:Prototype pollution fix" - "sha.js@2.4.12:CVE-2025-9288:Cypress dependency fix" - "ws@8.17.1:CVE-2024-37890:WebSocket DoS fix" + "${SECURITY_PATCH_FLAT}:VDB-216777/CVE-2020-36632:Prototype pollution fix" + "${SECURITY_PATCH_SHAJS}:CVE-2025-9288:Cypress dependency fix" + "${SECURITY_PATCH_WS}:CVE-2024-37890:WebSocket DoS fix" ) for patch in "${security_patches[@]}"; do @@ -296,6 +403,10 @@ jobs: run: | echo "Analyzing security scan results..." + if [[ "${{ steps.trivy-scan.outcome }}" == "failure" ]]; then + echo "::warning::Trivy scan failed — SARIF results may be incomplete" + fi + if [[ -f "dependency-security-scan.sarif" ]]; then # Parse SARIF for severity levels critical_count=$(jq '[.runs[].results[] | select(.level == "error")] | length' dependency-security-scan.sarif 2>/dev/null || echo "0") @@ -320,7 +431,7 @@ jobs: with: name: security-status-report path: security-status.md - retention-days: 30 + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} documentation-maintenance: name: Documentation Maintenance @@ -332,15 +443,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Update documentation timestamps + - name: Check documentation timestamps run: | - echo "Updating documentation timestamps..." + echo "Checking documentation timestamps..." - # Update VERSION_OVERVIEW.md last updated date if [[ -f "docs/VERSION_OVERVIEW.md" ]]; then - current_date=$(date '+%B %d, %Y') - sed -i "s/\*\*Last Updated\*\*:.*/\*\*Last Updated\*\*: $current_date/" docs/VERSION_OVERVIEW.md - echo "Updated VERSION_OVERVIEW.md timestamp" + last_updated=$(grep -oP '\*\*Last Updated\*\*:\s*\K.*' docs/VERSION_OVERVIEW.md || echo "unknown") + echo "VERSION_OVERVIEW.md last updated: $last_updated" fi - name: Validate documentation links @@ -393,23 +502,16 @@ jobs: fi done - - name: Update wiki content synchronization + - name: Check wiki content status run: | - echo "Synchronizing wiki content..." - - # Check if wiki-content and wiki-repo are in sync - if [[ -d "wiki-content" ]] && [[ -d "wiki-repo" ]]; then - # Compare key files - for file in wiki-content/*.md; do - filename=$(basename "$file") - wiki_file="wiki-repo/$filename" - - if [[ -f "$wiki_file" ]]; then - if ! diff -q "$file" "$wiki_file" > /dev/null; then - echo "::warning::Wiki content out of sync: $filename" - fi - fi - done + echo "Checking wiki content..." + + if [[ -d "wiki-content" ]]; then + wiki_count=$(find wiki-content -name "*.md" -type f | wc -l) + echo "Wiki content files: $wiki_count" + echo "::notice::Wiki sync is handled by the auto-sync-docs workflow" + else + echo "::warning::wiki-content directory not found" fi cleanup-old-artifacts: @@ -429,9 +531,9 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - 90); + cutoffDate.setDate(cutoffDate.getDate() - parseInt('${{ env.WORKFLOW_RUN_RETENTION_DAYS }}')); - console.log(`Cleaning up workflow runs older than ${cutoffDate.toISOString()}`); + console.log(`Cleaning up workflow runs older than ${cutoffDate.toISOString()} (${{ env.WORKFLOW_RUN_RETENTION_DAYS }} days)`); const workflows = await github.rest.actions.listRepoWorkflows({ owner, @@ -468,18 +570,126 @@ jobs: console.log(`Deleted ${deletedCount} old workflow runs`); - - name: Cleanup old container images - run: | - echo "Cleaning up old container images..." + - name: Cleanup caches from closed PRs + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + // 1. Collect all open PR refs (e.g. "refs/pull/123/merge") + const openRefs = new Set(); + for await (const response of github.paginate.iterator( + github.rest.pulls.list, + { owner, repo, state: 'open', per_page: 100 } + )) { + for (const pr of response.data) { + openRefs.add(`refs/pull/${pr.number}/merge`); + } + } + console.log(`Open PRs: ${openRefs.size}`); + + // 2. Walk every cache entry, delete those referencing closed PRs + let deletedCount = 0; + let totalCaches = 0; + let freedBytes = 0; + + for await (const response of github.paginate.iterator( + github.rest.actions.getActionsCacheList, + { owner, repo, per_page: 100 } + )) { + for (const cache of response.data) { + totalCaches++; + const ref = cache.ref; + + // Only target PR refs; skip branch caches (develop, main, etc.) + if (ref && ref.startsWith('refs/pull/') && !openRefs.has(ref)) { + console.log(`Deleting cache "${cache.key}" (ref: ${ref}, ${(cache.size_in_bytes / 1024 / 1024).toFixed(1)} MB)`); + try { + await github.rest.actions.deleteActionsCacheById({ + owner, + repo, + cache_id: cache.id + }); + deletedCount++; + freedBytes += cache.size_in_bytes; + } catch (error) { + console.log(`Failed to delete cache ${cache.id}: ${error.message}`); + } + } + } + } + + const freedMB = (freedBytes / 1024 / 1024).toFixed(1); + console.log(`\nSummary: deleted ${deletedCount} of ${totalCaches} caches (freed ${freedMB} MB)`); + + if (deletedCount === 0) { + console.log('No stale PR caches found — nothing to clean up.'); + } + + - name: Cleanup caches from deleted branches + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; - # This would clean up old images from GitHub Container Registry - # For now, just log what would be cleaned - echo "Would clean up container images older than 30 days" - echo "Keeping latest 10 versions of each image" + // 1. Collect all existing branch refs (e.g. "refs/heads/develop") + const existingBranches = new Set(); + for await (const response of github.paginate.iterator( + github.rest.repos.listBranches, + { owner, repo, per_page: 100 } + )) { + for (const branch of response.data) { + existingBranches.add(`refs/heads/${branch.name}`); + } + } + console.log(`Existing branches: ${existingBranches.size}`); + + // 2. Walk every cache entry, delete those referencing deleted branches + let deletedCount = 0; + let totalBranchCaches = 0; + let freedBytes = 0; + + for await (const response of github.paginate.iterator( + github.rest.actions.getActionsCacheList, + { owner, repo, per_page: 100 } + )) { + for (const cache of response.data) { + const ref = cache.ref; + + // Only target branch refs; skip PR refs (handled in previous step) + if (ref && ref.startsWith('refs/heads/')) { + totalBranchCaches++; + if (!existingBranches.has(ref)) { + console.log(`Deleting cache "${cache.key}" (ref: ${ref}, ${(cache.size_in_bytes / 1024 / 1024).toFixed(1)} MB)`); + try { + await github.rest.actions.deleteActionsCacheById({ + owner, + repo, + cache_id: cache.id + }); + deletedCount++; + freedBytes += cache.size_in_bytes; + } catch (error) { + console.log(`Failed to delete cache ${cache.id}: ${error.message}`); + } + } + } + } + } + + const freedMB = (freedBytes / 1024 / 1024).toFixed(1); + console.log(`\nSummary: deleted ${deletedCount} of ${totalBranchCaches} branch caches (freed ${freedMB} MB)`); + + if (deletedCount === 0) { + console.log('No stale branch caches found — nothing to clean up.'); + } comprehensive-health-check: name: Comprehensive Repository Health Check runs-on: ubuntu-latest + if: inputs.update_type == 'all' || inputs.update_type == 'version-tracking' || github.event_name == 'schedule' steps: - name: Checkout code uses: actions/checkout@v6 @@ -537,8 +747,8 @@ jobs: ((script_issues++)) fi - # Check syntax - if ! bash -n "$script" 2>/dev/null; then + # Check syntax (show errors for debugging) + if ! bash -n "$script"; then echo "::error::Script $script has syntax errors" ((script_issues++)) fi @@ -615,9 +825,9 @@ jobs: # Check for known security patches in Dockerfiles security_patches=( - "flat@5.0.2" - "sha.js@2.4.12" - "ws@8.17.1" + "$SECURITY_PATCH_FLAT" + "$SECURITY_PATCH_SHAJS" + "$SECURITY_PATCH_WS" ) for patch in "${security_patches[@]}"; do @@ -688,7 +898,7 @@ jobs: with: name: comprehensive-health-report path: comprehensive-health-report.md - retention-days: 30 + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} maintenance-summary: name: Maintenance Summary @@ -723,6 +933,7 @@ jobs: success_count=0 failed_count=0 + skipped_count=0 echo "# Maintenance Workflow Summary" > maintenance-summary.md echo "" >> maintenance-summary.md @@ -735,19 +946,26 @@ jobs: set +e for status in "${jobs_status[@]}"; do - echo "- $status" >> maintenance-summary.md if [[ "$status" == *"success"* ]]; then + echo "- ✅ $status" >> maintenance-summary.md ((success_count++)) elif [[ "$status" == *"failure"* ]]; then + echo "- ❌ $status" >> maintenance-summary.md ((failed_count++)) + elif [[ "$status" == *"skipped"* ]]; then + echo "- ⏭️ $status" >> maintenance-summary.md + ((skipped_count++)) + else + echo "- ⚠️ $status" >> maintenance-summary.md fi done set -e echo "" >> maintenance-summary.md echo "## Summary" >> maintenance-summary.md - echo "- ✅ Successful jobs: $success_count" >> maintenance-summary.md - echo "- ❌ Failed jobs: $failed_count" >> maintenance-summary.md + echo "- ✅ Successful: $success_count" >> maintenance-summary.md + echo "- ❌ Failed: $failed_count" >> maintenance-summary.md + echo "- ⏭️ Skipped: $skipped_count" >> maintenance-summary.md echo "" >> maintenance-summary.md echo "## Actions Taken" >> maintenance-summary.md echo "- Security vulnerability scanning completed" >> maintenance-summary.md @@ -769,4 +987,4 @@ jobs: with: name: maintenance-summary path: maintenance-summary.md - retention-days: 30 + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} diff --git a/docker/Dockerfile b/docker/Dockerfile index 4824657..fff2108 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,7 +9,7 @@ FROM ubuntu:resolute AS builder ARG TARGETPLATFORM ARG TARGETARCH ARG TARGETOS -ARG RUNNER_VERSION="2.331.0" +ARG RUNNER_VERSION="2.332.0" ARG CROSS_SPAWN_VERSION="7.0.6" ARG TAR_VERSION="7.5.9" ARG BRACE_EXPANSION_VERSION="5.0.4" @@ -113,7 +113,7 @@ LABEL version="2.2.0" # --- ARGUMENTS FOR RUNTIME --- ARG TARGETARCH -ARG RUNNER_VERSION="2.331.0" +ARG RUNNER_VERSION="2.332.0" ARG CROSS_SPAWN_VERSION="7.0.6" ARG TAR_VERSION="7.5.9" ARG BRACE_EXPANSION_VERSION="5.0.4" diff --git a/docker/Dockerfile.chrome b/docker/Dockerfile.chrome index 48da23e..8a70d9c 100644 --- a/docker/Dockerfile.chrome +++ b/docker/Dockerfile.chrome @@ -18,7 +18,7 @@ LABEL version="2.2.0" ARG TARGETPLATFORM ARG TARGETARCH ARG TARGETOS -ARG RUNNER_VERSION="2.331.0" +ARG RUNNER_VERSION="2.332.0" ARG CHROME_VERSION="146.0.7680.31" ARG NODE_VERSION="24.14.0" ARG NPM_VERSION="11.11.0" diff --git a/docker/Dockerfile.chrome-go b/docker/Dockerfile.chrome-go index 1bfe2a0..4ef4d5d 100644 --- a/docker/Dockerfile.chrome-go +++ b/docker/Dockerfile.chrome-go @@ -19,7 +19,7 @@ LABEL version="2.2.0" ARG TARGETPLATFORM ARG TARGETARCH ARG TARGETOS -ARG RUNNER_VERSION="2.331.0" +ARG RUNNER_VERSION="2.332.0" ARG CHROME_VERSION="146.0.7680.31" ARG NODE_VERSION="24.14.0" ARG NPM_VERSION="11.11.0" diff --git a/docs/VERSION_OVERVIEW.md b/docs/VERSION_OVERVIEW.md index b138ede..d26a600 100644 --- a/docs/VERSION_OVERVIEW.md +++ b/docs/VERSION_OVERVIEW.md @@ -24,9 +24,9 @@ This document provides a comprehensive overview of all software versions, depend ### GitHub Actions Runner -- **Version**: `2.331.0` +- **Version**: `2.332.0` - **Source**: GitHub official releases -- **Download URL**: `https://github.com/actions/runner/releases/download/v2.331.0/` +- **Download URL**: `https://github.com/actions/runner/releases/download/v2.332.0/` - **Security Status**: ✅ Latest stable version ### Operating System