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: {