Skip to content

PECU Release Channel Badge Sync #119

PECU Release Channel Badge Sync

PECU Release Channel Badge Sync #119

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}`);