diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..d42bbd660 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,11 @@ +name: Setup Node.js +description: Install Node 22, restore npm cache, run npm ci +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + shell: bash diff --git a/.github/scripts/discord-pr-sync.mjs b/.github/scripts/discord-pr-sync.mjs new file mode 100644 index 000000000..3a62dc819 --- /dev/null +++ b/.github/scripts/discord-pr-sync.mjs @@ -0,0 +1,447 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { validateThreadChannel } from "./discord-thread-validator.mjs"; + +const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); +const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + +const THREAD_MARKER_REGEX = //i; +const webhookUrl = ( + process.env.DISCORD_WEBHOOK_URL || + process.env.DISCORD_PR_FORUM_WEBHOOK || + "" +).trim(); +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); +const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim(); +const forumChannelId = (process.env.DISCORD_PR_FORUM_CHANNEL_ID || "").trim(); + +const TAGS = { + open: "1493976692967080096", + draft: "1493976782028935279", + ready: "1493976833626996756", + changes: "1493976909875515564", + approved: "1493976951038152764", + merged: "1493977049709281320", + closed: "1493977108102516786", +}; + +const labelTagMap = { + bug: "1493977562773458975", + enhancement: "1493977619216207993", + documentation: "1493978565153394830", +}; + +function cleanDescription(text, maxLen = 3500) { + if (!text) return "No description provided."; + const normalized = text + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 1)}…`; +} + +function trimThreadName(name) { + return name.length > 95 ? name.slice(0, 95) : name; +} + +function extractThreadId(body) { + if (!body) return null; + const match = body.match(THREAD_MARKER_REGEX); + return match ? match[1] : null; +} + +function upsertThreadMarker(body, threadId) { + const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); + return `${cleaned}\n\n`.trim(); +} + +async function discordPost(payload, options = {}) { + const endpoint = new URL(webhookUrl); + endpoint.searchParams.set("wait", "true"); + if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId)); + + const response = await fetch(endpoint.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + avatar_url: WEBHOOK_AVATAR, + allowed_mentions: { parse: [] }, + ...payload, + }), + }); + + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`Discord API error ${response.status}: ${text}`); + } + + if (!text) return {}; + if (contentType.includes("application/json")) return JSON.parse(text); + + warning( + `Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`, + ); + return {}; +} + +async function patchDiscordThread(threadId, patchBody) { + if (!botToken || !threadId) return; + const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { + method: "PATCH", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(patchBody), + }); + if (!response.ok) { + const text = await response.text(); + warning(`Discord thread patch failed (${response.status}): ${text}`); + } +} + +function desiredStatusTag(prState) { + if (prState.merged && TAGS.merged) return TAGS.merged; + if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed; + if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes; + if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved; + if (prState.draft && TAGS.draft) return TAGS.draft; + if (!prState.draft && TAGS.ready) return TAGS.ready; + return TAGS.open || null; +} + +function tagIdsFromLabels(labels) { + const out = []; + for (const label of labels) { + const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label]; + if (mapped) out.push(String(mapped)); + } + return out; +} + +async function getPullRequest(octokit) { + if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") { + return context.payload.pull_request || null; + } + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + if (!issue?.pull_request) return null; + const { data } = await octokit.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }); + return data; + } + return null; +} + +async function getReviewState(octokit, owner, repo, pullNumber) { + const { data } = await octokit.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + let hasChanges = false; + let hasApproved = false; + for (const r of data) { + const s = (r.state || "").toUpperCase(); + if (s === "CHANGES_REQUESTED") hasChanges = true; + if (s === "APPROVED") hasApproved = true; + } + if (hasChanges) return "CHANGES_REQUESTED"; + if (hasApproved) return "APPROVED"; + return "NONE"; +} + +async function main() { + try { + const octokit = getOctokit(process.env.GITHUB_TOKEN); + + const pr = await getPullRequest(octokit); + if (!pr) { + info("No PR context found. Skipping."); + return; + } + + if (!webhookUrl) { + warning( + `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + + "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.", + ); + return; + } + + const action = context.payload.action || ""; + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = pr.number; + const title = pr.title; + const author = pr.user?.login || "unknown"; + const url = pr.html_url; + const authorUrl = pr.user?.html_url || ""; + const authorAvatar = pr.user?.avatar_url || ""; + const base = pr.base?.ref || ""; + const head = pr.head?.ref || ""; + const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`; + const labels = (pr.labels || []).map((l) => l.name); + const body = (pr.body || "").trim(); + const reviewState = await getReviewState(octokit, owner, repo, number); + + let threadId = extractThreadId(body); + const shouldCreateThread = + context.eventName === "pull_request_target" && + ["opened", "reopened", "ready_for_review"].includes(action) && + !threadId; + + if (shouldCreateThread) { + const fields = [ + { name: "PR", value: `[#${number}](${url})`, inline: true }, + { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true }, + { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true }, + { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true }, + { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true }, + { name: "Files Changed", value: String(pr.changed_files), inline: true }, + ]; + + if (labels.length) { + fields.push({ + name: "Labels", + value: labels.map((l) => `\`${l}\``).join(" "), + inline: false, + }); + } + + const statusTag = desiredStatusTag({ + draft: pr.draft, + reviewState, + merged: false, + closed: false, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice(0, 5); + + const createPayload = { + content: + action === "ready_for_review" + ? "🔔 PR is now ready for review" + : "🔔 New pull request opened", + thread_name: trimThreadName(`PR #${number} - ${title}`), + applied_tags: appliedTags, + embeds: [ + { + title: `PR #${number}: ${title}`, + url, + description: cleanDescription(body), + color: pr.draft ? 15105570 : 1998671, + author: { + name: author, + url: authorUrl || undefined, + icon_url: authorAvatar || undefined, + }, + fields, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }, + ], + }; + + const result = await discordPost(createPayload); + const createdThreadId = result.channel_id || null; + if (createdThreadId) { + const updatedBody = upsertThreadMarker(body, createdThreadId); + await octokit.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody }); + info(`Created Discord thread ${createdThreadId} and stored mapping.`); + } else { + warning("Discord thread created but channel_id missing in response."); + } + return; + } + + if (!threadId) { + info("No mapped Discord thread ID found; skipping update event."); + return; + } + + if (!(await validateThreadChannel(threadId, number, { botToken, forumChannelId }))) { + info("Thread ID in PR body failed channel validation; ignoring marker."); + return; + } + + if ( + context.eventName === "pull_request_target" && + ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action) + ) { + const statusTag = desiredStatusTag({ + draft: action === "converted_to_draft" ? true : pr.draft, + reviewState, + merged: false, + closed: false, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice(0, 5); + await patchDiscordThread(threadId, { + name: trimThreadName(`PR #${number} - ${title}`), + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + + let updateMessage = null; + let updateEmbed = null; + + if (context.eventName === "pull_request_target") { + if (action === "synchronize") { + const { data: commits } = await octokit.rest.pulls.listCommits({ + owner, + repo, + pull_number: number, + per_page: 5, + }); + const list = + commits + .map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`) + .join("\n") || "- No commit details"; + updateMessage = `🧩 New commits pushed to PR #${number}`; + updateEmbed = { + title: `Commit Update • PR #${number}`, + url: `${url}/files`, + description: `${list}`, + color: 1998671, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }; + } else if (action === "edited") { + updateMessage = `✏️ PR #${number} details were edited`; + updateEmbed = { + title: `PR Updated • #${number}`, + url, + description: cleanDescription(body, 1200), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } else if (action === "closed") { + const isMerged = !!pr.merged; + const statusTag = desiredStatusTag({ + draft: false, + reviewState, + merged: isMerged, + closed: true, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice( + 0, + 5, + ); + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + ...(isMerged ? { archived: true, locked: true } : {}), + }); + + updateMessage = isMerged + ? `✅ PR #${number} was merged` + : `🛑 PR #${number} was closed without merge`; + updateEmbed = { + title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`, + url, + description: isMerged + ? "This PR has been merged into the base branch." + : "This PR was closed before merge.", + color: isMerged ? 5763719 : 15158332, + timestamp: new Date().toISOString(), + }; + } else if (action === "ready_for_review") { + updateMessage = `🚀 PR #${number} moved from draft to ready for review`; + if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; + } else if (action === "converted_to_draft") { + updateMessage = `📝 PR #${number} converted to draft`; + } + } else if (context.eventName === "pull_request_review") { + const review = context.payload.review; + if (review) { + const state = (review.state || "commented").toUpperCase(); + const reviewer = review.user?.login || "reviewer"; + updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`; + if (state === "CHANGES_REQUESTED" && reviewerRoleId) + updateMessage += ` <@&${reviewerRoleId}>`; + updateEmbed = { + title: `Review ${state} • PR #${number}`, + url: review.html_url || url, + description: cleanDescription(review.body || "No review note.", 1000), + color: + state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671, + timestamp: new Date().toISOString(), + }; + + if (state === "CHANGES_REQUESTED" || state === "APPROVED") { + const statusTag = desiredStatusTag({ + draft: pr.draft, + reviewState: state, + merged: false, + closed: false, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))].slice( + 0, + 5, + ); + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + } + } else if (context.eventName === "issue_comment") { + const comment = context.payload.comment; + if (comment) { + const commenter = comment.user?.login || "user"; + updateMessage = `💬 New comment by **${commenter}** on PR #${number}`; + updateEmbed = { + title: `New PR Comment • #${number}`, + url: comment.html_url || url, + description: cleanDescription(comment.body || "No comment body.", 1000), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } + } + + if (!updateMessage && !updateEmbed) { + info("No Discord update message for this event/action. Skipping."); + return; + } + + const payload = { content: updateMessage || "" }; + if (updateEmbed) payload.embeds = [updateEmbed]; + await discordPost(payload, { threadId }); + info(`Posted update to Discord thread ${threadId}.`); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + warning( + `Discord sync failed, but this optional automation will not block PR validation: ${msg}`, + ); + + if (alertWebhookUrl) { + try { + await fetch(alertWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "OpenScreen", + avatar_url: WEBHOOK_AVATAR, + content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + allowed_mentions: { parse: [] }, + }), + }); + } catch { + warning("Failed to send alert webhook."); + } + } + } +} + +main(); diff --git a/.github/scripts/discord-roadmap-sync.mjs b/.github/scripts/discord-roadmap-sync.mjs new file mode 100644 index 000000000..96386c2ad --- /dev/null +++ b/.github/scripts/discord-roadmap-sync.mjs @@ -0,0 +1,222 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; + +const ROADMAP_PATTERN = /(^|\/)ROADMAP\.md$|(^|\/)docs\/roadmap\.md$/i; +const ROADMAP_EMBED_TITLE = "🗺️ OpenScreen Roadmap"; + +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const channelId = (process.env.DISCORD_ROADMAP_CHANNEL_ID || "").trim(); +const overrideMessageId = (process.env.DISCORD_ROADMAP_MESSAGE_ID || "").trim(); + +async function main() { + try { + if (!botToken || !channelId) { + info( + "DISCORD_BOT_TOKEN or DISCORD_ROADMAP_CHANNEL_ID not set; skipping. " + + "Configure both as repo secret / variable to enable #🗺️・roadmap auto-sync.", + ); + return; + } + + const octokit = getOctokit(process.env.GITHUB_TOKEN); + + // 0. Resolve the message id to update + let existingMessageId = overrideMessageId; + if (!existingMessageId) { + try { + const pinRes = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/pins`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (pinRes.ok) { + const data = await pinRes.json(); + const pins = (data.items || []).map((item) => item.message).filter(Boolean); + const existing = pins.find((m) => m.embeds?.[0]?.title === ROADMAP_EMBED_TITLE); + if (existing) { + existingMessageId = existing.id; + info(`Found existing pinned roadmap message ${existingMessageId}.`); + } else { + info("No existing pinned roadmap message found; will create one."); + } + } else { + const txt = await pinRes.text(); + warning(`Failed to fetch pins (${pinRes.status}): ${txt}; falling back to POST.`); + } + } catch (err) { + warning( + `Pin lookup threw: ${err && err.message ? err.message : err}; falling back to POST.`, + ); + } + } + + // 1. Detect which files changed in this event + let changedFiles = []; + try { + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + if (!pr) { + info("No PR context; skipping."); + return; + } + const res = await octokit.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + changedFiles = res.data; + } else if (context.eventName === "push") { + const sha = context.payload.after || context.payload.head_commit?.id || context.sha; + const res = await octokit.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + }); + changedFiles = res.data.files || []; + } + } catch (err) { + warning(`Failed to list changed files: ${err && err.message ? err.message : err}`); + return; + } + + const roadmapFiles = changedFiles.filter((f) => ROADMAP_PATTERN.test(f.filename)); + if (roadmapFiles.length === 0) { + info("No roadmap files in event; skipping."); + return; + } + + // 2. Fetch the current ROADMAP.md content from main + let content; + try { + const res = await octokit.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: "ROADMAP.md", + ref: "main", + }); + if (Array.isArray(res.data) || res.data.type !== "file" || !res.data.content) { + warning("ROADMAP.md is not a readable file; skipping."); + return; + } + content = Buffer.from(res.data.content, "base64").toString("utf-8"); + } catch (err) { + warning(`Failed to fetch ROADMAP.md: ${err && err.message ? err.message : err}`); + return; + } + + // 3. Truncate if it exceeds Discord's embed description limit + const rawUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/blob/main/ROADMAP.md`; + const truncationNote = `\n\n… *(truncated, see [full file on GitHub](${rawUrl}))*`; + const maxContentLength = 4096 - truncationNote.length; + let description = content; + let truncated = false; + if (content.length > maxContentLength) { + description = content.slice(0, maxContentLength) + truncationNote; + truncated = true; + } + + // 4. Build the embed payload + const syncedAt = new Date().toISOString().split("T")[0]; + const payload = { + embeds: [ + { + title: ROADMAP_EMBED_TITLE, + url: rawUrl, + description, + color: 1998671, + footer: { + text: `${context.repo.owner}/${context.repo.repo} • Last synced ${syncedAt}`, + }, + timestamp: new Date().toISOString(), + }, + ], + allowed_mentions: { parse: [] }, + }; + if (truncated) { + payload.content = `⚠️ Roadmap exceeds Discord embed limit; truncated. See the [full file on GitHub](${rawUrl}) for the complete version.`; + } + + // 5. PATCH the existing message, or POST a new one + let messageId = existingMessageId; + try { + if (messageId) { + const res = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, + { + method: "PATCH", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + ); + if (res.status === 404) { + warning( + `Existing message ${messageId} not found in Discord (was it deleted?). Falling back to POST.`, + ); + messageId = ""; + } else if (!res.ok) { + const txt = await res.text(); + warning(`Roadmap Discord PATCH failed ${res.status}: ${txt}`); + return; + } else { + info(`Roadmap Discord message ${messageId} updated.`); + } + } + + if (!messageId) { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const txt = await res.text(); + warning(`Roadmap Discord POST failed ${res.status}: ${txt}`); + return; + } + const data = await res.json(); + messageId = data.id; + info(`🆕 New roadmap message created with id ${messageId}.`); + info( + `👉 Set DISCORD_ROADMAP_MESSAGE_ID=${messageId} as a repo variable to update this message on future changes.`, + ); + info( + ` gh variable set DISCORD_ROADMAP_MESSAGE_ID ${messageId} --repo ${context.repo.owner}/${context.repo.repo}`, + ); + } + } catch (err) { + warning(`Roadmap Discord sync threw: ${err && err.message ? err.message : err}`); + return; + } + + // 6. Pin the message + try { + const pinRes = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/pins/${messageId}`, + { method: "PUT", headers: { Authorization: `Bot ${botToken}` } }, + ); + if (pinRes.status === 204 || pinRes.ok) { + info(`Message ${messageId} pinned.`); + } else if (pinRes.status === 403) { + warning( + "Cannot pin message: bot lacks 'Manage Messages' on the channel. Add it via Discord channel permissions.", + ); + } else { + const txt = await pinRes.text(); + warning(`Pin failed ${pinRes.status}: ${txt}`); + } + } catch (err) { + warning(`Pin threw: ${err && err.message ? err.message : err}`); + } + } catch (err) { + const msg = err && err.message ? err.message : String(err); + warning(`Roadmap Discord sync failed: ${msg}`); + } +} + +main(); diff --git a/.github/scripts/discord-thread-validator.mjs b/.github/scripts/discord-thread-validator.mjs new file mode 100644 index 000000000..88ae9c7ea --- /dev/null +++ b/.github/scripts/discord-thread-validator.mjs @@ -0,0 +1,42 @@ +import { warning } from "@actions/core"; + +export async function validateThreadChannel(threadId, prNumber, { botToken, forumChannelId } = {}) { + if (!botToken) { + warning( + "DISCORD_BOT_TOKEN not set; cannot validate thread channel ownership. Rejecting marker.", + ); + return false; + } + const VALIDATION_TIMEOUT_MS = 5_000; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS); + const res = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { + headers: { Authorization: `Bot ${botToken}` }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (!res.ok) { + warning(`Thread validation failed: channel ${threadId} returned ${res.status}`); + return false; + } + const channel = await res.json(); + if (forumChannelId && channel.parent_id !== forumChannelId) { + warning( + `Thread ${threadId} parent_id=${channel.parent_id} does not match expected forum ${forumChannelId}; treating marker as untrusted.`, + ); + return false; + } + const expectedPrefix = `PR #${prNumber} -`; + if (!channel.name || !channel.name.startsWith(expectedPrefix)) { + warning( + `Thread ${threadId} name "${channel.name}" does not match expected prefix "${expectedPrefix}"; treating marker as untrusted.`, + ); + return false; + } + return true; + } catch (err) { + warning(`Thread validation threw: ${err && err.message ? err.message : err}`); + return false; + } +} diff --git a/.github/scripts/discord-thread-validator.test.mjs b/.github/scripts/discord-thread-validator.test.mjs new file mode 100644 index 000000000..f2c686b85 --- /dev/null +++ b/.github/scripts/discord-thread-validator.test.mjs @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { validateThreadChannel } from "./discord-thread-validator.mjs"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("validateThreadChannel", () => { + const botToken = "bot-token"; + const number = 42; + + it("fails closed when botToken is unset", async () => { + const result = await validateThreadChannel("123", number, { botToken: "" }); + expect(result).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a forged marker pointing at a random thread (wrong parent)", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "111", + parent_id: "999999999999999999", + name: `PR #${number} - Some PR`, + }), + }); + + const result = await validateThreadChannel("111", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(false); + }); + + it("rejects a marker pointing at a sibling PR thread in the same forum", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "222", + parent_id: "888888888888888888", + name: `PR #99 - Other PR`, + }), + }); + + const result = await validateThreadChannel("222", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(false); + }); + + it("accepts a valid bot-created thread for PR #N", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "333", + parent_id: "888888888888888888", + name: `PR #${number} - My feature`, + }), + }); + + const result = await validateThreadChannel("333", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(true); + }); + + it("returns false when Discord API returns non-ok", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + }); + + const result = await validateThreadChannel("404", number, { botToken }); + expect(result).toBe(false); + }); + + it("passes an AbortSignal with timeout to fetch", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "777", + parent_id: "888888888888888888", + name: `PR #${number} - gated`, + }), + }); + + await validateThreadChannel("777", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + const call = vi.mocked(fetch).mock.calls[0]; + expect(call[1].signal).toBeInstanceOf(AbortSignal); + }); + + it("returns false when fetch throws", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("network error")); + + const result = await validateThreadChannel("500", number, { botToken }); + expect(result).toBe(false); + }); +}); diff --git a/.github/scripts/discord-weekly-leaderboard.mjs b/.github/scripts/discord-weekly-leaderboard.mjs new file mode 100644 index 000000000..bcf3192cf --- /dev/null +++ b/.github/scripts/discord-weekly-leaderboard.mjs @@ -0,0 +1,78 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; + +const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim(); +const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); +const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + +async function main() { + if (!spotlightWebhook) { + info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post."); + return; + } + + const octokit = getOctokit(process.env.GITHUB_TOKEN); + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const owner = context.repo.owner; + const repo = context.repo.repo; + + const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`; + + let allItems = []; + try { + allItems = await octokit.paginate(octokit.rest.search.issuesAndPullRequests, { + q, + per_page: 100, + }); + } catch (err) { + warning(`Search API failed: ${err && err.message ? err.message : err}`); + return; + } + const counter = new Map(); + for (const item of allItems) { + const login = item.user?.login; + if (!login) continue; + counter.set(login, (counter.get(login) || 0) + 1); + } + + const ranked = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); + + const totalMerged = allItems.length; + const lines = ranked.length + ? ranked + .map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`) + .join("\n") + : "No merged PRs this week."; + + const payload = { + username: webhookUsername, + ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}), + embeds: [ + { + title: "🌟 Weekly Contributor Leaderboard", + description: lines, + color: 1998671, + fields: [ + { name: "Merged PRs (7d)", value: String(totalMerged), inline: true }, + { name: "Repository", value: `${owner}/${repo}`, inline: true }, + { name: "Period", value: "Last 7 days", inline: true }, + ], + timestamp: new Date().toISOString(), + }, + ], + allowed_mentions: { parse: [] }, + }; + + const res = await fetch(`${spotlightWebhook}?wait=true`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const txt = await res.text(); + warning(`Leaderboard post failed ${res.status}: ${txt}`); + } +} + +main(); diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 000000000..03a1b3c51 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,162 @@ +name: Publish to AUR + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (e.g. v1.5.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.AUR_PACKAGE_NAME != '' + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check AUR secrets + id: aur_secret + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then + echo "AUR_SSH_PRIVATE_KEY secret not set; skipping." + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Find .pacman asset + if: steps.aur_secret.outputs.configured == 'true' + id: asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name') + PACMAN_NAME=$(echo "$NAMES" | grep -iE '\.pacman$' | head -n1 || true) + if [[ -z "$PACMAN_NAME" ]]; then + echo "::error::No .pacman asset found in release $TAG" + echo "Available assets:" + echo "$NAMES" + exit 1 + fi + echo "name=$PACMAN_NAME" >> "$GITHUB_OUTPUT" + echo "Found pacman asset: $PACMAN_NAME" + + - name: Download and compute sha256 + if: steps.aur_secret.outputs.configured == 'true' + id: sha + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + ASSET: ${{ steps.asset.outputs.name }} + run: | + set -euo pipefail + BASE="https://github.com/${REPO}/releases/download/${TAG}" + curl -fsSL --retry 3 -o /tmp/pkg.pacman "${BASE}/${ASSET}" + PKG_SHA=$(sha256sum /tmp/pkg.pacman | awk '{print $1}') + echo "sha256=$PKG_SHA" >> "$GITHUB_OUTPUT" + + - name: Setup SSH for AUR + if: steps.aur_secret.outputs.configured == 'true' + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} + run: | + set -euo pipefail + if [[ -z "$AUR_KNOWN_HOSTS" ]]; then + echo "::error::AUR_KNOWN_HOSTS variable is required for secure AUR SSH" + exit 1 + fi + mkdir -p ~/.ssh + echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur_key + chmod 600 ~/.ssh/aur_key + printf '%s\n' "$AUR_KNOWN_HOSTS" > ~/.ssh/aur_known_hosts + cat >> ~/.ssh/config <<'SSHCONF' + Host aur.archlinux.org + HostName aur.archlinux.org + User aur + IdentityFile ~/.ssh/aur_key + StrictHostKeyChecking yes + UserKnownHostsFile ~/.ssh/aur_known_hosts + SSHCONF + + - name: Clone AUR repository + if: steps.aur_secret.outputs.configured == 'true' + env: + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} + run: | + set -euo pipefail + git clone "ssh://aur@aur.archlinux.org/${PACKAGE}.git" aur-repo + + - name: Install makepkg + if: steps.aur_secret.outputs.configured == 'true' + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq pacman-package-manager 2>/dev/null || \ + sudo apt-get install -y -qq makepkg 2>/dev/null || { + echo "::error::Unable to install makepkg. Install pacman-package-manager or makepkg." + exit 1 + } + command -v makepkg >/dev/null || { + echo "::error::makepkg still missing after install." + exit 1 + } + + - name: Update PKGBUILD and .SRCINFO + if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo + env: + VERSION: ${{ steps.meta.outputs.version }} + SHA256: ${{ steps.sha.outputs.sha256 }} + ASSET: ${{ steps.asset.outputs.name }} + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + sed -i -E "s|^pkgver=.*|pkgver=${VERSION}|" PKGBUILD + sed -i -E "s|^pkgrel=.*|pkgrel=1|" PKGBUILD + sed -i -E "s|^sha256sums=\('[^']*'|sha256sums=('${SHA256}'|" PKGBUILD + makepkg --printsrcinfo > .SRCINFO + echo "Updated .SRCINFO" + + - name: Commit and push + if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add PKGBUILD .SRCINFO + if git diff --cached --quiet; then + echo "PKGBUILD already up to date for ${VERSION} — nothing to commit." + exit 0 + fi + git commit -m "Bump to ${VERSION}" + git push diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 44a73e2ac..1413552b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,19 +36,13 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci + uses: ./.github/actions/setup - name: Cache caption assets uses: actions/cache@v4 with: path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Build Windows app run: npm run build:win -- --publish never @@ -73,19 +67,13 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm + uses: ./.github/actions/setup - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install dependencies - run: npm ci - - name: Ensure sharp prebuilt run: npm rebuild sharp env: @@ -95,7 +83,7 @@ jobs: uses: actions/cache@v4 with: path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Resolve macOS signing id: signing @@ -249,13 +237,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci + uses: ./.github/actions/setup - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools @@ -264,7 +246,7 @@ jobs: uses: actions/cache@v4 with: path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Build Linux app run: npm run build:linux -- --publish never @@ -319,10 +301,31 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Download installers + - name: Download Windows installer uses: actions/download-artifact@v4 with: - path: artifacts + name: openscreen-windows + path: artifacts/windows + + - name: Download macOS arm64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-arm64 + path: artifacts/mac-arm64 + + - name: Download macOS x64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-x64 + path: artifacts/mac-x64 + + - name: Download Linux packages + uses: actions/download-artifact@v4 + with: + name: openscreen-linux + path: artifacts/linux - name: Publish release assets env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c9e8ef18..605802bea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npm run lint typecheck: @@ -24,11 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npx tsc --noEmit test: @@ -36,11 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npm run test - run: npm run test:browser:install - run: npm run test:browser @@ -50,9 +38,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npx vite build diff --git a/.github/workflows/diagnostic-artifact.yml b/.github/workflows/diagnostic-artifact.yml index eb9279113..bdf9bde9d 100644 --- a/.github/workflows/diagnostic-artifact.yml +++ b/.github/workflows/diagnostic-artifact.yml @@ -17,13 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci + - uses: ./.github/actions/setup - name: Build native helper run: npm run build:native:win @@ -81,13 +75,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci + - uses: ./.github/actions/setup - name: Build native helper run: npm run build:native:mac diff --git a/.github/workflows/discord-pr-notify.yml b/.github/workflows/discord-pr-notify.yml new file mode 100644 index 000000000..8c97f78ec --- /dev/null +++ b/.github/workflows/discord-pr-notify.yml @@ -0,0 +1,44 @@ +name: PR to Discord Forum + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] + pull_request_review: + types: [submitted] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + notify: + name: Sync PR activity to Discord + if: | + github.actor != 'github-actions[bot]' + concurrency: + group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Sync PR activity to Discord forum thread + continue-on-error: true + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }} + DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }} + DISCORD_PR_FORUM_CHANNEL_ID: ${{ vars.DISCORD_PR_FORUM_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-pr-sync.mjs diff --git a/.github/workflows/discord-roadmap-sync.yml b/.github/workflows/discord-roadmap-sync.yml new file mode 100644 index 000000000..bbada3e0e --- /dev/null +++ b/.github/workflows/discord-roadmap-sync.yml @@ -0,0 +1,40 @@ +name: Discord Roadmap Sync + +on: + pull_request_target: + types: [closed] + push: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + roadmap-sync: + name: Sync ROADMAP.md to Discord + if: | + (github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main') || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + concurrency: + group: discord-roadmap-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Sync ROADMAP.md to pinned Discord message + continue-on-error: true + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_ROADMAP_CHANNEL_ID: ${{ vars.DISCORD_ROADMAP_CHANNEL_ID }} + DISCORD_ROADMAP_MESSAGE_ID: ${{ vars.DISCORD_ROADMAP_MESSAGE_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-roadmap-sync.mjs diff --git a/.github/workflows/discord-weekly-leaderboard.yml b/.github/workflows/discord-weekly-leaderboard.yml new file mode 100644 index 000000000..f2c93e453 --- /dev/null +++ b/.github/workflows/discord-weekly-leaderboard.yml @@ -0,0 +1,28 @@ +name: Discord Weekly Leaderboard + +on: + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + leaderboard: + name: Post weekly contributor leaderboard + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Post weekly leaderboard to Discord + env: + DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-weekly-leaderboard.mjs diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml deleted file mode 100644 index aa6d7dd47..000000000 --- a/.github/workflows/discord.yaml +++ /dev/null @@ -1,769 +0,0 @@ -name: PR to Discord Forum - -on: - pull_request_target: - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] - pull_request_review: - types: [submitted] - issue_comment: - types: [created] - push: - branches: [main] - schedule: - - cron: "0 12 * * 1" - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - issues: read - -jobs: - notify: - if: | - (github.event_name == 'pull_request_target' || - github.event_name == 'pull_request_review' || - github.event_name == 'issue_comment') && - github.actor != 'github-actions[bot]' - concurrency: - group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} - cancel-in-progress: false - runs-on: ubuntu-latest - steps: - - name: Sync PR activity to Discord forum thread - id: sync - # Discord sync is helpful automation, but it should not block PR validation. - continue-on-error: true - uses: actions/github-script@v7 - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }} - DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} - DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }} - DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }} - with: - script: | - const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); - const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); - - const THREAD_MARKER_REGEX = //i; - const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim(); - const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); - const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); - const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim(); - - const TAGS = { - open: "1493976692967080096", - draft: "1493976782028935279", - ready: "1493976833626996756", - changes: "1493976909875515564", - approved: "1493976951038152764", - merged: "1493977049709281320", - closed: "1493977108102516786", - }; - - const labelTagMap = { - bug: "1493977562773458975", - enhancement: "1493977619216207993", - documentation: "1493978565153394830", - }; - - function cleanDescription(text, maxLen = 3500) { - if (!text) return "No description provided."; - const normalized = text - .replace(/\r\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (normalized.length <= maxLen) return normalized; - return `${normalized.slice(0, maxLen - 1)}…`; - } - - function trimThreadName(name) { - return name.length > 95 ? name.slice(0, 95) : name; - } - - function extractThreadId(body) { - if (!body) return null; - const match = body.match(THREAD_MARKER_REGEX); - return match ? match[1] : null; - } - - function upsertThreadMarker(body, threadId) { - const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); - return `${cleaned}\n\n`.trim(); - } - - async function discordPost(payload, options = {}) { - const endpoint = new URL(webhookUrl); - endpoint.searchParams.set("wait", "true"); - if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId)); - - const response = await fetch(endpoint.toString(), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: WEBHOOK_USERNAME, - avatar_url: WEBHOOK_AVATAR, - allowed_mentions: { parse: [] }, - ...payload, - }) - }); - - const contentType = (response.headers.get("content-type") || "").toLowerCase(); - const text = await response.text(); - - if (!response.ok) { - throw new Error(`Discord API error ${response.status}: ${text}`); - } - - if (!text) return {}; - if (contentType.includes("application/json")) return JSON.parse(text); - - // Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse. - core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`); - return {}; - } - - async function patchDiscordThread(threadId, patchBody) { - if (!botToken || !threadId) return; - const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { - method: "PATCH", - headers: { - "Authorization": `Bot ${botToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(patchBody), - }); - if (!response.ok) { - const text = await response.text(); - core.warning(`Discord thread patch failed (${response.status}): ${text}`); - } - } - - function desiredStatusTag(prState) { - if (prState.merged && TAGS.merged) return TAGS.merged; - if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed; - if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes; - if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved; - if (prState.draft && TAGS.draft) return TAGS.draft; - if (!prState.draft && TAGS.ready) return TAGS.ready; - return TAGS.open || null; - } - - function tagIdsFromLabels(labels) { - const out = []; - for (const label of labels) { - const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label]; - if (mapped) out.push(String(mapped)); - } - return out; - } - - async function getPullRequest() { - if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") { - return context.payload.pull_request || null; - } - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - if (!issue?.pull_request) return null; - const { data } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: issue.number, - }); - return data; - } - return null; - } - - async function getReviewState(owner, repo, pullNumber) { - const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 }); - let hasChanges = false; - let hasApproved = false; - for (const r of data) { - const s = (r.state || "").toUpperCase(); - if (s === "CHANGES_REQUESTED") hasChanges = true; - if (s === "APPROVED") hasApproved = true; - } - if (hasChanges) return "CHANGES_REQUESTED"; - if (hasApproved) return "APPROVED"; - return "NONE"; - } - - async function sendFailureAlert(message) { - if (!alertWebhookUrl) return; - try { - await fetch(alertWebhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "OpenScreen", - avatar_url: WEBHOOK_AVATAR, - content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - allowed_mentions: { parse: [] } - }) - }); - } catch { - core.warning("Failed to send failure alert webhook."); - } - } - - try { - const pr = await getPullRequest(); - if (!pr) { - core.info("No PR context found. Skipping."); - return; - } - - if (!webhookUrl) { - const msg = - `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + - "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets."; - core.warning(msg); - return; - } - - const action = context.payload.action || ""; - const owner = context.repo.owner; - const repo = context.repo.repo; - const number = pr.number; - const title = pr.title; - const author = pr.user?.login || "unknown"; - const url = pr.html_url; - const authorUrl = pr.user?.html_url || ""; - const authorAvatar = pr.user?.avatar_url || ""; - const base = pr.base?.ref || ""; - const head = pr.head?.ref || ""; - const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`; - const labels = (pr.labels || []).map((l) => l.name); - const body = (pr.body || "").trim(); - const reviewState = await getReviewState(owner, repo, number); - - let threadId = extractThreadId(body); - const shouldCreateThread = - context.eventName === "pull_request_target" && - ["opened", "reopened", "ready_for_review"].includes(action) && - !threadId; - - if (shouldCreateThread) { - const fields = [ - { name: "PR", value: `[#${number}](${url})`, inline: true }, - { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true }, - { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true }, - { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true }, - { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true }, - { name: "Files Changed", value: String(pr.changed_files), inline: true } - ]; - - if (labels.length) { - fields.push({ - name: "Labels", - value: labels.map((l) => `\`${l}\``).join(" "), - inline: false, - }); - } - - const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false }); - const mappedLabelTags = tagIdsFromLabels(labels); - const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; - - const createPayload = { - content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened", - thread_name: trimThreadName(`PR #${number} - ${title}`), - applied_tags: appliedTags, - embeds: [ - { - title: `PR #${number}: ${title}`, - url, - description: cleanDescription(body), - color: pr.draft ? 15105570 : 1998671, - author: { - name: author, - url: authorUrl || undefined, - icon_url: authorAvatar || undefined, - }, - fields, - footer: { text: repoFullName }, - timestamp: new Date().toISOString(), - }, - ], - }; - - const result = await discordPost(createPayload); - const createdThreadId = result.channel_id || null; - if (createdThreadId) { - const updatedBody = upsertThreadMarker(body, createdThreadId); - await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody }); - core.info(`Created Discord thread ${createdThreadId} and stored mapping.`); - } else { - core.warning("Discord thread created but channel_id missing in response."); - } - return; - } - - if (!threadId) { - core.info("No mapped Discord thread ID found; skipping update event."); - return; - } - - if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) { - const statusTag = desiredStatusTag({ - draft: action === "converted_to_draft" ? true : pr.draft, - reviewState, - merged: false, - closed: false, - }); - const mappedLabelTags = tagIdsFromLabels(labels); - const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; - await patchDiscordThread(threadId, { - name: trimThreadName(`PR #${number} - ${title}`), - ...(appliedTags.length ? { applied_tags: appliedTags } : {}), - }); - } - - let updateMessage = null; - let updateEmbed = null; - - if (context.eventName === "pull_request_target") { - if (action === "synchronize") { - const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 }); - const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details"; - updateMessage = `🧩 New commits pushed to PR #${number}`; - updateEmbed = { - title: `Commit Update • PR #${number}`, - url: `${url}/files`, - description: `${list}`, - color: 1998671, - footer: { text: repoFullName }, - timestamp: new Date().toISOString(), - }; - } else if (action === "edited") { - updateMessage = `✏️ PR #${number} details were edited`; - updateEmbed = { - title: `PR Updated • #${number}`, - url, - description: cleanDescription(body, 1200), - color: 1998671, - timestamp: new Date().toISOString(), - }; - } else if (action === "closed") { - const isMerged = !!pr.merged; - const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true }); - const mappedLabelTags = tagIdsFromLabels(labels); - const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; - await patchDiscordThread(threadId, { - ...(appliedTags.length ? { applied_tags: appliedTags } : {}), - ...(isMerged ? { archived: true, locked: true } : {}), - }); - - updateMessage = isMerged - ? `✅ PR #${number} was merged` - : `🛑 PR #${number} was closed without merge`; - updateEmbed = { - title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`, - url, - description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.", - color: isMerged ? 5763719 : 15158332, - timestamp: new Date().toISOString(), - }; - } else if (action === "ready_for_review") { - updateMessage = `🚀 PR #${number} moved from draft to ready for review`; - if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; - } else if (action === "converted_to_draft") { - updateMessage = `📝 PR #${number} converted to draft`; - } - } else if (context.eventName === "pull_request_review") { - const review = context.payload.review; - if (review) { - const state = (review.state || "commented").toUpperCase(); - const reviewer = review.user?.login || "reviewer"; - updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`; - if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; - updateEmbed = { - title: `Review ${state} • PR #${number}`, - url: review.html_url || url, - description: cleanDescription(review.body || "No review note.", 1000), - color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671, - timestamp: new Date().toISOString(), - }; - - if (state === "CHANGES_REQUESTED" || state === "APPROVED") { - const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false }); - const mappedLabelTags = tagIdsFromLabels(labels); - const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; - await patchDiscordThread(threadId, { - ...(appliedTags.length ? { applied_tags: appliedTags } : {}), - }); - } - } - } else if (context.eventName === "issue_comment") { - const comment = context.payload.comment; - if (comment) { - const commenter = comment.user?.login || "user"; - updateMessage = `💬 New comment by **${commenter}** on PR #${number}`; - updateEmbed = { - title: `New PR Comment • #${number}`, - url: comment.html_url || url, - description: cleanDescription(comment.body || "No comment body.", 1000), - color: 1998671, - timestamp: new Date().toISOString(), - }; - } - } - - if (!updateMessage && !updateEmbed) { - core.info("No Discord update message for this event/action. Skipping."); - return; - } - - const payload = { content: updateMessage || "" }; - if (updateEmbed) payload.embeds = [updateEmbed]; - await discordPost(payload, { threadId }); - core.info(`Posted update to Discord thread ${threadId}.`); - } catch (err) { - const msg = err && err.message ? err.message : String(err); - core.warning(`Discord sync failed, but this optional automation will not block PR validation: ${msg}`); - - const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; - if (alertWebhook) { - try { - await fetch(alertWebhook, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "OpenScreen", - avatar_url: WEBHOOK_AVATAR, - content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - allowed_mentions: { parse: [] } - }) - }); - } catch { - core.warning("Failed to send alert webhook."); - } - } - } - - weekly-contributor-leaderboard: - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: Post weekly contributor leaderboard - uses: actions/github-script@v7 - env: - DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }} - DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} - DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} - with: - script: | - const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim(); - const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); - const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); - if (!spotlightWebhook) { - core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post."); - return; - } - - const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const owner = context.repo.owner; - const repo = context.repo.repo; - - const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`; - const search = await github.rest.search.issuesAndPullRequests({ - q, - per_page: 100, - }); - - const counter = new Map(); - for (const item of search.data.items) { - const login = item.user?.login; - if (!login) continue; - counter.set(login, (counter.get(login) || 0) + 1); - } - - const ranked = [...counter.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 10); - - const totalMerged = search.data.items.length; - const lines = ranked.length - ? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n") - : "No merged PRs this week."; - - const payload = { - username: webhookUsername, - ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}), - embeds: [ - { - title: "🌟 Weekly Contributor Leaderboard", - description: lines, - color: 1998671, - fields: [ - { name: "Merged PRs (7d)", value: String(totalMerged), inline: true }, - { name: "Repository", value: `${owner}/${repo}`, inline: true }, - { name: "Period", value: "Last 7 days", inline: true } - ], - timestamp: new Date().toISOString() - } - ], - allowed_mentions: { parse: [] } - }; - - const res = await fetch(`${spotlightWebhook}?wait=true`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const txt = await res.text(); - core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`); - } - - # Keeps the #🗺️・roadmap Discord channel in sync with ./ROADMAP.md. - # On every push to main (direct or via PR merge) that touches ROADMAP.md, - # the bot updates a single pinned message in the channel to mirror the - # file's current content. The Discord channel is a read-only mirror of - # the file; the file is the source of truth. - # - # The bot uses the channel's pin as the persistent state: it lists the - # pinned messages, finds the one whose embed title matches - # "🗺️ OpenScreen Roadmap", and PATCHes it. If none is found, it POSTs a - # new message and pins it. No external variable is required — the pin - # IS the state. Self-healing: if a moderator unpins, the next sync - # re-pins automatically. - # - # Required: - # DISCORD_BOT_TOKEN — bot token of the openscreen_etienne app (secret) - # DISCORD_ROADMAP_CHANNEL_ID — channel id of #🗺️・roadmap (var) - # - # Optional escape hatch: - # DISCORD_ROADMAP_MESSAGE_ID — force a specific message id instead of - # looking it up via pins (e.g. after a manual unpin recovery). - # - # Per-channel permissions required on the Discord side: - # The bot's role must have "Send Messages", "Embed Links", and - # "Manage Messages" allowed on #🗺️・roadmap (channel-level overrides). - roadmap-notify: - if: | - (github.event_name == 'pull_request_target' && - github.event.action == 'closed' && - github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'main') || - (github.event_name == 'push' && github.ref == 'refs/heads/main') - concurrency: - group: discord-roadmap-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - runs-on: ubuntu-latest - steps: - - name: Sync ROADMAP.md to pinned Discord message - continue-on-error: true - uses: actions/github-script@v7 - env: - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_ROADMAP_CHANNEL_ID: ${{ vars.DISCORD_ROADMAP_CHANNEL_ID }} - DISCORD_ROADMAP_MESSAGE_ID: ${{ vars.DISCORD_ROADMAP_MESSAGE_ID }} - with: - script: | - const ROADMAP_PATTERN = /(^|\/)ROADMAP\.md$|(^|\/)docs\/roadmap\.md$/i; - const ROADMAP_EMBED_TITLE = "🗺️ OpenScreen Roadmap"; - - const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); - const channelId = (process.env.DISCORD_ROADMAP_CHANNEL_ID || "").trim(); - const overrideMessageId = (process.env.DISCORD_ROADMAP_MESSAGE_ID || "").trim(); - - if (!botToken || !channelId) { - core.info( - "DISCORD_BOT_TOKEN or DISCORD_ROADMAP_CHANNEL_ID not set; skipping. " + - "Configure both as repo secret / variable to enable #🗺️・roadmap auto-sync." - ); - return; - } - - // 0. Resolve the message id to update. The pin is the persistent - // state: list the channel's pinned messages and find the one - // whose embed title matches. The DISCORD_ROADMAP_MESSAGE_ID - // var is an escape hatch (overrides the pin lookup). - let existingMessageId = overrideMessageId; - if (!existingMessageId) { - try { - const pinRes = await fetch(`https://discord.com/api/v10/channels/${channelId}/pins`, { - headers: { "Authorization": `Bot ${botToken}` }, - }); - if (pinRes.ok) { - const pins = await pinRes.json(); - const existing = pins.find(m => m.embeds?.[0]?.title === ROADMAP_EMBED_TITLE); - if (existing) { - existingMessageId = existing.id; - core.info(`Found existing pinned roadmap message ${existingMessageId}.`); - } else { - core.info("No existing pinned roadmap message found; will create one."); - } - } else { - const txt = await pinRes.text(); - core.warning(`Failed to fetch pins (${pinRes.status}): ${txt}; falling back to POST.`); - } - } catch (err) { - core.warning(`Pin lookup threw: ${err && err.message ? err.message : err}; falling back to POST.`); - } - } - - // 1. Detect which files changed in this event. - let changedFiles = []; - try { - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - if (!pr) { - core.info("No PR context; skipping."); - return; - } - const res = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }); - changedFiles = res.data; - } else if (context.eventName === "push") { - const sha = context.payload.after || context.payload.head_commit?.id || context.sha; - const res = await github.rest.repos.getCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: sha, - }); - changedFiles = res.data.files || []; - } - } catch (err) { - core.warning(`Failed to list changed files: ${err && err.message ? err.message : err}`); - return; - } - - const roadmapFiles = changedFiles.filter(f => ROADMAP_PATTERN.test(f.filename)); - if (roadmapFiles.length === 0) { - core.info("No roadmap files in event; skipping."); - return; - } - - // 2. Fetch the current ROADMAP.md content from main. - let content; - try { - const res = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: "ROADMAP.md", - ref: "main", - }); - if (Array.isArray(res.data) || res.data.type !== "file" || !res.data.content) { - core.warning("ROADMAP.md is not a readable file; skipping."); - return; - } - content = Buffer.from(res.data.content, "base64").toString("utf-8"); - } catch (err) { - core.warning(`Failed to fetch ROADMAP.md: ${err && err.message ? err.message : err}`); - return; - } - - // 3. Truncate if it exceeds Discord's embed description limit. - const rawUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/blob/main/ROADMAP.md`; - const truncationNote = `\n\n… *(truncated, see [full file on GitHub](${rawUrl}))*`; - // The truncation note itself consumes characters, so slice content to leave room. - const maxContentLength = 4096 - truncationNote.length; - let description = content; - let truncated = false; - if (content.length > maxContentLength) { - description = content.slice(0, maxContentLength) + truncationNote; - truncated = true; - } - - // 4. Build the embed payload. - const syncedAt = new Date().toISOString().split("T")[0]; - const payload = { - embeds: [ - { - title: ROADMAP_EMBED_TITLE, - url: rawUrl, - description, - color: 1998671, - footer: { - text: `${context.repo.owner}/${context.repo.repo} • Last synced ${syncedAt}`, - }, - timestamp: new Date().toISOString(), - }, - ], - allowed_mentions: { parse: [] }, - }; - if (truncated) { - payload.content = `⚠️ Roadmap exceeds Discord embed limit; truncated. See the [full file on GitHub](${rawUrl}) for the complete version.`; - } - - // 5. PATCH the existing message, or POST a new one. - let messageId = existingMessageId; - try { - if (messageId) { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, { - method: "PATCH", - headers: { - "Authorization": `Bot ${botToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - if (res.status === 404) { - core.warning(`Existing message ${messageId} not found in Discord (was it deleted?). Falling back to POST.`); - messageId = ""; - } else if (!res.ok) { - const txt = await res.text(); - core.warning(`Roadmap Discord PATCH failed ${res.status}: ${txt}`); - return; - } else { - core.info(`Roadmap Discord message ${messageId} updated.`); - } - } - - if (!messageId) { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - "Authorization": `Bot ${botToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const txt = await res.text(); - core.warning(`Roadmap Discord POST failed ${res.status}: ${txt}`); - return; - } - const data = await res.json(); - messageId = data.id; - core.info(`🆕 New roadmap message created with id ${messageId}.`); - core.info(`👉 Set DISCORD_ROADMAP_MESSAGE_ID=${messageId} as a repo variable to update this message on future changes.`); - core.info(` gh variable set DISCORD_ROADMAP_MESSAGE_ID ${messageId} --repo ${context.repo.owner}/${context.repo.repo}`); - } - } catch (err) { - core.warning(`Roadmap Discord sync threw: ${err && err.message ? err.message : err}`); - return; - } - - // 6. Pin the message (idempotent: PUT on an already-pinned message returns 204). - // Self-healing: always attempt, so a transient 403 or a manual unpin by a - // moderator will get fixed on the next sync without manual intervention. - try { - const pinRes = await fetch(`https://discord.com/api/v10/channels/${channelId}/pins/${messageId}`, { - method: "PUT", - headers: { - "Authorization": `Bot ${botToken}`, - }, - }); - if (pinRes.status === 204 || pinRes.ok) { - core.info(`Message ${messageId} pinned.`); - } else if (pinRes.status === 403) { - core.warning(`Cannot pin message: bot lacks 'Manage Messages' on the channel. Add it via Discord channel permissions.`); - } else { - const txt = await pinRes.text(); - core.warning(`Pin failed ${pinRes.status}: ${txt}`); - } - } catch (err) { - core.warning(`Pin threw: ${err && err.message ? err.message : err}`); - } diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 030b893bd..60661a3bb 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -38,6 +38,32 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Wait for release DMG assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + TIMEOUT_MINUTES=12 + POLL_INTERVAL=30 + MAX_ATTEMPTS=$(( (TIMEOUT_MINUTES * 60) / POLL_INTERVAL )) + VERSION="${TAG#v}" + ARM_DMG="Openscreen-Mac-arm64-${VERSION}.dmg" + X64_DMG="Openscreen-Mac-x64-${VERSION}.dmg" + + for i in $(seq 1 $MAX_ATTEMPTS); do + if gh release view "$TAG" --repo "$REPO" --json assets --jq \ + --arg arm "$ARM_DMG" --arg x64 "$X64_DMG" \ + '[.assets[] | select(.name == $arm or .name == $x64)] | length' 2>/dev/null | grep -q '^2$'; then + echo "Both DMG assets present: $ARM_DMG and $X64_DMG" + exit 0 + fi + echo "Waiting for DMG assets... (attempt $i/$MAX_ATTEMPTS)" + sleep $POLL_INTERVAL + done + echo "::warning::Timeout after ${TIMEOUT_MINUTES}min waiting for DMG assets. Proceeding anyway." + - name: Find macOS DMG assets id: assets env: diff --git a/docs/github-actions-workflows.md b/docs/github-actions-workflows.md new file mode 100644 index 000000000..282fa20dd --- /dev/null +++ b/docs/github-actions-workflows.md @@ -0,0 +1,195 @@ +# GitHub Actions workflows + +## Overview + +The repository uses 12 workflow files across four functional tiers. This document describes the triggers, job dependencies, and artifact flow for each tier. + +## Workflow dependency graph + +```mermaid +graph TD + subgraph Tier 1 - CI + ci[ci.yml
push / PR → main] + ci_lint[lint] + ci_typecheck[typecheck] + ci_test[test] + ci_build[build] + ci --> ci_lint + ci --> ci_typecheck + ci --> ci_test + ci --> ci_build + end + + subgraph Tier 2 - Release build + build[build.yml
tag v* / dispatch] + build_win[build-windows] + build_mac[build-macos
matrix arm64 x64] + build_linux[build-linux] + build_release[publish-release] + build --> build_win + build --> build_mac + build --> build_linux + build_win --> build_release + build_mac --> build_release + build_linux --> build_release + end + + subgraph Tier 3 - Package registries + homebrew[update-homebrew-cask.yml
release published] + winget[publish-winget.yml
release published] + nix[bump-nix-package.yml
release published] + aur[aur-publish.yml
release published] + end + + subgraph Tier 4 - Automation + discord_pr[discord-pr-notify.yml
PR events, review, comment] + discord_roadmap[discord-roadmap-sync.yml
push to main] + discord_leaderboard[discord-weekly-leaderboard.yml
schedule mon 12:00 UTC] + bookkeeping[merged-pr-bookkeeping.yml
PR closed merged] + diag[diagnostic-artifact.yml
push / PR / dispatch] + diag_win[build-windows] + diag_mac[build-macos
matrix arm64 x64] + diag --> diag_win + diag --> diag_mac + end + + build_release -->|gh release create| homebrew + build_release -->|gh release create| winget + build_release -->|gh release create| nix + build_release -->|gh release create| aur +``` + +## Tier 1: CI checks + +**File:** `ci.yml` + +Triggered on every push to `main` and every pull request targeting `main`. Four parallel, independent jobs with no interdependencies: + +| Job | Runner | Purpose | +|---|---|---| +| `lint` | ubuntu-latest | Biome check | +| `typecheck` | ubuntu-latest | `tsc --noEmit` | +| `test` | ubuntu-latest | Vitest unit tests + Playwright browser tests | +| `build` | ubuntu-latest | `vite build` (renderer-only, no electron-builder) | + +All jobs use the shared composite action `.github/actions/setup` for Node.js installation and `npm ci`. Failure of one job does not cancel the others. + +## Tier 2: Release build and publish + +### build.yml + +Triggered by version tags (`v*`) or manual `workflow_dispatch` (with optional macOS architecture selection and release tag override). + +**Jobs:** + +1. **`build-windows`** (windows-latest): Compiles NSIS installer via `electron-builder --win`. Uploads artifact `openscreen-windows` (30-day retention). + +2. **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles native helpers, runs `tsc && vite build`, builds `.app` bundle, creates and signs a DMG. Uploads artifacts `openscreen-mac-arm64` and `openscreen-mac-x64` (30-day retention). Signing and notarization are conditional on the presence of Apple developer secrets (`MAC_CERTIFICATE_P12`, `APPLE_ID`, etc.). Without secrets, produces an unsigned DMG. + +3. **`build-linux`** (ubuntu-latest): Installs `libarchive-tools` for `.pacman` support, runs `electron-builder --linux AppImage deb pacman`. Uploads artifact `openscreen-linux` (30-day retention). + +4. **`publish-release`** (ubuntu-latest, needs all three build jobs): Downloads all four artifacts by explicit name, validates that `package.json` version matches the tag, and publishes them to a GitHub Release via `gh release create` or `gh release upload --clobber`. The download step uses explicit `name:` parameters to fail fast on missing artifacts rather than silently skipping them. + +All three build jobs use a shared caption-assets cache keyed by `runner.os` and the hash of `scripts/fetch-caption-model.mjs` to avoid cross-platform cache collisions. + +## Tier 3: Package registries + +These workflows react to `release: published` events and push the release to external package registries. Each also supports `workflow_dispatch` for manual re-runs. + +### update-homebrew-cask.yml + +Finds both `arm64` and `x64` DMG assets in the release, downloads them, computes SHA-256, generates a Ruby cask file, and pushes it to a separate Homebrew tap repository (`vars.HOMEBREW_TAP_OWNER` / `vars.HOMEBREW_TAP_REPO`). + +Before scanning for assets, a polling loop waits up to 12 minutes for DMGs to appear in the release, accounting for the Apple notarization delay. + +Conditional on `vars.HOMEBREW_TAP_OWNER`, `vars.HOMEBREW_TAP_REPO`, and `secrets.HOMEBREW_TAP_TOKEN`. + +### publish-winget.yml + +Delegates to `vedantmgoyal9/winget-releaser@v2`, which finds the Windows installer matching `Setup\..*\.exe$` and publishes a manifest to the WinGet Community Repository. + +Conditional on `vars.WINGET_IDENTIFIER` and `secrets.WINGET_ACC_TOKEN`. + +### bump-nix-package.yml + +Checks out `main`, installs Nix, runs `prefetch-npm-deps` on `package-lock.json` to compute the new `npmDepsHash`, patches `nix/package.nix` with `sed`, and opens a PR against `main` on branch `chore/bump-nix-{version}`. + +Conditional on non-prerelease releases. + +### aur-publish.yml + +Finds the `.pacman` asset in the release, computes SHA-256, clones the AUR repository via SSH, updates `PKGBUILD` and `.SRCINFO`, and pushes the updated package. + +Conditional on `vars.AUR_PACKAGE_NAME` and `secrets.AUR_SSH_PRIVATE_KEY`. + +## Tier 4: Automation and diagnostics + +### discord-pr-notify.yml + +Triggered by `pull_request_target` (opened, reopened, synchronize, edited, labeled, unlabeled, closed, converted_to_draft, ready_for_review), `pull_request_review` (submitted), and `issue_comment` (created). + +Runs `node .github/scripts/discord-pr-sync.mjs`, which creates or updates a Discord forum thread for each PR. Thread state is persisted via an HTML comment (``) in the PR body. Tag updates (draft, ready, changes requested, approved, merged, closed) are applied via the Discord API. The job is marked `continue-on-error: true` so that Discord failures never block the PR workflow. + +### discord-roadmap-sync.yml + +Triggered on push to `main` and on merged PRs targeting `main`. Runs `node .github/scripts/discord-roadmap-sync.mjs`, which: + +- Detects whether `ROADMAP.md` changed in the event +- Fetches the current `ROADMAP.md` from `main` +- Updates (or creates and pins) a Discord message in the `#roadmap` channel +- Uses the channel's pinned message as persistent state; self-heals if a moderator unpins it + +Requires `DISCORD_BOT_TOKEN` (secret) and `DISCORD_ROADMAP_CHANNEL_ID` (variable). `DISCORD_ROADMAP_MESSAGE_ID` (variable) is an optional escape hatch that bypasses the pin-based lookup. + +### discord-weekly-leaderboard.yml + +Triggered by schedule (Mondays at 12:00 UTC) and `workflow_dispatch`. Runs `node .github/scripts/discord-weekly-leaderboard.mjs`, which queries the GitHub Search API for merged PRs in the last 7 days, ranks contributors by PR count, and posts a top-10 leaderboard to a Discord webhook. + +### merged-pr-bookkeeping.yml + +Triggered by `pull_request_target: closed` on merged PRs targeting `main`. Uses a GraphQL query (`closingIssuesReferences`) to find linked issues, then: + +- Adds labels `status: fixed in main` and `status: pending release` +- Removes `status: in progress` and `status: needs triage` +- Assigns the `Next Release` milestone (creates it if missing) +- Closes the issue with `state_reason: completed` +- Posts an idempotent comment with a marker comment + +### diagnostic-artifact.yml + +Triggered on push to `main`, PRs targeting `main`, and `workflow_dispatch`. Produces platform-specific diagnostic bundles for troubleshooting: + +- **`build-windows`** (windows-latest): Compiles the WGC capture helper via CMake, bundles it with diagnostic scripts into a ZIP, smoke-tests the bundle structure. +- **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles the ScreenCaptureKit helper, bundles it with diagnostic scripts into a `.tar.gz`. + +Artifacts are retained for 14 days (shorter than release artifacts). + +## Shared infrastructure + +### Composite action: `.github/actions/setup` + +A single composite action used by all jobs that need Node.js: + +```yaml +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + shell: bash +``` + +When the Node.js version needs to change, only this one file is updated. The action does not include `actions/checkout`; callers manage their own checkout step to allow for custom `ref`, `repository`, or `fetch-depth` options. + +### Inline scripts: `.github/scripts/` + +Scripts previously embedded as `actions/github-script@v7` inline JavaScript blocks are now standalone `.mjs` files invoked via `node`. This allows: + +- Biome linting and formatting coverage in CI +- TypeScript type-checking coverage in CI +- Local execution and debugging outside of GitHub Actions + +The scripts import `@actions/core` and `@actions/github` (added to `devDependencies`) to access the same APIs (`core.info`, `core.warning`, `context`, `getOctokit`) that `actions/github-script@v7` provides as globals. diff --git a/package-lock.json b/package-lock.json index f3f21713b..0e8e1661d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,8 @@ "web-demuxer": "^4.0.0" }, "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", "@biomejs/biome": "^2.4.12", "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", @@ -86,6 +88,92 @@ "npm": "10.9.4" } }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/github": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-9.1.1.tgz", + "integrity": "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^3.0.2", + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "@octokit/request": "^10.0.7", + "@octokit/request-error": "^7.1.0", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/github/node_modules/@actions/http-client": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz", + "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/github/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -1965,6 +2053,144 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", + "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", @@ -4980,6 +5206,13 @@ "node": ">=6.0.0" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5586,6 +5819,20 @@ "dev": true, "license": "MIT" }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7667,6 +7914,13 @@ "license": "ISC", "optional": true }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -10958,6 +11212,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -11014,6 +11278,13 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index e3ee24a6b..125c0b0e2 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,8 @@ "web-demuxer": "^4.0.0" }, "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", "@biomejs/biome": "^2.4.12", "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", diff --git a/vitest.config.ts b/vitest.config.ts index 5a52a9bea..e6b1497f9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: {