From 1797b25df1c2851d9e6c21e8aaf9335ddbe15d0f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 22 May 2026 12:12:46 -0400 Subject: [PATCH 1/5] fix(actions): harden Linear release workflow Purpose of the change: - Address review feedback on the WebGUI Linear release workflow while keeping the patch minimal. Previous behavior: - The workflow used the mutable actions/checkout@v6 tag. - Tags without a previous semver tag used RANGE_SPEC equal to the tag name, causing git log to walk full ancestry. - PR metadata collection only recognized merge commits shaped as Merge pull request #123. Why that was a problem: - Mutable action tags can be retargeted upstream. - A first-tag release could scan unrelated historical commits and attach unrelated Linear metadata. - Squash-merged PRs with commit messages like Fix thing (#123) would not have their PR body scanned for Linear or FeatureOS links. What this changes: - Pins actions/checkout to the current v6.0.2 commit SHA. - Uses TAG^..TAG for first-tag ranges when a parent exists, with the old tag fallback only for parentless commits. - Extends PR number extraction to include squash-merge (#123) patterns while preserving numeric extraction, sorting, and dedupe. How it works: - The existing PR lookup loop remains unchanged; it receives a broader but still numeric-only PR number list. - The git range emitted by Resolve tag remains bounded before the later git log call uses it. --- .github/workflows/linear-release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index 2667c0182..91c9eb63f 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -31,7 +31,7 @@ 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 }} fetch-depth: 0 @@ -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 @@ -118,7 +120,7 @@ jobs: git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH" PR_NUMBERS="$( - grep -Eo 'Merge pull request #[0-9]+' "$LOG_PATH" \ + grep -Eo 'Merge pull request #[0-9]+|\(#[0-9]+\)' "$LOG_PATH" \ | grep -Eo '[0-9]+' \ | sort -u || true )" From 52cb2f061171ed0b15fdd7963606a4c559f150ba Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 22 May 2026 12:20:07 -0400 Subject: [PATCH 2/5] feat(actions): resolve Linear issues from PR links Purpose of the change: - Support WebGUI release sync when the only issue relationship is stored in Linear as a GitHub PR attachment. Previous behavior: - The workflow scanned commit and PR text for Linear issue IDs and FeatureOS URLs only. - If a PR was linked from Linear but did not mention the Linear ticket in GitHub, release sync could miss the issue. Why that was a problem: - WebGUI release membership would be incomplete for issues where the canonical link lives in Linear's GitHub integration attachments. What this changes: - Records the GitHub PR URL for each PR discovered from the release tag range. - Passes those PR URLs into the Linear sync script. - Resolves matching Linear attachments with attachmentsForURL and attaches the linked issues to the release. - Treats missing PR attachments as normal and avoids noisy skipped entries. How it works: - The existing PR number parsing and PR metadata fetch stay in place. - The script uses the same attachment lookup path for FeatureOS URLs and GitHub PR URLs, then dedupes issues before applying addedReleaseIds. --- .github/scripts/sync-linear-release.mjs | 30 ++++++++++++++++++++----- .github/workflows/linear-release.yml | 8 +++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/scripts/sync-linear-release.mjs b/.github/scripts/sync-linear-release.mjs index 6a927fc58..cab5eada0 100644 --- a/.github/scripts/sync-linear-release.mjs +++ b/.github/scripts/sync-linear-release.mjs @@ -21,6 +21,7 @@ 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 pipelineName = RELEASE_PIPELINE_BY_CHANNEL[releaseChannel]; if (!pipelineName) { @@ -30,11 +31,12 @@ 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 }); +const syncResult = await syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, githubPrUrls }); setOutput("release_id", release.id); setOutput("release_url", release.url || ""); @@ -86,7 +88,7 @@ async function upsertRelease({ pipeline, targetStage }) { return existing; } -async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) { +async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, githubPrUrls }) { const synced = []; const skipped = []; const seenIssueIds = new Set(); @@ -102,7 +104,7 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) } 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; @@ -118,6 +120,22 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) } } + 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 syncIssueToRelease(issue, release, synced, seenIssueIds); + } + } + return { synced, skipped }; } @@ -290,8 +308,8 @@ async function findIssue(identifier) { return data.issue || null; } -async function findIssuesForFeatureOsUrl(url) { - const urls = candidateFeatureOsUrls(url); +async function findIssuesForAttachmentUrl(url) { + const urls = candidateAttachmentUrls(url); const issuesById = new Map(); for (const candidate of urls) { @@ -382,7 +400,7 @@ function readLines(path) { .filter((value, index, values) => values.indexOf(value) === index); } -function candidateFeatureOsUrls(url) { +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 91c9eb63f..63a4e530a 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -112,10 +112,12 @@ 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" : > "$LOG_PATH" : > "$PR_TEXT_PATH" : > "$ISSUE_IDS_PATH" : > "$FEATUREOS_URLS_PATH" + : > "$GITHUB_PR_URLS_PATH" git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH" @@ -126,6 +128,7 @@ jobs: )" for PR_NUMBER in $PR_NUMBERS; do + echo "${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" >> "$GITHUB_PR_URLS_PATH" curl -fsSL \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ @@ -146,8 +149,10 @@ 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 "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 @@ -175,6 +180,7 @@ 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 }} run: node .github/scripts/sync-linear-release.mjs - name: Summarize Linear release @@ -191,6 +197,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' @@ -213,6 +220,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" From 1747c1c19b4b3ec279f79dec26e2cd54020d57a2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 22 May 2026 12:23:45 -0400 Subject: [PATCH 3/5] fix(actions): limit PR extraction to merge commits Purpose of the change: - Address review feedback on the WebGUI Linear release workflow PR parsing. Previous behavior: - PR number extraction matched both explicit merge commits and any parenthetical number shaped like (#123). Why that was a problem: - Ordinary issue references in commit messages could be treated as PR numbers and sent to the GitHub pulls API, causing the workflow to fail. What this changes: - Restricts PR number extraction to explicit Merge pull request #123 commit messages. - Keeps the existing numeric extraction, sort -u dedupe, and || true fallback behavior. How it works: - The first grep now emits only Merge pull request #[0-9]+ matches before the second grep extracts digits for the existing curl loop. --- .github/workflows/linear-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index 63a4e530a..231ab4fd0 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -122,7 +122,7 @@ jobs: git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH" PR_NUMBERS="$( - grep -Eo 'Merge pull request #[0-9]+|\(#[0-9]+\)' "$LOG_PATH" \ + grep -Eo 'Merge pull request #[0-9]+' "$LOG_PATH" \ | grep -Eo '[0-9]+' \ | sort -u || true )" From b869eb794c59508037d16401b1ed7e723bff78c7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 22 May 2026 12:27:44 -0400 Subject: [PATCH 4/5] fix(actions): check out workflow branch for manual release sync Purpose of the change: - Make workflow_dispatch testing work for historical WebGUI release tags. Previous behavior: - Manual runs checked out the requested tag directly. - Historical tags do not contain the new sync script, so the workflow failed with MODULE_NOT_FOUND. Why that was a problem: - The workflow could not be tested against previous tags from the PR branch even though the tag metadata was fetched successfully. What this changes: - Manual workflow_dispatch runs now check out the workflow ref/branch while still resolving and operating on the requested tag_name. - Tag push runs continue to check out the pushed tag. How it works: - The checkout ref uses github.ref for workflow_dispatch and github.ref_name for tag pushes. --- .github/workflows/linear-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index 231ab4fd0..0aafa6bbf 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -33,7 +33,7 @@ jobs: - name: Checkout tag 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 From 019e57bcc6d46fd6b8e2676bccf616a6575d4372 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 22 May 2026 12:36:22 -0400 Subject: [PATCH 5/5] feat(actions): carry QA issues across OS releases Purpose of the change: - Keep active QA work attached to the next prerelease and stable companion release from WebGUI tag sync. Previous behavior: - WebGUI release sync attached discovered Linear issues only to the exact tag release. - Existing QA Ready issues already on a prerelease were not swept into the next planned prerelease. - WebGUI-linked prerelease issues could miss the stable companion release. Why that was a problem: - QA Ready work could fall out of the next release bucket even though it still needed validation. - Stable release planning would not reliably track all work that passed through the prerelease series. What this changes: - Internal/prerelease tag sync now resolves the exact prerelease, stable companion, and next planned prerelease releases. - Active issues are attached to all applicable release buckets. - Completed/internal-released issues are removed from the next planned prerelease while preserving exact prerelease and stable companion memberships. - Existing issues already attached to the exact prerelease are swept through the same policy, even if they were not discovered from the current tag diff. How it works: - The sync script creates or updates the stable companion as Planned and the next prerelease as Planned. - Issue state is loaded from Linear and used to decide whether to carry or remove next-prerelease membership. - Linear issue updates use addedReleaseIds and removedReleaseIds without changing workflow state. --- .github/scripts/sync-linear-release.mjs | 188 +++++++++++++++++++++--- 1 file changed, 168 insertions(+), 20 deletions(-) diff --git a/.github/scripts/sync-linear-release.mjs b/.github/scripts/sync-linear-release.mjs index cab5eada0..9a037f33f 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; @@ -36,7 +38,8 @@ 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, githubPrUrls }); +const relatedReleases = await resolveRelatedReleases(pipeline); +const syncResult = await syncIssuesToRelease(release, relatedReleases, { issueIdentifiers, featureOsUrls, githubPrUrls }); setOutput("release_id", release.id); setOutput("release_url", release.url || ""); @@ -49,13 +52,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"); @@ -63,18 +116,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) { @@ -88,7 +141,7 @@ async function upsertRelease({ pipeline, targetStage }) { return existing; } -async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, githubPrUrls }) { +async function syncIssuesToRelease(release, relatedReleases, { issueIdentifiers, featureOsUrls, githubPrUrls }) { const synced = []; const skipped = []; const seenIssueIds = new Set(); @@ -100,7 +153,7 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, g continue; } - await syncIssueToRelease(issue, release, synced, seenIssueIds); + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); } for (const url of featureOsUrls) { @@ -116,7 +169,7 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, g continue; } - await syncIssueToRelease(issue, release, synced, seenIssueIds); + await syncIssueToReleases(issue, release, relatedReleases, synced, seenIssueIds); } } @@ -132,22 +185,52 @@ async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls, g continue; } - await syncIssueToRelease(issue, release, synced, seenIssueIds); + 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); @@ -296,6 +379,10 @@ async function findIssue(identifier) { id identifier archivedAt + state { + name + type + } releases(first: 50) { nodes { id @@ -308,6 +395,33 @@ async function findIssue(identifier) { return data.issue || null; } +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(); @@ -323,6 +437,10 @@ async function findIssuesForAttachmentUrl(url) { id identifier archivedAt + state { + name + type + } releases(first: 50) { nodes { id @@ -387,6 +505,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));