|
| 1 | +name: PECU Release Channel Badge Sync |
| 2 | + |
| 3 | +on: |
| 4 | + release: |
| 5 | + types: [published, edited] |
| 6 | + schedule: |
| 7 | + # Every 6 hours (adjust as needed) |
| 8 | + - cron: "0 */6 * * *" |
| 9 | + workflow_dispatch: {} |
| 10 | + |
| 11 | +permissions: |
| 12 | + contents: write |
| 13 | + |
| 14 | +concurrency: |
| 15 | + group: pecu-release-channel-badge-sync |
| 16 | + cancel-in-progress: true |
| 17 | + |
| 18 | +jobs: |
| 19 | + sync: |
| 20 | + # Avoid loops only for release-triggered runs caused by the bot itself. |
| 21 | + if: ${{ github.event_name != 'release' || github.actor != 'github-actions[bot]' }} |
| 22 | + runs-on: ubuntu-latest |
| 23 | + |
| 24 | + steps: |
| 25 | + - name: Sync channel badges in release notes |
| 26 | + uses: actions/github-script@v7 |
| 27 | + with: |
| 28 | + script: | |
| 29 | + const core = require("@actions/core"); |
| 30 | +
|
| 31 | + const owner = context.repo.owner; |
| 32 | + const repo = context.repo.repo; |
| 33 | +
|
| 34 | + const START = "<!-- PECU_CHANNEL_BADGE_START -->"; |
| 35 | + const END = "<!-- PECU_CHANNEL_BADGE_END -->"; |
| 36 | +
|
| 37 | + const palette = { |
| 38 | + stable: { label: "Stable", color: "2E8B57" }, |
| 39 | + beta: { label: "Beta", color: "1E90FF" }, |
| 40 | + preview: { label: "Preview", color: "F59E0B" }, |
| 41 | + experimental: { label: "Experimental", color: "FF8C00" }, |
| 42 | + nightly: { label: "Nightly", color: "8B5CF6" }, |
| 43 | + deprecated: { label: "Deprecated", color: "6B7280" }, |
| 44 | + }; |
| 45 | +
|
| 46 | + function normalizeChannel(raw) { |
| 47 | + if (!raw) return null; |
| 48 | + let v = raw.trim(); |
| 49 | +
|
| 50 | + // Common typos / variants |
| 51 | + const lower = v.toLowerCase(); |
| 52 | + if (lower === "deprecates") v = "Deprecated"; |
| 53 | + if (lower === "depreciated") v = "Deprecated"; |
| 54 | +
|
| 55 | + return v; |
| 56 | + } |
| 57 | +
|
| 58 | + function buildBadgeBlock(channelRaw) { |
| 59 | + const channelKey = channelRaw.toLowerCase(); |
| 60 | + const picked = palette[channelKey] || { label: channelRaw, color: "6B7280" }; |
| 61 | +
|
| 62 | + const labelEnc = encodeURIComponent(picked.label.replace(/\s+/g, " ")); |
| 63 | + const badgeUrl = |
| 64 | + `https://img.shields.io/badge/PECU%20Channel-${labelEnc}-${picked.color}` + |
| 65 | + `?style=for-the-badge&logo=github&logoColor=white`; |
| 66 | +
|
| 67 | + return [ |
| 68 | + START, |
| 69 | + `<p align="center">`, |
| 70 | + ` <img src="${badgeUrl}" alt="PECU Channel: ${picked.label}">`, |
| 71 | + `</p>`, |
| 72 | + END, |
| 73 | + ].join("\n"); |
| 74 | + } |
| 75 | +
|
| 76 | + function upsertBadge(body, badgeBlock) { |
| 77 | + const text = body || ""; |
| 78 | +
|
| 79 | + const hasMarkers = text.includes(START) && text.includes(END); |
| 80 | +
|
| 81 | + if (hasMarkers) { |
| 82 | + const re = new RegExp(`${START}[\\s\\S]*?${END}`, "m"); |
| 83 | + return text.replace(re, badgeBlock); |
| 84 | + } |
| 85 | +
|
| 86 | + // Append at bottom with a divider to keep it visually clean |
| 87 | + const trimmed = text.replace(/\s+$/g, ""); |
| 88 | + return `${trimmed}\n\n---\n\n${badgeBlock}\n`; |
| 89 | + } |
| 90 | +
|
| 91 | + function extractChannel(body) { |
| 92 | + const text = body || ""; |
| 93 | + // Read from anywhere in body (including inside <!-- --> blocks) |
| 94 | + const match = text.match(/PECU-Channel:\s*([^\n\r]+)/i); |
| 95 | + if (!match) return null; |
| 96 | + return normalizeChannel(match[1]); |
| 97 | + } |
| 98 | +
|
| 99 | + async function processRelease(release) { |
| 100 | + const body = release.body || ""; |
| 101 | + const channel = extractChannel(body); |
| 102 | +
|
| 103 | + if (!channel) { |
| 104 | + core.info(`Release ${release.tag_name}: PECU-Channel not found. Skipping.`); |
| 105 | + return { updated: false, reason: "no-channel" }; |
| 106 | + } |
| 107 | +
|
| 108 | + const badgeBlock = buildBadgeBlock(channel); |
| 109 | + const nextBody = upsertBadge(body, badgeBlock); |
| 110 | +
|
| 111 | + if (nextBody === body) { |
| 112 | + core.info(`Release ${release.tag_name}: already up to date.`); |
| 113 | + return { updated: false, reason: "no-change" }; |
| 114 | + } |
| 115 | +
|
| 116 | + await github.rest.repos.updateRelease({ |
| 117 | + owner, |
| 118 | + repo, |
| 119 | + release_id: release.id, |
| 120 | + body: nextBody, |
| 121 | + }); |
| 122 | +
|
| 123 | + core.info(`Release ${release.tag_name}: updated badge -> ${channel}`); |
| 124 | + return { updated: true, reason: "updated" }; |
| 125 | + } |
| 126 | +
|
| 127 | + async function listAllReleases() { |
| 128 | + const releases = []; |
| 129 | + let page = 1; |
| 130 | +
|
| 131 | + while (true) { |
| 132 | + const res = await github.rest.repos.listReleases({ |
| 133 | + owner, |
| 134 | + repo, |
| 135 | + per_page: 100, |
| 136 | + page, |
| 137 | + }); |
| 138 | +
|
| 139 | + if (!res.data || res.data.length === 0) break; |
| 140 | +
|
| 141 | + releases.push(...res.data); |
| 142 | + if (res.data.length < 100) break; |
| 143 | + page += 1; |
| 144 | + } |
| 145 | +
|
| 146 | + return releases; |
| 147 | + } |
| 148 | +
|
| 149 | + // Mode selection |
| 150 | + if (context.eventName === "release") { |
| 151 | + const release = context.payload.release; |
| 152 | + await processRelease(release); |
| 153 | + return; |
| 154 | + } |
| 155 | +
|
| 156 | + // Scheduled / manual: scan everything |
| 157 | + const releases = await listAllReleases(); |
| 158 | + core.info(`Scanning ${releases.length} releases...`); |
| 159 | +
|
| 160 | + let updatedCount = 0; |
| 161 | + let skippedNoChannel = 0; |
| 162 | +
|
| 163 | + for (const r of releases) { |
| 164 | + const result = await processRelease(r); |
| 165 | + if (result.updated) updatedCount += 1; |
| 166 | + if (result.reason === "no-channel") skippedNoChannel += 1; |
| 167 | + } |
| 168 | +
|
| 169 | + core.info(`Done. Updated: ${updatedCount}. Skipped (no channel): ${skippedNoChannel}.`); |
0 commit comments