Skip to content

Commit de06bf8

Browse files
committed
feat(actions): generate Linear webgui release notes
- Purpose: let the webgui tag workflow own user-facing Linear release notes for the active tag.\n- Before: the workflow synced Linear releases and issue membership but did not write release notes from the tag's commit or PR range.\n- Problem: stable release notes were being filled by release-diff internals instead of webgui-facing release context.\n- New behavior: the workflow records PR summaries, linked Linear IDs, FeatureOS links, and commit metadata, then syncs an idempotent Version <tag> release note.\n- How it works: the collection step emits PR summary and log paths, and the Node sync script creates or updates a marker-managed Linear release note attached to the active tag release.
1 parent 019e57b commit de06bf8

2 files changed

Lines changed: 222 additions & 3 deletions

File tree

.github/scripts/sync-linear-release.mjs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const tagSha = requiredEnv("TAG_SHA");
2424
const issueIdsPath = requiredEnv("ISSUE_IDS_PATH");
2525
const featureOsUrlsPath = env.FEATUREOS_URLS_PATH;
2626
const githubPrUrlsPath = env.GITHUB_PR_URLS_PATH;
27+
const prSummaryPath = env.PR_SUMMARY_PATH;
28+
const logPath = env.LOG_PATH;
2729

2830
const pipelineName = RELEASE_PIPELINE_BY_CHANNEL[releaseChannel];
2931
if (!pipelineName) {
@@ -38,6 +40,7 @@ const githubPrUrls = githubPrUrlsPath ? readLines(githubPrUrlsPath) : [];
3840
const pipeline = await findReleasePipeline(pipelineName);
3941
const targetStage = findStage(pipeline, targetStageName);
4042
const release = await upsertRelease({ pipeline, targetStage });
43+
await syncWebguiReleaseNotes(pipeline, release);
4144
const relatedReleases = await resolveRelatedReleases(pipeline);
4245
const syncResult = await syncIssuesToRelease(release, relatedReleases, { issueIdentifiers, featureOsUrls, githubPrUrls });
4346

@@ -304,6 +307,13 @@ async function findRelease(pipelineId, version, name) {
304307
name
305308
type
306309
}
310+
releaseNotes {
311+
id
312+
title
313+
documentContent {
314+
content
315+
}
316+
}
307317
}
308318
}
309319
}
@@ -331,6 +341,13 @@ async function createRelease(input) {
331341
name
332342
type
333343
}
344+
releaseNotes {
345+
id
346+
title
347+
documentContent {
348+
content
349+
}
350+
}
334351
}
335352
}
336353
}
@@ -360,6 +377,13 @@ async function updateRelease(id, input) {
360377
name
361378
type
362379
}
380+
releaseNotes {
381+
id
382+
title
383+
documentContent {
384+
content
385+
}
386+
}
363387
}
364388
}
365389
}
@@ -372,6 +396,124 @@ async function updateRelease(id, input) {
372396
return data.releaseUpdate.release;
373397
}
374398

399+
async function syncWebguiReleaseNotes(pipeline, release) {
400+
const content = buildWebguiReleaseNotes();
401+
if (!content || !release?.id) {
402+
return;
403+
}
404+
405+
const title = `Version ${tagName}`;
406+
const existingNote = findReleaseNote(release, title);
407+
const nextContent = renderManagedSection(
408+
existingNote?.documentContent?.content || "",
409+
"notification-worker-webgui-release-notes",
410+
title,
411+
content,
412+
);
413+
414+
if (existingNote?.id) {
415+
if (nextContent !== (existingNote.documentContent?.content || "") || existingNote.title !== title) {
416+
await updateReleaseNote(existingNote.id, {
417+
releaseId: release.id,
418+
title,
419+
content: nextContent,
420+
});
421+
}
422+
return;
423+
}
424+
425+
await createReleaseNote({
426+
pipelineId: pipeline.id,
427+
releaseId: release.id,
428+
title,
429+
content: nextContent,
430+
});
431+
}
432+
433+
function buildWebguiReleaseNotes() {
434+
const prSummaries = prSummaryPath ? readOptionalLines(prSummaryPath) : [];
435+
const commitSubjects = logPath ? readCommitSubjects(logPath) : [];
436+
const metadata = [
437+
`Tag: \`${tagName}\``,
438+
`Commit: \`${tagSha}\``,
439+
env.PREVIOUS_TAG ? `Previous tag: \`${env.PREVIOUS_TAG}\`` : undefined,
440+
env.RANGE_SPEC ? `Commit range: \`${env.RANGE_SPEC}\`` : undefined,
441+
].filter(Boolean);
442+
const sections = [["## Release Metadata", ...metadata]];
443+
444+
if (prSummaries.length > 0) {
445+
sections.push(["## WebGUI Pull Requests", ...prSummaries]);
446+
}
447+
if (issueIdentifiers.length > 0) {
448+
sections.push(["## Linked Linear Issues", ...issueIdentifiers.map((id) => `- ${id}`)]);
449+
}
450+
if (featureOsUrls.length > 0) {
451+
sections.push(["## Linked FeatureOS Posts", ...featureOsUrls.map((url) => `- ${url}`)]);
452+
}
453+
if (prSummaries.length === 0 && commitSubjects.length > 0) {
454+
sections.push(["## Commit Summary", ...commitSubjects.slice(0, 25).map((subject) => `- ${subject}`)]);
455+
}
456+
457+
return sections.map((section) => section.join("\n")).join("\n\n").trim();
458+
}
459+
460+
function findReleaseNote(release, title) {
461+
const normalizedTitle = title.trim().toLowerCase();
462+
const marker = managedSectionStartMarker("notification-worker-webgui-release-notes", title);
463+
return (release.releaseNotes || []).find((note) => (note.title || "").trim().toLowerCase() === normalizedTitle)
464+
|| (release.releaseNotes || []).find((note) => (note.documentContent?.content || "").includes(marker));
465+
}
466+
467+
async function createReleaseNote(input) {
468+
const data = await graphql(`
469+
mutation CreateReleaseNote($input: ReleaseNoteCreateInput!) {
470+
releaseNoteCreate(input: $input) {
471+
success
472+
releaseNote {
473+
id
474+
title
475+
}
476+
}
477+
}
478+
`, {
479+
input: dropUndefined({
480+
pipelineId: input.pipelineId,
481+
releaseIds: [input.releaseId],
482+
title: input.title,
483+
content: input.content,
484+
}),
485+
});
486+
487+
if (!data.releaseNoteCreate.success) {
488+
throw new Error(`Linear release note create failed for ${input.releaseId}`);
489+
}
490+
}
491+
492+
async function updateReleaseNote(id, input) {
493+
const data = await graphql(`
494+
mutation UpdateReleaseNote($id: String!, $input: ReleaseNoteUpdateInput!) {
495+
releaseNoteUpdate(id: $id, input: $input) {
496+
success
497+
releaseNote {
498+
id
499+
title
500+
}
501+
}
502+
}
503+
`, {
504+
id,
505+
input: dropUndefined({
506+
releaseIds: [input.releaseId],
507+
title: input.title,
508+
content: input.content,
509+
}),
510+
});
511+
512+
if (!data.releaseNoteUpdate.success) {
513+
throw new Error(`Linear release note update failed for ${id}`);
514+
}
515+
}
516+
375517
async function findIssue(identifier) {
376518
const data = await graphql(`
377519
query FindIssue($id: String!) {
@@ -548,6 +690,67 @@ function readLines(path) {
548690
.filter((value, index, values) => values.indexOf(value) === index);
549691
}
550692

693+
function readOptionalLines(path) {
694+
try {
695+
return readLines(path);
696+
} catch {
697+
return [];
698+
}
699+
}
700+
701+
function readCommitSubjects(path) {
702+
try {
703+
return readFileSync(path, "utf8")
704+
.split(/\r?\n/)
705+
.map((line) => line.trim())
706+
.filter((line) => line && !line.startsWith("Merge pull request #"))
707+
.filter((value, index, values) => values.indexOf(value) === index);
708+
} catch {
709+
return [];
710+
}
711+
}
712+
713+
function renderManagedSection(content, markerPrefix, title, body) {
714+
const normalizedTitle = title.trim() || "Release Notes";
715+
const normalizedBody = body.trim();
716+
if (!normalizedBody) {
717+
return content;
718+
}
719+
720+
const startMarker = managedSectionStartMarker(markerPrefix, normalizedTitle);
721+
const endMarker = `<!-- ${markerPrefix}:end:${stableMarkerHash(normalizedTitle)} -->`;
722+
const section = [
723+
startMarker,
724+
`# ${normalizedTitle}`,
725+
"",
726+
normalizedBody,
727+
endMarker,
728+
].join("\n").trim();
729+
const existing = content.trim();
730+
const pattern = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`, "m");
731+
if (pattern.test(existing)) {
732+
return existing.replace(pattern, section).trim();
733+
}
734+
735+
return [existing, section].filter(Boolean).join("\n\n").trim();
736+
}
737+
738+
function managedSectionStartMarker(markerPrefix, title) {
739+
return `<!-- ${markerPrefix}:start:${stableMarkerHash(title.trim() || "Release Notes")} -->`;
740+
}
741+
742+
function stableMarkerHash(value) {
743+
let hash = 5381;
744+
for (let index = 0; index < value.length; index += 1) {
745+
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
746+
}
747+
return (hash >>> 0).toString(16);
748+
}
749+
750+
function escapeRegExp(value) {
751+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
752+
}
753+
551754
function candidateAttachmentUrls(url) {
552755
const candidates = new Set([url]);
553756
try {

.github/workflows/linear-release.yml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,13 @@ jobs:
113113
ISSUE_IDS_PATH="${RUNNER_TEMP}/linear-release-issue-ids.txt"
114114
FEATUREOS_URLS_PATH="${RUNNER_TEMP}/linear-release-featureos-urls.txt"
115115
GITHUB_PR_URLS_PATH="${RUNNER_TEMP}/linear-release-github-pr-urls.txt"
116+
PR_SUMMARY_PATH="${RUNNER_TEMP}/linear-release-pr-summary.txt"
116117
: > "$LOG_PATH"
117118
: > "$PR_TEXT_PATH"
118119
: > "$ISSUE_IDS_PATH"
119120
: > "$FEATUREOS_URLS_PATH"
120121
: > "$GITHUB_PR_URLS_PATH"
122+
: > "$PR_SUMMARY_PATH"
121123
122124
git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH"
123125
@@ -128,14 +130,24 @@ jobs:
128130
)"
129131
130132
for PR_NUMBER in $PR_NUMBERS; do
131-
echo "${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" >> "$GITHUB_PR_URLS_PATH"
133+
PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
134+
PR_JSON_PATH="$(mktemp)"
135+
echo "${PR_URL}" >> "$GITHUB_PR_URLS_PATH"
132136
curl -fsSL \
133137
-H "Accept: application/vnd.github+json" \
134138
-H "Authorization: Bearer ${GH_TOKEN}" \
135139
-H "X-GitHub-Api-Version: 2022-11-28" \
136140
"${GITHUB_API_URL:-https://api.github.com}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
137-
| jq -r '[.title, .body, .head.ref, .base.ref] | map(select(. != null and . != "")) | .[]' \
138-
>> "$PR_TEXT_PATH"
141+
> "$PR_JSON_PATH"
142+
jq -r '[.title, .body, .head.ref, .base.ref] | map(select(. != null and . != "")) | .[]' \
143+
"$PR_JSON_PATH" >> "$PR_TEXT_PATH"
144+
PR_TITLE="$(jq -r '.title // ""' "$PR_JSON_PATH")"
145+
if [ -n "$PR_TITLE" ]; then
146+
printf -- '- [#%s %s](%s)\n' "$PR_NUMBER" "$PR_TITLE" "$PR_URL" >> "$PR_SUMMARY_PATH"
147+
else
148+
printf -- '- [#%s](%s)\n' "$PR_NUMBER" "$PR_URL" >> "$PR_SUMMARY_PATH"
149+
fi
150+
rm -f "$PR_JSON_PATH"
139151
done
140152
141153
{
@@ -150,6 +162,8 @@ jobs:
150162
echo "issue_ids_path=${ISSUE_IDS_PATH}"
151163
echo "featureos_urls_path=${FEATUREOS_URLS_PATH}"
152164
echo "github_pr_urls_path=${GITHUB_PR_URLS_PATH}"
165+
echo "pr_summary_path=${PR_SUMMARY_PATH}"
166+
echo "log_path=${LOG_PATH}"
153167
echo "issue_count=$(wc -l < "$ISSUE_IDS_PATH" | tr -d ' ')"
154168
echo "featureos_url_count=$(wc -l < "$FEATUREOS_URLS_PATH" | tr -d ' ')"
155169
echo "github_pr_url_count=$(wc -l < "$GITHUB_PR_URLS_PATH" | tr -d ' ')"
@@ -181,6 +195,8 @@ jobs:
181195
ISSUE_IDS_PATH: ${{ steps.issues.outputs.issue_ids_path }}
182196
FEATUREOS_URLS_PATH: ${{ steps.issues.outputs.featureos_urls_path }}
183197
GITHUB_PR_URLS_PATH: ${{ steps.issues.outputs.github_pr_urls_path }}
198+
PR_SUMMARY_PATH: ${{ steps.issues.outputs.pr_summary_path }}
199+
LOG_PATH: ${{ steps.issues.outputs.log_path }}
184200
run: node .github/scripts/sync-linear-release.mjs
185201

186202
- name: Summarize Linear release

0 commit comments

Comments
 (0)