|
4 | 4 | release: |
5 | 5 | types: [published, edited] |
6 | 6 | schedule: |
7 | | - # Every 6 hours (adjust as needed) |
8 | | - - cron: "0 */6 * * *" |
9 | | - workflow_dispatch: {} |
| 7 | + - cron: "17 */6 * * *" |
| 8 | + workflow_dispatch: |
| 9 | + inputs: |
| 10 | + tag: |
| 11 | + description: "Optional: process only this tag (e.g. v2026.01.05). Leave empty to scan all releases." |
| 12 | + required: false |
| 13 | + type: string |
10 | 14 |
|
11 | 15 | permissions: |
12 | 16 | contents: write |
13 | 17 |
|
14 | 18 | concurrency: |
15 | | - group: pecu-release-channel-badge-sync |
16 | | - cancel-in-progress: true |
| 19 | + group: pecu-release-channel-badge |
| 20 | + cancel-in-progress: false |
17 | 21 |
|
18 | 22 | jobs: |
19 | 23 | 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 | 24 | runs-on: ubuntu-latest |
23 | 25 |
|
| 26 | + # Prevent self-trigger loops for release events caused by github-actions[bot] updating the body |
| 27 | + if: ${{ !(github.event_name == 'release' && github.actor == 'github-actions[bot]') }} |
| 28 | + |
24 | 29 | steps: |
25 | | - - name: Sync channel badges in release notes |
| 30 | + - name: Sync PECU channel badge into release body |
26 | 31 | uses: actions/github-script@v7 |
27 | 32 | with: |
28 | 33 | script: | |
29 | | - const core = require("@actions/core"); |
30 | | -
|
| 34 | + // NOTE: github-script provides `core`, `github`, `context` already. Do NOT redeclare them. |
31 | 35 | 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; |
| 36 | + const repo = context.repo.repo; |
| 37 | +
|
| 38 | + const START = '<!-- PECU-CHANNEL-BADGE:START -->'; |
| 39 | + const END = '<!-- PECU-CHANNEL-BADGE:END -->'; |
| 40 | +
|
| 41 | + function channelStyle(channelRaw) { |
| 42 | + const channel = String(channelRaw || '').trim(); |
| 43 | + const key = channel.toLowerCase(); |
| 44 | +
|
| 45 | + const map = { |
| 46 | + stable: { label: 'Stable', color: '2E8B57' }, |
| 47 | + beta: { label: 'Beta', color: 'F59E0B' }, |
| 48 | + preview: { label: 'Preview', color: '1E90FF' }, |
| 49 | + experimental: { label: 'Experimental', color: 'B91C1C' }, |
| 50 | + nightly: { label: 'Nightly', color: '111827' }, |
| 51 | + deprecated: { label: 'Deprecated', color: '6A737D' }, |
| 52 | + }; |
| 53 | +
|
| 54 | + return map[key] || { label: channel || 'Unknown', color: '6A737D' }; |
56 | 55 | } |
57 | 56 |
|
58 | 57 | function buildBadgeBlock(channelRaw) { |
59 | | - const channelKey = channelRaw.toLowerCase(); |
60 | | - const picked = palette[channelKey] || { label: channelRaw, color: "6B7280" }; |
| 58 | + const { label, color } = channelStyle(channelRaw); |
| 59 | +
|
| 60 | + const subject = encodeURIComponent('PECU Channel'); |
| 61 | + const status = encodeURIComponent(label); |
61 | 62 |
|
62 | | - const labelEnc = encodeURIComponent(picked.label.replace(/\s+/g, " ")); |
63 | 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"); |
| 64 | + `https://img.shields.io/badge/${subject}-${status}-${color}?style=for-the-badge`; |
| 65 | +
|
| 66 | + return `${START} |
| 67 | + <p align="center"> |
| 68 | + <img src="${badgeUrl}" alt="PECU Channel: ${label}"> |
| 69 | + </p> |
| 70 | + ${END}`; |
74 | 71 | } |
75 | 72 |
|
76 | | - function upsertBadge(body, badgeBlock) { |
77 | | - const text = body || ""; |
| 73 | + function extractChannel(body) { |
| 74 | + if (!body) return null; |
| 75 | +
|
| 76 | + // Prefer parsing from the metadata comment block |
| 77 | + // <!-- ... PECU-Channel: Stable ... --> |
| 78 | + const m1 = body.match(/^\s*<!--[\s\S]*?\bPECU-Channel:\s*([^\r\n]+)[\s\S]*?-->/m); |
| 79 | + if (m1) return m1[1].trim(); |
| 80 | +
|
| 81 | + // Fallback: anywhere in body |
| 82 | + const m2 = body.match(/\bPECU-Channel:\s*([^\r\n]+)/i); |
| 83 | + return m2 ? m2[1].trim() : null; |
| 84 | + } |
78 | 85 |
|
79 | | - const hasMarkers = text.includes(START) && text.includes(END); |
| 86 | + function injectOrUpdate(body, channel) { |
| 87 | + const block = buildBadgeBlock(channel); |
80 | 88 |
|
| 89 | + if (!body) body = ''; |
| 90 | +
|
| 91 | + const hasMarkers = body.includes(START) && body.includes(END); |
81 | 92 | if (hasMarkers) { |
82 | | - const re = new RegExp(`${START}[\\s\\S]*?${END}`, "m"); |
83 | | - return text.replace(re, badgeBlock); |
| 93 | + const re = new RegExp(`${START}[\\s\\S]*?${END}`, 'm'); |
| 94 | + return body.replace(re, block); |
84 | 95 | } |
85 | 96 |
|
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 | | - } |
| 97 | + // Insert right after the leading metadata comment block (if present) |
| 98 | + const metaRe = /^\s*<!--[\s\S]*?PECU-Channel:[\s\S]*?-->\s*/m; |
| 99 | + if (metaRe.test(body)) { |
| 100 | + return body.replace(metaRe, (m) => `${m}\n${block}\n\n`); |
| 101 | + } |
90 | 102 |
|
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]); |
| 103 | + // Fallback: prepend at top |
| 104 | + return `${block}\n\n${body}`; |
97 | 105 | } |
98 | 106 |
|
99 | | - async function processRelease(release) { |
100 | | - const body = release.body || ""; |
101 | | - const channel = extractChannel(body); |
| 107 | + async function updateRelease(release) { |
| 108 | + const currentBody = release.body || ''; |
| 109 | + const channel = extractChannel(currentBody); |
102 | 110 |
|
103 | 111 | if (!channel) { |
104 | | - core.info(`Release ${release.tag_name}: PECU-Channel not found. Skipping.`); |
105 | | - return { updated: false, reason: "no-channel" }; |
| 112 | + core.info(`Skip ${release.tag_name}: no PECU-Channel metadata`); |
| 113 | + return { updated: false, reason: 'no-channel' }; |
106 | 114 | } |
107 | 115 |
|
108 | | - const badgeBlock = buildBadgeBlock(channel); |
109 | | - const nextBody = upsertBadge(body, badgeBlock); |
| 116 | + const nextBody = injectOrUpdate(currentBody, channel); |
110 | 117 |
|
111 | | - if (nextBody === body) { |
112 | | - core.info(`Release ${release.tag_name}: already up to date.`); |
113 | | - return { updated: false, reason: "no-change" }; |
| 118 | + if (nextBody === currentBody) { |
| 119 | + core.info(`No change for ${release.tag_name}`); |
| 120 | + return { updated: false, reason: 'no-change' }; |
114 | 121 | } |
115 | 122 |
|
| 123 | + core.info(`Updating ${release.tag_name} (channel: ${channel})`); |
| 124 | +
|
116 | 125 | await github.rest.repos.updateRelease({ |
117 | 126 | owner, |
118 | 127 | repo, |
119 | 128 | release_id: release.id, |
120 | 129 | body: nextBody, |
121 | 130 | }); |
122 | 131 |
|
123 | | - core.info(`Release ${release.tag_name}: updated badge -> ${channel}`); |
124 | | - return { updated: true, reason: "updated" }; |
| 132 | + return { updated: true, reason: 'updated' }; |
125 | 133 | } |
126 | 134 |
|
127 | 135 | async function listAllReleases() { |
128 | | - const releases = []; |
| 136 | + const perPage = 100; |
| 137 | + const all = []; |
129 | 138 | let page = 1; |
130 | 139 |
|
131 | | - while (true) { |
| 140 | + for (;;) { |
132 | 141 | const res = await github.rest.repos.listReleases({ |
133 | 142 | owner, |
134 | 143 | repo, |
135 | | - per_page: 100, |
| 144 | + per_page: perPage, |
136 | 145 | page, |
137 | 146 | }); |
138 | 147 |
|
139 | | - if (!res.data || res.data.length === 0) break; |
| 148 | + if (!res.data.length) break; |
| 149 | +
|
| 150 | + all.push(...res.data); |
| 151 | + if (res.data.length < perPage) break; |
140 | 152 |
|
141 | | - releases.push(...res.data); |
142 | | - if (res.data.length < 100) break; |
143 | 153 | page += 1; |
144 | 154 | } |
145 | 155 |
|
146 | | - return releases; |
| 156 | + return all; |
147 | 157 | } |
148 | 158 |
|
149 | | - // Mode selection |
150 | | - if (context.eventName === "release") { |
151 | | - const release = context.payload.release; |
152 | | - await processRelease(release); |
| 159 | + // Case 1: release event => process only that release |
| 160 | + if (context.eventName === 'release' && context.payload && context.payload.release) { |
| 161 | + await updateRelease(context.payload.release); |
153 | 162 | return; |
154 | 163 | } |
155 | 164 |
|
156 | | - // Scheduled / manual: scan everything |
157 | | - const releases = await listAllReleases(); |
158 | | - core.info(`Scanning ${releases.length} releases...`); |
| 165 | + // Case 2: workflow_dispatch => optional single tag, otherwise scan all |
| 166 | + const inputs = (context.payload && context.payload.inputs) ? context.payload.inputs : {}; |
| 167 | + const tag = (inputs.tag || '').trim(); |
159 | 168 |
|
160 | | - let updatedCount = 0; |
161 | | - let skippedNoChannel = 0; |
| 169 | + if (tag) { |
| 170 | + const rel = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); |
| 171 | + await updateRelease(rel.data); |
| 172 | + return; |
| 173 | + } |
| 174 | +
|
| 175 | + // Case 3: schedule / dispatch w/o tag => scan all releases |
| 176 | + const releases = await listAllReleases(); |
| 177 | + core.info(`Scanning ${releases.length} release(s)…`); |
162 | 178 |
|
| 179 | + let updated = 0; |
163 | 180 | 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; |
| 181 | + const res = await updateRelease(r); |
| 182 | + if (res.updated) updated += 1; |
167 | 183 | } |
168 | 184 |
|
169 | | - core.info(`Done. Updated: ${updatedCount}. Skipped (no channel): ${skippedNoChannel}.`); |
| 185 | + core.info(`Done. Updated: ${updated}/${releases.length}`); |
0 commit comments