PECU Release Channel Badge Sync #119
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PECU Release Channel Badge Sync | |
| on: | |
| release: | |
| types: [published, edited] | |
| schedule: | |
| - cron: "17 */6 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Optional: process only this tag (e.g. v2026.01.05). Leave empty to scan all releases." | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: pecu-release-channel-badge | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| # Prevent self-trigger loops for release events caused by github-actions[bot] updating the body | |
| if: ${{ !(github.event_name == 'release' && github.actor == 'github-actions[bot]') }} | |
| steps: | |
| - name: Sync PECU channel badge into release body | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| // NOTE: github-script provides `core`, `github`, `context` already. Do NOT redeclare them. | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const START = '<!-- PECU-CHANNEL-BADGE:START -->'; | |
| const END = '<!-- PECU-CHANNEL-BADGE:END -->'; | |
| function channelStyle(channelRaw) { | |
| const channel = String(channelRaw || '').trim(); | |
| const key = channel.toLowerCase(); | |
| const map = { | |
| stable: { label: 'Stable', color: '2E8B57' }, | |
| beta: { label: 'Beta', color: 'F59E0B' }, | |
| preview: { label: 'Preview', color: '1E90FF' }, | |
| experimental: { label: 'Experimental', color: 'B91C1C' }, | |
| nightly: { label: 'Nightly', color: '111827' }, | |
| deprecated: { label: 'Deprecated', color: '6A737D' }, | |
| }; | |
| return map[key] || { label: channel || 'Unknown', color: '6A737D' }; | |
| } | |
| function buildBadgeBlock(channelRaw) { | |
| const { label, color } = channelStyle(channelRaw); | |
| const subject = encodeURIComponent('PECU Channel'); | |
| const status = encodeURIComponent(label); | |
| const badgeUrl = | |
| `https://img.shields.io/badge/${subject}-${status}-${color}?style=for-the-badge`; | |
| return `${START} | |
| <p align="center"> | |
| <img src="${badgeUrl}" alt="PECU Channel: ${label}"> | |
| </p> | |
| ${END}`; | |
| } | |
| function extractChannel(body) { | |
| if (!body) return null; | |
| // Prefer parsing from the metadata comment block | |
| // <!-- ... PECU-Channel: Stable ... --> | |
| const m1 = body.match(/^\s*<!--[\s\S]*?\bPECU-Channel:\s*([^\r\n]+)[\s\S]*?-->/m); | |
| if (m1) return m1[1].trim(); | |
| // Fallback: anywhere in body | |
| const m2 = body.match(/\bPECU-Channel:\s*([^\r\n]+)/i); | |
| return m2 ? m2[1].trim() : null; | |
| } | |
| function injectOrUpdate(body, channel) { | |
| const block = buildBadgeBlock(channel); | |
| if (!body) body = ''; | |
| const hasMarkers = body.includes(START) && body.includes(END); | |
| if (hasMarkers) { | |
| const re = new RegExp(`${START}[\\s\\S]*?${END}`, 'm'); | |
| return body.replace(re, block); | |
| } | |
| // Insert right after the leading metadata comment block (if present) | |
| const metaRe = /^\s*<!--[\s\S]*?PECU-Channel:[\s\S]*?-->\s*/m; | |
| if (metaRe.test(body)) { | |
| return body.replace(metaRe, (m) => `${m}\n${block}\n\n`); | |
| } | |
| // Fallback: prepend at top | |
| return `${block}\n\n${body}`; | |
| } | |
| async function updateRelease(release) { | |
| const currentBody = release.body || ''; | |
| const channel = extractChannel(currentBody); | |
| if (!channel) { | |
| core.info(`Skip ${release.tag_name}: no PECU-Channel metadata`); | |
| return { updated: false, reason: 'no-channel' }; | |
| } | |
| const nextBody = injectOrUpdate(currentBody, channel); | |
| if (nextBody === currentBody) { | |
| core.info(`No change for ${release.tag_name}`); | |
| return { updated: false, reason: 'no-change' }; | |
| } | |
| core.info(`Updating ${release.tag_name} (channel: ${channel})`); | |
| await github.rest.repos.updateRelease({ | |
| owner, | |
| repo, | |
| release_id: release.id, | |
| body: nextBody, | |
| }); | |
| return { updated: true, reason: 'updated' }; | |
| } | |
| async function listAllReleases() { | |
| const perPage = 100; | |
| const all = []; | |
| let page = 1; | |
| for (;;) { | |
| const res = await github.rest.repos.listReleases({ | |
| owner, | |
| repo, | |
| per_page: perPage, | |
| page, | |
| }); | |
| if (!res.data.length) break; | |
| all.push(...res.data); | |
| if (res.data.length < perPage) break; | |
| page += 1; | |
| } | |
| return all; | |
| } | |
| // Case 1: release event => process only that release | |
| if (context.eventName === 'release' && context.payload && context.payload.release) { | |
| await updateRelease(context.payload.release); | |
| return; | |
| } | |
| // Case 2: workflow_dispatch => optional single tag, otherwise scan all | |
| const inputs = (context.payload && context.payload.inputs) ? context.payload.inputs : {}; | |
| const tag = (inputs.tag || '').trim(); | |
| if (tag) { | |
| const rel = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); | |
| await updateRelease(rel.data); | |
| return; | |
| } | |
| // Case 3: schedule / dispatch w/o tag => scan all releases | |
| const releases = await listAllReleases(); | |
| core.info(`Scanning ${releases.length} release(s)…`); | |
| let updated = 0; | |
| for (const r of releases) { | |
| const res = await updateRelease(r); | |
| if (res.updated) updated += 1; | |
| } | |
| core.info(`Done. Updated: ${updated}/${releases.length}`); |