diff --git a/.github/scripts/release-index/.gitignore b/.github/scripts/release-index/.gitignore new file mode 100644 index 000000000..411c5987b --- /dev/null +++ b/.github/scripts/release-index/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +releases/ diff --git a/.github/scripts/release-index/build-releases.js b/.github/scripts/release-index/build-releases.js new file mode 100644 index 000000000..0fe2912d9 --- /dev/null +++ b/.github/scripts/release-index/build-releases.js @@ -0,0 +1,303 @@ +const { Octokit } = require("@octokit/rest"); +const semver = require("semver"); +const fs = require("fs"); +const path = require("path"); +const { load } = require("js-yaml"); + +const OWNER = "codefresh-io"; +const REPO = "gitops-runtime-helm"; +const LATEST_PATTERN = /^(\d{4})\.(\d{1,2})-(\d+)$/; +const TOKEN = process.env.GITHUB_TOKEN; +const SECURITY_FIXES_STRING = + process.env.SECURITY_FIXES_STRING || "### Security Fixes:"; +const MAX_RELEASES_PER_CHANNEL = 10; +const MAX_GITHUB_RELEASES = 1000; +const CHART_PATH = "charts/gitops-runtime/Chart.yaml"; +const DEFAULT_APP_VERSION = "0.0.0"; + +if (!TOKEN) { + console.error("āŒ GITHUB_TOKEN environment variable is required"); + process.exit(1); +} + +const octokit = new Octokit({ auth: TOKEN }); + +function detectChannel(version) { + const match = version.match(LATEST_PATTERN); + if (match) { + const month = Number(match[2]); + if (month >= 1 && month <= 12) { + return "latest"; + } + } + return "stable"; +} + +/** + * Normalize version for semver validation + * Converts: 2025.01-1 → 2025.1.1 + */ +function normalizeVersion(version, channel) { + if (channel === "latest") { + const match = version.match(LATEST_PATTERN); + if (match) { + const year = match[1]; + const month = Number(match[2]); + const patch = match[3]; + return `${year}.${month}.${patch}`; + } + } + return version; +} + +function isValidVersion(normalized) { + return !!semver.valid(normalized); +} + +function compareVersions(normA, normB) { + try { + return semver.compare(normA, normB); + } catch (error) { + console.warn(`Failed to compare versions:`, error.message); + return 0; + } +} + +async function getAppVersionFromChart(tag) { + try { + const { data } = await octokit.repos.getContent({ + owner: OWNER, + repo: REPO, + path: CHART_PATH, + ref: tag, + mediaType: { + format: "raw", + }, + }); + + const chart = load(data); + return chart.appVersion || DEFAULT_APP_VERSION; + } catch (error) { + console.warn(` āš ļø Failed to get appVersion for ${tag}:`, error.message); + return DEFAULT_APP_VERSION; + } +} + +async function fetchReleases() { + console.log("šŸ“¦ Fetching releases from GitHub using Octokit..."); + + const allReleases = []; + let page = 0; + + try { + for await (const response of octokit.paginate.iterator( + octokit.rest.repos.listReleases, + { + owner: OWNER, + repo: REPO, + per_page: 100, + } + )) { + page++; + const releases = response.data; + + allReleases.push(...releases); + console.log(` Fetched page ${page} (${releases.length} releases)`); + + if (allReleases.length >= MAX_GITHUB_RELEASES) { + console.log( + ` Reached ${MAX_GITHUB_RELEASES} releases limit, stopping...` + ); + break; + } + } + } catch (error) { + console.error("Error fetching releases:", error.message); + throw error; + } + + console.log(`āœ… Fetched ${allReleases.length} total releases`); + return allReleases; +} + +function processReleases(rawReleases) { + console.log("\nšŸ” Processing releases..."); + + const releases = []; + const channels = { stable: [], latest: [] }; + + let skipped = 0; + + for (const release of rawReleases) { + if (release.draft || release.prerelease) { + skipped++; + console.log(` āš ļø Skipping draft or prerelease: ${release.tag_name}`); + continue; + } + + const version = release.tag_name || release.name; + if (!version) { + skipped++; + console.log( + ` āš ļø Skipping release without version: ${release.tag_name}` + ); + continue; + } + + const channel = detectChannel(version); + + const normalized = normalizeVersion(version, channel); + + if (!isValidVersion(normalized)) { + console.log(` āš ļø Skipping invalid version: ${version}`); + skipped++; + continue; + } + + const hasSecurityFixes = + release.body?.includes(SECURITY_FIXES_STRING) || false; + + const releaseData = { + version, + normalized, + channel, + hasSecurityFixes, + publishedAt: release.published_at, + url: release.html_url, + createdAt: release.created_at, + }; + + releases.push(releaseData); + channels[channel].push(releaseData); + } + + console.log( + `āœ… Processed ${releases.length} valid releases (skipped ${skipped})` + ); + console.log(` Stable: ${channels.stable.length}`); + console.log(` Latest: ${channels.latest.length}`); + + return { releases, channels }; +} + +async function buildChannelData(channelReleases, channelName) { + const sorted = channelReleases.sort((a, b) => { + return compareVersions(b.normalized, a.normalized); + }); + + const latestWithSecurityFixes = + sorted.find((r) => r.hasSecurityFixes)?.version || null; + const topReleases = sorted.slice(0, MAX_RELEASES_PER_CHANNEL); + + console.log( + ` Fetching appVersion for ${topReleases.length} ${channelName} releases...` + ); + for (const release of topReleases) { + release.appVersion = await getAppVersionFromChart(release.version); + } + + const latestVersion = sorted[0]?.version; + const latestSecureIndex = latestWithSecurityFixes + ? sorted.findIndex((r) => r.version === latestWithSecurityFixes) + : -1; + + topReleases.forEach((release, index) => { + release.upgradeAvailable = release.version !== latestVersion; + release.hasSecurityVulnerabilities = + latestSecureIndex >= 0 && index > latestSecureIndex; + }); + + return { + releases: topReleases, + latestChartVersion: sorted[0]?.version || null, + latestWithSecurityFixes, + }; +} + +async function buildIndex() { + console.log("šŸš€ Building release index...\n"); + console.log(`šŸ“ Repository: ${OWNER}/${REPO}\n`); + + try { + const rawReleases = await fetchReleases(); + + const { releases, channels } = processReleases(rawReleases); + + console.log("\nšŸ“Š Building channel data..."); + const stable = await buildChannelData(channels.stable, "stable"); + const latest = await buildChannelData(channels.latest, "latest"); + + console.log(` Stable latest: ${stable.latest || "none"}`); + console.log(` Latest latest: ${latest.latest || "none"}`); + if (stable.latestWithSecurityFixes) { + console.log(` šŸ”’ Stable security: ${stable.latestWithSecurityFixes}`); + } + if (latest.latestWithSecurityFixes) { + console.log(` šŸ”’ Latest security: ${latest.latestWithSecurityFixes}`); + } + + const index = { + generatedAt: new Date().toISOString(), + repository: `${OWNER}/${REPO}`, + channels: { + stable: { + releases: stable.releases, + latestChartVersion: stable.latestChartVersion, + latestWithSecurityFixes: stable.latestWithSecurityFixes, + }, + latest: { + releases: latest.releases, + latestChartVersion: latest.latestChartVersion, + latestWithSecurityFixes: latest.latestWithSecurityFixes, + }, + }, + stats: { + totalReleases: releases.length, + stableSecure: stable.latestWithSecurityFixes || null, + latestSecure: latest.latestWithSecurityFixes || null, + }, + }; + + console.log("\nšŸ’¾ Writing index file..."); + const outDir = path.join(process.cwd(), "releases"); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + const outputPath = path.join(outDir, "releases.json"); + fs.writeFileSync(outputPath, JSON.stringify(index, null, 2)); + + console.log("\nāœ… Release index built successfully!"); + console.log("\nšŸ“‹ Summary:"); + console.log(` Total releases: ${index.stats.totalReleases}`); + console.log(`\n 🟢 Stable Channel:`); + console.log( + ` Latest: ${index.channels.stable.latestChartVersion || "none"}` + ); + console.log( + ` Latest secure: ${ + index.channels.stable.latestWithSecurityFixes || "none" + }` + ); + console.log(`\n šŸ”µ Latest Channel:`); + console.log( + ` Latest: ${index.channels.latest.latestChartVersion || "none"}` + ); + console.log( + ` Latest secure: ${ + index.channels.latest.latestWithSecurityFixes || "none" + }` + ); + console.log(`\nšŸ“ Files created:`); + console.log(` ${outputPath}`); + } catch (error) { + console.error("\nāŒ Error building index:", error.message); + if (error.status) { + console.error(` GitHub API Status: ${error.status}`); + } + console.error(error.stack); + process.exit(1); + } +} + +buildIndex(); diff --git a/.github/scripts/release-index/package-lock.json b/.github/scripts/release-index/package-lock.json new file mode 100644 index 000000000..26af070b2 --- /dev/null +++ b/.github/scripts/release-index/package-lock.json @@ -0,0 +1,233 @@ +{ + "name": "gitops-runtime-helm-releases", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitops-runtime-helm-releases", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^22.0.1", + "js-yaml": "^4.1.0", + "semver": "^7.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.6.tgz", + "integrity": "sha512-FO+UgZCUu+pPnZAR+iKdUt64kPE7QW7ciqpldaMXaNzixz5Jld8dJ31LAUewk0cfSRkNSRKyqG438ba9c/qDlQ==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.2.tgz", + "integrity": "sha512-U8piOROoQQUyExw5c6dTkU3GKxts5/ERRThIauNL7yaRoeXW0q/5bgHWT7JfWBw1UyrbK8ERId2wVkcB32n0uQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/.github/scripts/release-index/package.json b/.github/scripts/release-index/package.json new file mode 100644 index 000000000..4a79a0c79 --- /dev/null +++ b/.github/scripts/release-index/package.json @@ -0,0 +1,31 @@ +{ + "name": "gitops-runtime-helm-releases", + "version": "1.0.0", + "description": "Automated release index builder for Codefresh GitOps Runtime Helm charts", + "main": "build-releases.js", + "scripts": { + "build": "node build-releases.js", + "start": "npm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/codefresh-io/gitops-runtime-helm.git" + }, + "keywords": [ + "codefresh", + "gitops", + "helm", + "releases", + "kubernetes" + ], + "author": "Codefresh", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@octokit/rest": "^22.0.1", + "js-yaml": "^4.1.0", + "semver": "^7.7.3" + } +} diff --git a/.github/workflows/release-index.yaml b/.github/workflows/release-index.yaml new file mode 100644 index 000000000..b3ca8e6f6 --- /dev/null +++ b/.github/workflows/release-index.yaml @@ -0,0 +1,60 @@ +name: Update Release Index + +on: + release: + types: [published, edited] + workflow_dispatch: + +concurrency: + group: release-index + cancel-in-progress: false + +jobs: + build-index: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: .github/scripts/release-index/package-lock.json + + - name: Install dependencies + working-directory: .github/scripts/release-index + run: npm ci + + - name: Build Release Index + working-directory: .github/scripts/release-index + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node build-releases.js + timeout-minutes: 2 + + - name: Verify output + working-directory: .github/scripts/release-index + run: | + if [ ! -f releases/releases.json ]; then + echo "Error: releases.json not generated" + exit 1 + fi + echo "āœ“ Release index generated successfully" + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + if: success() + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: .github/scripts/release-index/releases + publish_branch: gh-pages + user_name: "github-actions[bot]" + user_email: "github-actions[bot]@users.noreply.github.com" + commit_message: "Update release index - ${{ github.run_number }}" + enable_jekyll: false