1111 description : ' Final English release note in Markdown'
1212 required : true
1313 type : string
14- media_sources :
15- description : ' JSON map from original media URL to downloadable source metadata'
16- required : false
17- default : ' {}'
18- type : string
1914 publish_latest :
2015 description : ' Whether GitHub should mark this release as latest'
2116 required : false
@@ -36,20 +31,13 @@ jobs:
3631 ref : develop
3732 fetch-depth : 0
3833
39- - name : Install FFmpeg when videos are present
40- if : contains(inputs.release_note, '.mp4') || contains(inputs.release_note, '.webm') || contains(inputs.release_note, '.mov') || contains(inputs.release_note, '.m4v') || contains(inputs.release_note, '.ogg')
41- run : |
42- sudo apt-get update
43- sudo apt-get install -y ffmpeg
44-
4534 - name : Build release body
4635 env :
4736 RELEASE_NOTE : ${{ inputs.release_note }}
4837 run : |
4938 printf '%s\n' "$RELEASE_NOTE" > release-body.md
5039
5140 - name : Create or update GitHub release
52- id : release
5341 env :
5442 GH_TOKEN : ${{ github.token }}
5543 RELEASE_ID : ${{ inputs.release_id }}
7361 }
7462
7563 upsert_release() {
76- local release_body release_payload response_file release_api_id release_url
64+ local release_body release_payload response_file release_api_id
7765 response_file="$(mktemp)"
7866 release_body="$(cat release-body.md)"
7967 release_payload="$(jq -nc \
@@ -103,200 +91,13 @@ jobs:
10391 --input - <<<"${release_payload}" >"${response_file}"
10492 fi
10593
106- release_api_id="$(jq -r '.id' "${response_file}")"
10794 release_url="$(jq -r '.html_url // empty' "${response_file}")"
108- if [ -z "${release_url}" ] || [ -z "${release_api_id}" ] ; then
109- echo "Missing release id/ html_url in GitHub release response" >&2
95+ if [ -z "${release_url}" ]; then
96+ echo "Missing html_url in GitHub release response" >&2
11097 cat "${response_file}" >&2
11198 return 1
11299 fi
113-
114- echo "release_api_id=${release_api_id}" >> "${GITHUB_OUTPUT}"
115- echo "release_url=${release_url}" >> "${GITHUB_OUTPUT}"
116100 }
117101
118102 ensure_tag
119103 upsert_release
120-
121- - name : Render media links and upload GIF previews
122- env :
123- GH_TOKEN : ${{ github.token }}
124- RELEASE_ID : ${{ inputs.release_id }}
125- RELEASE_API_ID : ${{ steps.release.outputs.release_api_id }}
126- MEDIA_SOURCES : ${{ inputs.media_sources }}
127- run : |
128- node <<'NODE'
129- const { execFileSync } = require('node:child_process');
130- const crypto = require('node:crypto');
131- const fs = require('node:fs/promises');
132- const path = require('node:path');
133- const os = require('node:os');
134-
135- const repo = process.env.GITHUB_REPOSITORY;
136- const token = process.env.GH_TOKEN;
137- const releaseApiId = process.env.RELEASE_API_ID;
138- const mediaSources = (() => {
139- try { return JSON.parse(process.env.MEDIA_SOURCES || '{}') || {}; }
140- catch { return {}; }
141- })();
142-
143- function getMediaType(url) {
144- const cleanUrl = String(url).split(/[?#]/)[0].toLowerCase();
145- if (/^https:\/\/github\.com\/user-attachments\/assets\//.test(cleanUrl)) return 'githubAttachment';
146- if (/^https:\/\/github\.com\/[^/]+\/[^/]+\/assets\/[^/]+\/[^/?#]+/.test(cleanUrl)) return 'githubAttachment';
147- if (/\.(png|jpe?g|gif|webp|avif|svg)$/.test(cleanUrl)) return 'image';
148- if (/\.(mp4|webm|ogg|mov|m4v)$/.test(cleanUrl)) return 'video';
149- return null;
150- }
151-
152- function safeAlt(label, fallback) {
153- const value = String(label || fallback || 'Media').trim();
154- return value.replace(/[\]\n\r]/g, '').replace(/^https?:\/\//i, '') || fallback || 'Media';
155- }
156-
157- function sanitizeAssetName(name, fallbackUrl) {
158- const fallbackName = (() => {
159- try {
160- const pathname = new URL(fallbackUrl).pathname;
161- return decodeURIComponent(pathname.split('/').filter(Boolean).pop() || 'video.mp4');
162- } catch {
163- return 'video.mp4';
164- }
165- })();
166- const rawName = String(name || fallbackName || 'video.mp4').trim();
167- const ext = path.extname(rawName);
168- const base = path.basename(rawName, ext) || crypto.createHash('sha1').update(fallbackUrl).digest('hex').slice(0, 10);
169- const safeBase = base
170- .replace(/[\\/:*?"<>|#%{}]/g, '-')
171- .replace(/\s+/g, '-')
172- .replace(/-+/g, '-')
173- .replace(/^-|-$/g, '') || 'video';
174- return `${safeBase}-preview.gif`;
175- }
176-
177- function markdownImage(url, label) {
178- return ``;
179- }
180-
181- async function githubJson(method, apiPath, body) {
182- const options = {
183- method,
184- headers: {
185- Accept: 'application/vnd.github+json',
186- Authorization: `Bearer ${token}`,
187- 'X-GitHub-Api-Version': '2022-11-28',
188- },
189- };
190- if (body !== undefined) {
191- options.headers['Content-Type'] = 'application/json';
192- options.body = JSON.stringify(body);
193- }
194- const response = await fetch(`https://api.github.com${apiPath}`, options);
195- if (!response.ok) throw new Error(`${method} ${apiPath} failed: HTTP ${response.status}: ${await response.text()}`);
196- if (response.status === 204) return null;
197- return response.json();
198- }
199-
200- async function uploadGifAsset(filePath, assetName) {
201- const assets = await githubJson('GET', `/repos/${repo}/releases/${releaseApiId}/assets?per_page=100`);
202- const existing = assets.find((asset) => asset.name === assetName);
203- if (existing) await githubJson('DELETE', `/repos/${repo}/releases/assets/${existing.id}`);
204-
205- const buffer = await fs.readFile(filePath);
206- const response = await fetch(`https://uploads.github.com/repos/${repo}/releases/${releaseApiId}/assets?name=${encodeURIComponent(assetName)}`, {
207- method: 'POST',
208- headers: {
209- Accept: 'application/vnd.github+json',
210- Authorization: `Bearer ${token}`,
211- 'Content-Type': 'image/gif',
212- 'Content-Length': String(buffer.byteLength),
213- 'X-GitHub-Api-Version': '2022-11-28',
214- },
215- body: buffer,
216- });
217- if (!response.ok) throw new Error(`Upload ${assetName} failed: HTTP ${response.status}: ${await response.text()}`);
218- const asset = await response.json();
219- return asset.browser_download_url;
220- }
221-
222- async function renderVideo(url, label) {
223- const source = mediaSources[url] || { url, name: label };
224- const downloadUrl = source.url || url;
225- const assetName = sanitizeAssetName(source.name || label, url);
226- const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'release-video-'));
227- const inputPath = path.join(workDir, 'input-video');
228- const outputPath = path.join(workDir, assetName);
229-
230- const response = await fetch(downloadUrl);
231- if (!response.ok) throw new Error(`Download video failed for ${url}: HTTP ${response.status}`);
232- await fs.writeFile(inputPath, Buffer.from(await response.arrayBuffer()));
233-
234- execFileSync('ffmpeg', [
235- '-y',
236- '-i', inputPath,
237- '-t', '6',
238- '-vf', 'fps=12,scale=1280:-1:flags=lanczos,format=rgb24,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[p];[s1][p]paletteuse=dither=sierra2_4a',
239- outputPath,
240- ], { stdio: 'inherit' });
241-
242- const gifUrl = await uploadGifAsset(outputPath, assetName);
243- return `\n\n<img src="${gifUrl}" alt="video preview" width="720">\n\n`;
244- }
245-
246- async function renderUrl(url, label) {
247- const type = getMediaType(url);
248- if (type === 'image') return markdownImage(url, label);
249- if (type === 'githubAttachment') return `\n\n${url}\n\n`;
250- if (type === 'video') return renderVideo(url, label);
251- return null;
252- }
253-
254- async function renderMarkdown(markdown) {
255- let rendered = String(markdown || '');
256- const markdownLinkPattern = /(!?)\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
257- const linkMatches = [...rendered.matchAll(markdownLinkPattern)];
258- for (const match of linkMatches) {
259- const [fullMatch, bang, label, url] = match;
260- if (bang) continue;
261- const replacement = await renderUrl(url, label);
262- if (replacement) rendered = rendered.replace(fullMatch, replacement);
263- }
264-
265- const bareUrlPattern = /(^|[\s>])(https?:\/\/[^\s<>)]+)/g;
266- const bareMatches = [...rendered.matchAll(bareUrlPattern)];
267- for (const match of bareMatches) {
268- const [fullMatch, prefix, url] = match;
269- const offset = match.index;
270- if (prefix === '(' || rendered[offset - 1] === '(' || rendered[offset - 1] === '"') continue;
271- const beforeUrl = rendered.slice(0, offset + prefix.length);
272- const lastOpenParen = beforeUrl.lastIndexOf('(');
273- const lastCloseParen = beforeUrl.lastIndexOf(')');
274- if (lastOpenParen > lastCloseParen && /\[[^\]]*\]$/.test(beforeUrl.slice(0, lastOpenParen))) continue;
275- const replacement = await renderUrl(url);
276- if (replacement) rendered = rendered.replace(fullMatch, `${prefix}${replacement}`);
277- }
278-
279- return rendered.replace(/\n{3,}/g, '\n\n').trim();
280- }
281-
282- (async () => {
283- const raw = await fs.readFile('release-body.md', 'utf8');
284- const rendered = await renderMarkdown(raw);
285- await fs.writeFile('release-body.md', `${rendered}\n`);
286- })().catch((error) => {
287- console.error(error);
288- process.exit(1);
289- });
290- NODE
291-
292- - name : Update GitHub release body with media previews
293- env :
294- GH_TOKEN : ${{ github.token }}
295- RELEASE_API_ID : ${{ steps.release.outputs.release_api_id }}
296- run : |
297- set -euo pipefail
298- release_body="$(cat release-body.md)"
299- jq -nc --arg body "${release_body}" '{ body: $body }' |
300- gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_API_ID}" \
301- --method PATCH \
302- --input -
0 commit comments