diff --git a/.github/scripts/sync-linear-release.mjs b/.github/scripts/sync-linear-release.mjs index 6a927fc58c..1a2e65b2bc 100644 --- a/.github/scripts/sync-linear-release.mjs +++ b/.github/scripts/sync-linear-release.mjs @@ -7,10 +7,12 @@ const RELEASE_PIPELINE_BY_CHANNEL = { internal: "OS Prereleases", public: "OS Stable Releases", }; +const STABLE_RELEASE_PIPELINE = "OS Stable Releases"; const TARGET_STAGE_BY_CHANNEL = { internal: "In Progress", public: "Released", }; +const PLANNED_RELEASE_STAGE = "Planned"; const env = process.env; @@ -21,6 +23,9 @@ const tagName = requiredEnv("TAG_NAME"); const tagSha = requiredEnv("TAG_SHA"); const issueIdsPath = requiredEnv("ISSUE_IDS_PATH"); const featureOsUrlsPath = env.FEATUREOS_URLS_PATH; +const githubPrUrlsPath = env.GITHUB_PR_URLS_PATH; +const prSummaryPath = env.PR_SUMMARY_PATH; +const logPath = env.LOG_PATH; const pipelineName = RELEASE_PIPELINE_BY_CHANNEL[releaseChannel]; if (!pipelineName) { @@ -30,11 +35,14 @@ if (!pipelineName) { const targetStageName = TARGET_STAGE_BY_CHANNEL[releaseChannel]; const issueIdentifiers = readIssueIdentifiers(issueIdsPath); const featureOsUrls = featureOsUrlsPath ? readLines(featureOsUrlsPath) : []; +const githubPrUrls = githubPrUrlsPath ? readLines(githubPrUrlsPath) : []; const pipeline = await findReleasePipeline(pipelineName); const targetStage = findStage(pipeline, targetStageName); const release = await upsertRelease({ pipeline, targetStage }); -const syncResult = await syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }); +await syncWebguiReleaseNotes(pipeline, release); +const relatedReleases = await resolveRelatedReleases(pipeline); +const syncResult = await syncIssuesToRelease(release, relatedReleases, { issueIdentifiers, featureOsUrls, githubPrUrls }); setOutput("release_id", release.id); setOutput("release_url", release.url || ""); @@ -47,13 +55,63 @@ console.log(`Synced Linear release ${release.name} (${release.version || tagName console.log(`Attached issues: ${syncResult.synced.length > 0 ? syncResult.synced.join(", ") : "none"}`); console.log(`Skipped issues: ${syncResult.skipped.length > 0 ? syncResult.skipped.join(", ") : "none"}`); -async function upsertRelease({ pipeline, targetStage }) { - const existing = await findRelease(pipeline.id, tagName, releaseName); - const description = [ +async function resolveRelatedReleases(primaryPipeline) { + if (releaseChannel !== "internal") { + return {}; + } + + const stableVersion = stableVersionForPrerelease(tagName); + const stablePipeline = await findReleasePipeline(STABLE_RELEASE_PIPELINE); + const stableRelease = await upsertRelease({ + pipeline: stablePipeline, + targetStage: findStage(stablePipeline, PLANNED_RELEASE_STAGE), + name: `Unraid OS ${stableVersion} Stable`, + version: stableVersion, + description: [ + "Synced from unraid/webgui prerelease tag automation.", + "", + `Prerelease tag: ${tagName}`, + `Prerelease commit: ${tagSha}`, + "Stable companion release tracks work accumulated through the prerelease series.", + ].join("\n"), + commitSha: undefined, + }); + + const nextPrereleaseVersion = nextPrereleaseVersionFor(tagName); + const nextPrereleaseRelease = nextPrereleaseVersion + ? await upsertRelease({ + pipeline: primaryPipeline, + targetStage: findStage(primaryPipeline, PLANNED_RELEASE_STAGE), + name: nextPrereleaseVersion, + version: nextPrereleaseVersion, + description: [ + "Planned next prerelease opened by unraid/webgui tag automation.", + "", + `Created from tag: ${tagName}`, + `Source commit: ${tagSha}`, + ].join("\n"), + commitSha: undefined, + }) + : undefined; + + return { stableRelease, nextPrereleaseRelease }; +} + +async function upsertRelease(options) { + const { + pipeline, + targetStage, + name = releaseName, + version = tagName, + description, + } = options; + const commitSha = Object.prototype.hasOwnProperty.call(options, "commitSha") ? options.commitSha : tagSha; + const existing = await findRelease(pipeline.id, version, name); + const releaseDescription = description || [ "Synced from unraid/webgui tag automation.", "", - `Tag: ${tagName}`, - `Commit: ${tagSha}`, + `Tag: ${version}`, + commitSha ? `Commit: ${commitSha}` : undefined, env.PREVIOUS_TAG ? `Previous tag: ${env.PREVIOUS_TAG}` : undefined, env.RANGE_SPEC ? `Commit range: ${env.RANGE_SPEC}` : undefined, ].filter(Boolean).join("\n"); @@ -61,18 +119,18 @@ async function upsertRelease({ pipeline, targetStage }) { if (!existing) { return createRelease({ pipelineId: pipeline.id, - name: releaseName, - version: tagName, - description, - commitSha: tagSha, + name, + version, + description: releaseDescription, + commitSha, stageId: targetStage.id, }); } const input = { - name: existing.name === releaseName ? undefined : releaseName, - description, - commitSha: existing.commitSha === tagSha ? undefined : tagSha, + name: existing.name === name ? undefined : name, + description: releaseDescription, + commitSha: commitSha && existing.commitSha !== commitSha ? commitSha : undefined, }; if (!isTerminalReleaseStage(existing.stage) && existing.stage?.id !== targetStage.id) { @@ -86,7 +144,7 @@ async function upsertRelease({ pipeline, targetStage }) { return existing; } -async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) { +async function syncIssuesToRelease(release, relatedReleases, { issueIdentifiers, featureOsUrls, githubPrUrls }) { const synced = []; const skipped = []; const seenIssueIds = new Set(); @@ -98,11 +156,11 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) continue; } - await syncIssueToRelease(issue, release, synced, seenIssueIds); + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); } for (const url of featureOsUrls) { - const issues = await findIssuesForFeatureOsUrl(url); + const issues = await findIssuesForAttachmentUrl(url); if (issues.length === 0) { skipped.push(`${url} (no linked Linear issue)`); continue; @@ -114,22 +172,68 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) continue; } - await syncIssueToRelease(issue, release, synced, seenIssueIds); + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); + } + } + + for (const url of githubPrUrls) { + const issues = await findIssuesForAttachmentUrl(url); + if (issues.length === 0) { + continue; + } + + for (const issue of issues) { + if (issue.archivedAt) { + skipped.push(`${issue.identifier} (archived)`); + continue; + } + + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); } } + for (const issue of await findIssuesForRelease(release.id)) { + if (issue.archivedAt) { + continue; + } + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); + } + return { synced, skipped }; } -async function syncIssueToRelease(issue, release, synced, seenIssueIds) { +async function syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds) { if (seenIssueIds.has(issue.id)) { return; } seenIssueIds.add(issue.id); const releaseIds = new Set((issue.releases?.nodes || []).map((item) => item.id)); - if (!releaseIds.has(release.id)) { - await updateIssue(issue.id, { addedReleaseIds: [release.id] }); + const addedReleaseIds = []; + const removedReleaseIds = []; + + for (const targetRelease of [release, relatedReleases.stableRelease]) { + if (targetRelease && !releaseIds.has(targetRelease.id)) { + addedReleaseIds.push(targetRelease.id); + } + } + + if (relatedReleases.nextPrereleaseRelease) { + const nextReleaseId = relatedReleases.nextPrereleaseRelease.id; + if (shouldCarryIssueToNextPrerelease(issue)) { + if (!releaseIds.has(nextReleaseId)) { + addedReleaseIds.push(nextReleaseId); + } + } else if (releaseIds.has(nextReleaseId)) { + removedReleaseIds.push(nextReleaseId); + } + } + + if (addedReleaseIds.length > 0 || removedReleaseIds.length > 0) { + await updateIssue(issue.id, dropUndefined({ + addedReleaseIds: addedReleaseIds.length > 0 ? addedReleaseIds : undefined, + removedReleaseIds: removedReleaseIds.length > 0 ? removedReleaseIds : undefined, + })); } synced.push(issue.identifier); @@ -203,6 +307,13 @@ async function findRelease(pipelineId, version, name) { name type } + releaseNotes { + id + title + documentContent { + content + } + } } } } @@ -230,6 +341,13 @@ async function createRelease(input) { name type } + releaseNotes { + id + title + documentContent { + content + } + } } } } @@ -259,6 +377,13 @@ async function updateRelease(id, input) { name type } + releaseNotes { + id + title + documentContent { + content + } + } } } } @@ -271,6 +396,124 @@ async function updateRelease(id, input) { return data.releaseUpdate.release; } +async function syncWebguiReleaseNotes(pipeline, release) { + const content = buildWebguiReleaseNotes(); + if (!content || !release?.id) { + return; + } + + const title = `Version ${tagName}`; + const existingNote = findReleaseNote(release, title); + const nextContent = renderManagedSection( + existingNote?.documentContent?.content || "", + "notification-worker-webgui-release-notes", + title, + content, + ); + + if (existingNote?.id) { + if (nextContent !== (existingNote.documentContent?.content || "") || existingNote.title !== title) { + await updateReleaseNote(existingNote.id, { + releaseId: release.id, + title, + content: nextContent, + }); + } + return; + } + + await createReleaseNote({ + pipelineId: pipeline.id, + releaseId: release.id, + title, + content: nextContent, + }); +} + +function buildWebguiReleaseNotes() { + const prSummaries = prSummaryPath ? readOptionalLines(prSummaryPath) : []; + const commitSubjects = logPath ? readCommitSubjects(logPath) : []; + const metadata = [ + `Tag: \`${tagName}\``, + `Commit: \`${tagSha}\``, + env.PREVIOUS_TAG ? `Previous tag: \`${env.PREVIOUS_TAG}\`` : undefined, + env.RANGE_SPEC ? `Commit range: \`${env.RANGE_SPEC}\`` : undefined, + ].filter(Boolean); + const sections = [["## Release Metadata", ...metadata]]; + + if (prSummaries.length > 0) { + sections.push(["## WebGUI Pull Requests", ...prSummaries]); + } + if (issueIdentifiers.length > 0) { + sections.push(["## Linked Linear Issues", ...issueIdentifiers.map((id) => `- ${id}`)]); + } + if (featureOsUrls.length > 0) { + sections.push(["## Linked FeatureOS Posts", ...featureOsUrls.map((url) => `- ${url}`)]); + } + if (prSummaries.length === 0 && commitSubjects.length > 0) { + sections.push(["## Commit Summary", ...commitSubjects.slice(0, 25).map((subject) => `- ${subject}`)]); + } + + return sections.map((section) => section.join("\n")).join("\n\n").trim(); +} + +function findReleaseNote(release, title) { + const normalizedTitle = title.trim().toLowerCase(); + const marker = managedSectionStartMarker("notification-worker-webgui-release-notes", title); + return (release.releaseNotes || []).find((note) => (note.title || "").trim().toLowerCase() === normalizedTitle) + || (release.releaseNotes || []).find((note) => (note.documentContent?.content || "").includes(marker)); +} + +async function createReleaseNote(input) { + const data = await graphql(` + mutation CreateReleaseNote($input: ReleaseNoteCreateInput!) { + releaseNoteCreate(input: $input) { + success + releaseNote { + id + title + } + } + } + `, { + input: dropUndefined({ + pipelineId: input.pipelineId, + releaseIds: [input.releaseId], + title: input.title, + content: input.content, + }), + }); + + if (!data.releaseNoteCreate.success) { + throw new Error(`Linear release note create failed for ${input.releaseId}`); + } +} + +async function updateReleaseNote(id, input) { + const data = await graphql(` + mutation UpdateReleaseNote($id: String!, $input: ReleaseNoteUpdateInput!) { + releaseNoteUpdate(id: $id, input: $input) { + success + releaseNote { + id + title + } + } + } + `, { + id, + input: dropUndefined({ + releaseIds: [input.releaseId], + title: input.title, + content: input.content, + }), + }); + + if (!data.releaseNoteUpdate.success) { + throw new Error(`Linear release note update failed for ${id}`); + } +} + async function findIssue(identifier) { const data = await graphql(` query FindIssue($id: String!) { @@ -278,6 +521,10 @@ async function findIssue(identifier) { id identifier archivedAt + state { + name + type + } releases(first: 50) { nodes { id @@ -290,8 +537,35 @@ async function findIssue(identifier) { return data.issue || null; } -async function findIssuesForFeatureOsUrl(url) { - const urls = candidateFeatureOsUrls(url); +async function findIssuesForRelease(releaseId) { + const data = await graphql(` + query FindIssuesForRelease($id: String!) { + release(id: $id) { + issues(first: 100) { + nodes { + id + identifier + archivedAt + state { + name + type + } + releases(first: 50) { + nodes { + id + } + } + } + } + } + } + `, { id: releaseId }); + + return data.release?.issues?.nodes || []; +} + +async function findIssuesForAttachmentUrl(url) { + const urls = candidateAttachmentUrls(url); const issuesById = new Map(); for (const candidate of urls) { @@ -305,6 +579,10 @@ async function findIssuesForFeatureOsUrl(url) { id identifier archivedAt + state { + name + type + } releases(first: 50) { nodes { id @@ -369,6 +647,36 @@ function isTerminalReleaseStage(stage) { return type === "completed" || type === "canceled" || name === "released" || name === "canceled"; } +function shouldCarryIssueToNextPrerelease(issue) { + const stateName = (issue.state?.name || "").trim().toLowerCase(); + if (new Set([ + "internal release", + "internal validated", + "public release", + "released", + "canceled", + "cancelled", + "duplicate", + ]).has(stateName)) { + return false; + } + + const stateType = (issue.state?.type || "").trim().toLowerCase(); + return stateType !== "completed" && stateType !== "canceled"; +} + +function stableVersionForPrerelease(version) { + return version.split("-")[0]; +} + +function nextPrereleaseVersionFor(version) { + const match = version.match(/^(.+-)(\d+)$/); + if (!match) { + return undefined; + } + return `${match[1]}${Number(match[2]) + 1}`; +} + function readIssueIdentifiers(path) { return readLines(path) .filter((value) => /^[A-Z][A-Z0-9]+-[0-9]+$/.test(value)); @@ -382,7 +690,68 @@ function readLines(path) { .filter((value, index, values) => values.indexOf(value) === index); } -function candidateFeatureOsUrls(url) { +function readOptionalLines(path) { + try { + return readLines(path); + } catch { + return []; + } +} + +function readCommitSubjects(path) { + try { + return readFileSync(path, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("Merge pull request #")) + .filter((value, index, values) => values.indexOf(value) === index); + } catch { + return []; + } +} + +function renderManagedSection(content, markerPrefix, title, body) { + const normalizedTitle = title.trim() || "Release Notes"; + const normalizedBody = body.trim(); + if (!normalizedBody) { + return content; + } + + const startMarker = managedSectionStartMarker(markerPrefix, normalizedTitle); + const endMarker = ``; + const section = [ + startMarker, + `# ${normalizedTitle}`, + "", + normalizedBody, + endMarker, + ].join("\n").trim(); + const existing = content.trim(); + const pattern = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`, "m"); + if (pattern.test(existing)) { + return existing.replace(pattern, section).trim(); + } + + return [existing, section].filter(Boolean).join("\n\n").trim(); +} + +function managedSectionStartMarker(markerPrefix, title) { + return ``; +} + +function stableMarkerHash(value) { + let hash = 5381; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(index); + } + return (hash >>> 0).toString(16); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function candidateAttachmentUrls(url) { const candidates = new Set([url]); try { const parsed = new URL(url); diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index 2667c01825..8d09531667 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -31,9 +31,9 @@ jobs: steps: - name: Checkout tag - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.ref_name }} fetch-depth: 0 - name: Resolve tag @@ -84,6 +84,8 @@ jobs: if [ -n "$PREVIOUS_TAG" ]; then RANGE_SPEC="${PREVIOUS_TAG}..${TAG_NAME}" + elif git rev-parse -q --verify "${TAG_NAME}^" >/dev/null; then + RANGE_SPEC="${TAG_NAME}^..${TAG_NAME}" else RANGE_SPEC="$TAG_NAME" fi @@ -110,10 +112,14 @@ jobs: PR_TEXT_PATH="${RUNNER_TEMP}/linear-release-pr-text.txt" ISSUE_IDS_PATH="${RUNNER_TEMP}/linear-release-issue-ids.txt" FEATUREOS_URLS_PATH="${RUNNER_TEMP}/linear-release-featureos-urls.txt" + GITHUB_PR_URLS_PATH="${RUNNER_TEMP}/linear-release-github-pr-urls.txt" + PR_SUMMARY_PATH="${RUNNER_TEMP}/linear-release-pr-summary.txt" : > "$LOG_PATH" : > "$PR_TEXT_PATH" : > "$ISSUE_IDS_PATH" : > "$FEATUREOS_URLS_PATH" + : > "$GITHUB_PR_URLS_PATH" + : > "$PR_SUMMARY_PATH" git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH" @@ -124,13 +130,24 @@ jobs: )" for PR_NUMBER in $PR_NUMBERS; do + PR_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + PR_JSON_PATH="$(mktemp)" + echo "${PR_URL}" >> "$GITHUB_PR_URLS_PATH" curl -fsSL \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "${GITHUB_API_URL:-https://api.github.com}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - | jq -r '[.title, .body, .head.ref, .base.ref] | map(select(. != null and . != "")) | .[]' \ - >> "$PR_TEXT_PATH" + > "$PR_JSON_PATH" + jq -r '[.title, .body, .head.ref, .base.ref] | map(select(. != null and . != "")) | .[]' \ + "$PR_JSON_PATH" >> "$PR_TEXT_PATH" + PR_TITLE="$(jq -r '.title // ""' "$PR_JSON_PATH")" + if [ -n "$PR_TITLE" ]; then + printf -- '- [#%s %s](%s)\n' "$PR_NUMBER" "$PR_TITLE" "$PR_URL" >> "$PR_SUMMARY_PATH" + else + printf -- '- [#%s](%s)\n' "$PR_NUMBER" "$PR_URL" >> "$PR_SUMMARY_PATH" + fi + rm -f "$PR_JSON_PATH" done { @@ -144,8 +161,12 @@ jobs: { echo "issue_ids_path=${ISSUE_IDS_PATH}" echo "featureos_urls_path=${FEATUREOS_URLS_PATH}" + echo "github_pr_urls_path=${GITHUB_PR_URLS_PATH}" + echo "pr_summary_path=${PR_SUMMARY_PATH}" + echo "log_path=${LOG_PATH}" echo "issue_count=$(wc -l < "$ISSUE_IDS_PATH" | tr -d ' ')" echo "featureos_url_count=$(wc -l < "$FEATUREOS_URLS_PATH" | tr -d ' ')" + echo "github_pr_url_count=$(wc -l < "$GITHUB_PR_URLS_PATH" | tr -d ' ')" } >> "$GITHUB_OUTPUT" - name: Validate Linear API key @@ -173,6 +194,9 @@ jobs: RANGE_SPEC: ${{ steps.tag.outputs.range_spec }} ISSUE_IDS_PATH: ${{ steps.issues.outputs.issue_ids_path }} FEATUREOS_URLS_PATH: ${{ steps.issues.outputs.featureos_urls_path }} + GITHUB_PR_URLS_PATH: ${{ steps.issues.outputs.github_pr_urls_path }} + PR_SUMMARY_PATH: ${{ steps.issues.outputs.pr_summary_path }} + LOG_PATH: ${{ steps.issues.outputs.log_path }} run: node .github/scripts/sync-linear-release.mjs - name: Summarize Linear release @@ -189,6 +213,7 @@ jobs: SKIPPED_ISSUES: ${{ steps.sync.outputs.skipped_issue_identifiers }} ISSUE_COUNT: ${{ steps.issues.outputs.issue_count }} FEATUREOS_URL_COUNT: ${{ steps.issues.outputs.featureos_url_count }} + GITHUB_PR_URL_COUNT: ${{ steps.issues.outputs.github_pr_url_count }} run: | set -Eeuo pipefail IFS=$'\n\t' @@ -211,6 +236,7 @@ jobs: echo "- Linear issue IDs found: \`${ISSUE_COUNT:-0}\`" echo "- FeatureOS links found: \`${FEATUREOS_URL_COUNT:-0}\`" + echo "- GitHub PR links checked in Linear: \`${GITHUB_PR_URL_COUNT:-0}\`" echo "- Issues attached: ${SYNCED_ISSUES:-none}" echo "- Issues skipped: ${SKIPPED_ISSUES:-none}" } >> "$GITHUB_STEP_SUMMARY"