Skip to content

Commit da4e1ca

Browse files
committed
feat(ci): release candidate pipeline (prerelease + promote workflows)
- .github/workflows/prerelease.yml: cut vX.Y.Z-rc.N from rolling Next Release milestone - .github/workflows/promote.yml: promote RC tag to stable release - .github/scripts/release-milestone-migrate.mjs: snapshot Next Release -> vX.Y.Z - .github/scripts/release-milestone-close.mjs: close vX.Y.Z milestone on promote - .github/scripts/discord-release-announce.mjs: announce RC and stable - build.yml: tag regex accepts pre-release, --prerelease, skip notarization, use OPENSCREEN_RELEASE_TOKEN - ci.yml: semantic-pr job validates PR titles for clean release notes - docs: AGENTS.md, git-workflow.md, github-actions-workflows.md, secrets.md
1 parent f6b81e7 commit da4e1ca

12 files changed

Lines changed: 828 additions & 21 deletions
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { info, warning } from "@actions/core";
2+
import { context, getOctokit } from "@actions/github";
3+
4+
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
5+
const kind = (process.env.KIND || "stable").trim();
6+
const stableTag = (process.env.STABLE_TAG || "").trim();
7+
const rcTag = (process.env.RC_TAG || "").trim();
8+
const extra = (process.env.EXTRA || "").trim();
9+
10+
const channelId =
11+
kind === "rc"
12+
? (process.env.DISCORD_RC_TESTING_CHANNEL_ID || "").trim()
13+
: (process.env.DISCORD_RELEASE_CHANNEL_ID || "").trim();
14+
15+
if (!botToken || !channelId) {
16+
info(
17+
`Discord ${kind} announcement skipped: missing DISCORD_BOT_TOKEN or channel id. ` +
18+
`Set DISCORD_${kind === "rc" ? "RC_TESTING_" : ""}CHANNEL_ID as a repo variable.`,
19+
);
20+
process.exit(0);
21+
}
22+
if (!stableTag) {
23+
warning("STABLE_TAG missing; skipping.");
24+
process.exit(0);
25+
}
26+
27+
const owner = context.repo.owner;
28+
const repo = context.repo.repo;
29+
const releaseUrl = `${context.serverUrl}/${owner}/${repo}/releases/tag/${stableTag}`;
30+
31+
const octokit = getOctokit(process.env.GITHUB_TOKEN);
32+
33+
let releaseBody = "";
34+
try {
35+
const r = await octokit.rest.repos.getReleaseByTag({
36+
owner,
37+
repo,
38+
tag: stableTag,
39+
});
40+
releaseBody = (r.data.body || "").slice(0, 1500);
41+
} catch (err) {
42+
warning(`Failed to fetch release body: ${err?.message ?? err}`);
43+
}
44+
45+
let closedIssues = [];
46+
try {
47+
const versionTitle = `v${stableTag.replace(/^v/, "").replace(/-.*$/, "")}`;
48+
const milestones = await octokit.paginate(octokit.rest.issues.listMilestones, {
49+
owner,
50+
repo,
51+
state: "closed",
52+
per_page: 100,
53+
});
54+
const m = milestones.find((x) => x.title === versionTitle);
55+
if (m) {
56+
const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
57+
owner,
58+
repo,
59+
milestone: `${m.number}`,
60+
state: "closed",
61+
per_page: 100,
62+
});
63+
closedIssues = issues
64+
.filter((i) => !i.pull_request)
65+
.slice(0, 20)
66+
.map((i) => `• [#${i.number}](${i.html_url}) ${i.title}`);
67+
}
68+
} catch (err) {
69+
warning(`Failed to fetch closed issues: ${err?.message ?? err}`);
70+
}
71+
72+
const isRc = kind === "rc";
73+
const title = isRc
74+
? `🧪 ${stableTag} release candidate ready for testing`
75+
: `🚀 ${stableTag} released`;
76+
const color = isRc ? 15844367 : 5814783;
77+
78+
const description = [
79+
extra ? `> ${extra}\n` : "",
80+
`📦 **Download:** [${stableTag}](${releaseUrl})`,
81+
isRc && rcTag ? `_Promoted from \`${rcTag}\`_` : "",
82+
closedIssues.length > 0 ? `\n**Closed issues in this release:**\n${closedIssues.join("\n")}` : "",
83+
releaseBody ? `\n---\n${releaseBody}` : "",
84+
]
85+
.filter(Boolean)
86+
.join("\n");
87+
88+
const payload = {
89+
embeds: [
90+
{
91+
title,
92+
url: releaseUrl,
93+
description,
94+
color,
95+
timestamp: new Date().toISOString(),
96+
},
97+
],
98+
allowed_mentions: { parse: [] },
99+
};
100+
101+
try {
102+
const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
103+
method: "POST",
104+
headers: {
105+
Authorization: `Bot ${botToken}`,
106+
"Content-Type": "application/json",
107+
},
108+
body: JSON.stringify(payload),
109+
});
110+
if (!res.ok) {
111+
const txt = await res.text();
112+
warning(`Discord POST failed ${res.status}: ${txt}`);
113+
process.exit(0);
114+
}
115+
info(`📣 ${kind} announcement posted for ${stableTag} in channel ${channelId}.`);
116+
} catch (err) {
117+
warning(`Discord throw: ${err?.message ?? err}`);
118+
}

‎.github/scripts/discord-roadmap-sync.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async function main() {
186186
`👉 Set DISCORD_ROADMAP_MESSAGE_ID=${messageId} as a repo variable to update this message on future changes.`,
187187
);
188188
info(
189-
` gh variable set DISCORD_ROADMAP_MESSAGE_ID ${messageId} --repo ${context.repo.owner}/${context.repo.repo}`,
189+
` gh variable set DISCORD_ROADMAP_MESSAGE_ID --body "${messageId}" --repo ${context.repo.owner}/${context.repo.repo}`,
190190
);
191191
}
192192
} catch (err) {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { info, warning } from "@actions/core";
2+
import { getOctokit } from "@actions/github";
3+
4+
const token = (process.env.TOKEN || "").trim();
5+
const stable = (process.env.STABLE_VERSION || "").trim();
6+
7+
if (!token || !stable) {
8+
warning("TOKEN or STABLE_VERSION missing; skipping milestone close.");
9+
process.exit(0);
10+
}
11+
12+
const title = `v${stable}`;
13+
const owner = process.env.GITHUB_REPOSITORY_OWNER || "";
14+
const repoFull = process.env.GITHUB_REPOSITORY || "";
15+
const repo = repoFull.includes("/") ? repoFull.split("/")[1] : repoFull;
16+
17+
if (!owner || !repo) {
18+
warning("GITHUB_REPOSITORY not set; skipping.");
19+
process.exit(0);
20+
}
21+
22+
const octokit = getOctokit(token);
23+
24+
const open = await octokit.paginate(octokit.rest.issues.listMilestones, {
25+
owner,
26+
repo,
27+
state: "open",
28+
per_page: 100,
29+
});
30+
const m = open.find((x) => x.title === title);
31+
if (!m) {
32+
info(`Open milestone "${title}" not found; nothing to close.`);
33+
process.exit(0);
34+
}
35+
36+
await octokit.rest.issues.updateMilestone({
37+
owner,
38+
repo,
39+
milestone_number: m.number,
40+
state: "closed",
41+
});
42+
info(`Closed milestone "${title}" (#${m.number}).`);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { info, warning } from "@actions/core";
2+
import { getOctokit } from "@actions/github";
3+
4+
const ROLLING_NAME = "Next Release";
5+
const MARKER_PREFIX = "<!-- openscreen-rc-migrated:";
6+
7+
const token = (process.env.TOKEN || "").trim();
8+
const nextVersion = (process.env.NEXT || "").trim();
9+
10+
if (!token || !nextVersion) {
11+
warning("TOKEN or NEXT missing; skipping milestone migration.");
12+
process.exit(0);
13+
}
14+
15+
const versionedName = `v${nextVersion}`;
16+
const owner = process.env.GITHUB_REPOSITORY_OWNER || "";
17+
const repoFull = process.env.GITHUB_REPOSITORY || "";
18+
const repo = repoFull.includes("/") ? repoFull.split("/")[1] : repoFull;
19+
20+
if (!owner || !repo) {
21+
warning("GITHUB_REPOSITORY not set; skipping.");
22+
process.exit(0);
23+
}
24+
25+
const octokit = getOctokit(token);
26+
27+
async function findMilestone(title) {
28+
const all = await octokit.paginate(octokit.rest.issues.listMilestones, {
29+
owner,
30+
repo,
31+
state: "all",
32+
per_page: 100,
33+
});
34+
return all.find((m) => m.title === title) || null;
35+
}
36+
37+
async function ensureMilestone(title) {
38+
const existing = await findMilestone(title);
39+
if (existing && existing.state === "open") return existing;
40+
if (existing) {
41+
const reopened = await octokit.rest.issues.updateMilestone({
42+
owner,
43+
repo,
44+
milestone_number: existing.number,
45+
state: "open",
46+
});
47+
info(`Reopened milestone "${title}".`);
48+
return reopened.data;
49+
}
50+
const created = await octokit.rest.issues.createMilestone({
51+
owner,
52+
repo,
53+
title,
54+
description: `Issues and PRs included in ${title}.`,
55+
});
56+
info(`Created milestone "${title}".`);
57+
return created.data;
58+
}
59+
60+
async function listMilestoneItems(milestoneNumber) {
61+
return octokit.paginate(octokit.rest.issues.listForRepo, {
62+
owner,
63+
repo,
64+
milestone: `${milestoneNumber}`,
65+
state: "all",
66+
per_page: 100,
67+
});
68+
}
69+
70+
async function hasMarker(issueNumber, versionedTitle) {
71+
const marker = `${MARKER_PREFIX}${versionedTitle} -->`;
72+
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
73+
owner,
74+
repo,
75+
issue_number: issueNumber,
76+
per_page: 100,
77+
});
78+
return comments.some((c) => c.body && c.body.includes(marker));
79+
}
80+
81+
async function main() {
82+
const rolling = await findMilestone(ROLLING_NAME);
83+
if (!rolling) {
84+
info(`Rolling milestone "${ROLLING_NAME}" not found; nothing to migrate.`);
85+
return;
86+
}
87+
88+
const versioned = await ensureMilestone(versionedName);
89+
info(`Target milestone: ${versionedName} (#${versioned.number}).`);
90+
91+
const items = await listMilestoneItems(rolling.number);
92+
info(`Found ${items.length} item(s) in "${ROLLING_NAME}".`);
93+
94+
let moved = 0;
95+
let skipped = 0;
96+
for (const item of items) {
97+
if (await hasMarker(item.number, versionedName)) {
98+
skipped++;
99+
continue;
100+
}
101+
await octokit.rest.issues.update({
102+
owner,
103+
repo,
104+
issue_number: item.number,
105+
milestone: versioned.number,
106+
});
107+
const tag = `${MARKER_PREFIX}${versionedName} -->`;
108+
await octokit.rest.issues.createComment({
109+
owner,
110+
repo,
111+
issue_number: item.number,
112+
body: `${tag}\nMoved into \`${versionedName}\` as part of the pre-release cut.`,
113+
});
114+
moved++;
115+
}
116+
info(`Migrated ${moved}, skipped ${skipped} (already tagged).`);
117+
}
118+
119+
await main();

‎.github/workflows/build.yml‎

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,15 @@ jobs:
190190
echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT"
191191
192192
- name: Sign DMG
193-
if: steps.signing.outputs.enabled == 'true'
193+
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
194194
run: |
195195
codesign --force \
196196
--sign "${{ secrets.MAC_CSC_NAME }}" \
197197
--timestamp \
198198
"${{ steps.dmg.outputs.dmg_path }}"
199199
200200
- name: Notarize DMG
201-
if: steps.signing.outputs.enabled == 'true'
201+
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
202202
run: |
203203
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
204204
--apple-id "${{ secrets.APPLE_ID }}" \
@@ -208,11 +208,11 @@ jobs:
208208
timeout-minutes: 15
209209

210210
- name: Staple notarization ticket
211-
if: steps.signing.outputs.enabled == 'true'
211+
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
212212
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
213213

214214
- name: Validate stapled DMG
215-
if: steps.signing.outputs.enabled == 'true'
215+
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
216216
run: |
217217
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
218218
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
@@ -286,20 +286,33 @@ jobs:
286286
TAG="${INPUT_TAG}"
287287
fi
288288
289-
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
290-
echo "::error::Release tag must look like v1.5.0; got '${TAG}'"
289+
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then
290+
echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'"
291291
exit 1
292292
fi
293293
294294
VERSION="${TAG#v}"
295+
# Strip pre-release suffix (e.g. "1.5.0-rc.1" -> "1.5.0") for package.json comparison.
296+
STABLE_VERSION="${VERSION%%-*}"
295297
PACKAGE_VERSION="$(node -p 'require("./package.json").version')"
296-
if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then
297-
echo "::error::package.json version ${PACKAGE_VERSION} does not match release tag ${TAG}"
298+
if [[ "$PACKAGE_VERSION" != "$STABLE_VERSION" ]]; then
299+
echo "::error::package.json version ${PACKAGE_VERSION} does not match stable version ${STABLE_VERSION} from tag ${TAG}"
298300
exit 1
299301
fi
300302
303+
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then
304+
PRERELEASE_FLAG="--prerelease"
305+
IS_PRERELEASE="true"
306+
else
307+
PRERELEASE_FLAG=""
308+
IS_PRERELEASE="false"
309+
fi
310+
301311
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
302312
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
313+
echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT"
314+
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
315+
echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT"
303316
304317
- name: Download Windows installer
305318
uses: actions/download-artifact@v4
@@ -329,8 +342,9 @@ jobs:
329342

330343
- name: Publish release assets
331344
env:
332-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
345+
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
333346
TAG: ${{ steps.release.outputs.tag }}
347+
PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }}
334348
run: |
335349
mapfile -t FILES < <(find artifacts -type f | sort)
336350
if [[ "${#FILES[@]}" -eq 0 ]]; then
@@ -341,13 +355,22 @@ jobs:
341355
if gh release view "$TAG" >/dev/null 2>&1; then
342356
gh release upload "$TAG" "${FILES[@]}" --clobber
343357
else
358+
# shellcheck disable=SC2086
344359
gh release create "$TAG" "${FILES[@]}" \
345360
--target "$GITHUB_SHA" \
346361
--title "$TAG" \
347-
--generate-notes
362+
--generate-notes \
363+
$PRERELEASE_FLAG
348364
fi
349365
350-
gh release edit "$TAG" \
351-
--draft=false \
352-
--latest \
353-
--title "$TAG"
366+
if [[ -n "$PRERELEASE_FLAG" ]]; then
367+
gh release edit "$TAG" \
368+
--draft=false \
369+
--latest=false \
370+
--title "$TAG"
371+
else
372+
gh release edit "$TAG" \
373+
--draft=false \
374+
--latest \
375+
--title "$TAG"
376+
fi

0 commit comments

Comments
 (0)