diff --git a/.github/scripts/sync-linear-release.mjs b/.github/scripts/sync-linear-release.mjs new file mode 100644 index 0000000000..6a927fc58c --- /dev/null +++ b/.github/scripts/sync-linear-release.mjs @@ -0,0 +1,415 @@ +#!/usr/bin/env node + +import { readFileSync, appendFileSync } from "node:fs"; + +const GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; +const RELEASE_PIPELINE_BY_CHANNEL = { + internal: "OS Prereleases", + public: "OS Stable Releases", +}; +const TARGET_STAGE_BY_CHANNEL = { + internal: "In Progress", + public: "Released", +}; + +const env = process.env; + +const linearApiKey = requiredEnv("LINEAR_API_KEY"); +const releaseChannel = requiredEnv("RELEASE_CHANNEL"); +const releaseName = requiredEnv("RELEASE_NAME"); +const tagName = requiredEnv("TAG_NAME"); +const tagSha = requiredEnv("TAG_SHA"); +const issueIdsPath = requiredEnv("ISSUE_IDS_PATH"); +const featureOsUrlsPath = env.FEATUREOS_URLS_PATH; + +const pipelineName = RELEASE_PIPELINE_BY_CHANNEL[releaseChannel]; +if (!pipelineName) { + throw new Error(`Unsupported release channel: ${releaseChannel}`); +} + +const targetStageName = TARGET_STAGE_BY_CHANNEL[releaseChannel]; +const issueIdentifiers = readIssueIdentifiers(issueIdsPath); +const featureOsUrls = featureOsUrlsPath ? readLines(featureOsUrlsPath) : []; + +const pipeline = await findReleasePipeline(pipelineName); +const targetStage = findStage(pipeline, targetStageName); +const release = await upsertRelease({ pipeline, targetStage }); +const syncResult = await syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }); + +setOutput("release_id", release.id); +setOutput("release_url", release.url || ""); +setOutput("release_name", release.name || releaseName); +setOutput("release_version", release.version || tagName); +setOutput("synced_issue_identifiers", syncResult.synced.length > 0 ? syncResult.synced.join(", ") : "none"); +setOutput("skipped_issue_identifiers", syncResult.skipped.length > 0 ? syncResult.skipped.join(", ") : "none"); + +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 = [ + "Synced from unraid/webgui tag automation.", + "", + `Tag: ${tagName}`, + `Commit: ${tagSha}`, + env.PREVIOUS_TAG ? `Previous tag: ${env.PREVIOUS_TAG}` : undefined, + env.RANGE_SPEC ? `Commit range: ${env.RANGE_SPEC}` : undefined, + ].filter(Boolean).join("\n"); + + if (!existing) { + return createRelease({ + pipelineId: pipeline.id, + name: releaseName, + version: tagName, + description, + commitSha: tagSha, + stageId: targetStage.id, + }); + } + + const input = { + name: existing.name === releaseName ? undefined : releaseName, + description, + commitSha: existing.commitSha === tagSha ? undefined : tagSha, + }; + + if (!isTerminalReleaseStage(existing.stage) && existing.stage?.id !== targetStage.id) { + input.stageId = targetStage.id; + } + + if (Object.values(input).some((value) => value !== undefined)) { + return updateRelease(existing.id, input); + } + + return existing; +} + +async function syncIssuesToRelease(release, { issueIdentifiers, featureOsUrls }) { + const synced = []; + const skipped = []; + const seenIssueIds = new Set(); + + for (const identifier of issueIdentifiers) { + const issue = await findIssue(identifier); + if (!issue || issue.archivedAt) { + skipped.push(`${identifier} (not found)`); + continue; + } + + await syncIssueToRelease(issue, release, synced, seenIssueIds); + } + + for (const url of featureOsUrls) { + const issues = await findIssuesForFeatureOsUrl(url); + if (issues.length === 0) { + skipped.push(`${url} (no linked Linear issue)`); + continue; + } + + for (const issue of issues) { + if (issue.archivedAt) { + skipped.push(`${issue.identifier} (archived)`); + continue; + } + + await syncIssueToRelease(issue, release, synced, seenIssueIds); + } + } + + return { synced, skipped }; +} + +async function syncIssueToRelease(issue, release, 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] }); + } + + synced.push(issue.identifier); +} + +async function findReleasePipeline(name) { + const data = await graphql(` + query ListReleasePipelines { + releasePipelines(first: 50, includeArchived: false) { + nodes { + id + name + slugId + url + stages(first: 50, includeArchived: false) { + nodes { + id + name + type + } + } + } + } + } + `); + + const pipeline = data.releasePipelines.nodes.find((item) => item.name === name || item.slugId === name); + if (!pipeline) { + throw new Error(`Linear release pipeline not found: ${name}`); + } + + return pipeline; +} + +function findStage(pipeline, name) { + const stage = pipeline.stages.nodes.find((item) => item.name === name); + if (!stage) { + throw new Error(`Linear release stage not found in ${pipeline.name}: ${name}`); + } + + return stage; +} + +async function findRelease(pipelineId, version, name) { + const data = await graphql(` + query FindRelease($pipelineId: ID!, $version: String!, $name: String!) { + releases( + first: 20 + includeArchived: false + filter: { + and: [ + { pipeline: { id: { eq: $pipelineId } } } + { + or: [ + { version: { eq: $version } } + { name: { eq: $name } } + ] + } + ] + } + ) { + nodes { + id + name + version + description + commitSha + url + stage { + id + name + type + } + } + } + } + `, { pipelineId, version, name }); + + return data.releases.nodes.find((release) => release.version === version) + || data.releases.nodes.find((release) => release.name === name) + || null; +} + +async function createRelease(input) { + const data = await graphql(` + mutation CreateRelease($input: ReleaseCreateInput!) { + releaseCreate(input: $input) { + success + release { + id + name + version + description + commitSha + url + stage { + id + name + type + } + } + } + } + `, { input }); + + if (!data.releaseCreate.success || !data.releaseCreate.release) { + throw new Error(`Linear release create failed for ${input.name}`); + } + + return data.releaseCreate.release; +} + +async function updateRelease(id, input) { + const data = await graphql(` + mutation UpdateRelease($id: String!, $input: ReleaseUpdateInput!) { + releaseUpdate(id: $id, input: $input) { + success + release { + id + name + version + description + commitSha + url + stage { + id + name + type + } + } + } + } + `, { id, input: dropUndefined(input) }); + + if (!data.releaseUpdate.success || !data.releaseUpdate.release) { + throw new Error(`Linear release update failed for ${id}`); + } + + return data.releaseUpdate.release; +} + +async function findIssue(identifier) { + const data = await graphql(` + query FindIssue($id: String!) { + issue(id: $id) { + id + identifier + archivedAt + releases(first: 50) { + nodes { + id + } + } + } + } + `, { id: identifier }); + + return data.issue || null; +} + +async function findIssuesForFeatureOsUrl(url) { + const urls = candidateFeatureOsUrls(url); + const issuesById = new Map(); + + for (const candidate of urls) { + const data = await graphql(` + query FindAttachmentsForUrl($url: String!) { + attachmentsForURL(url: $url, first: 20, includeArchived: false) { + nodes { + id + url + issue { + id + identifier + archivedAt + releases(first: 50) { + nodes { + id + } + } + } + } + } + } + `, { url: candidate }); + + for (const attachment of data.attachmentsForURL.nodes || []) { + if (attachment.issue?.id) { + issuesById.set(attachment.issue.id, attachment.issue); + } + } + } + + return [...issuesById.values()]; +} + +async function updateIssue(id, input) { + const data = await graphql(` + mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + id + identifier + } + } + } + `, { id, input }); + + if (!data.issueUpdate.success) { + throw new Error(`Linear issue update failed for ${id}`); + } +} + +async function graphql(query, variables = {}) { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "Authorization": linearApiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + const payload = await response.json(); + + if (!response.ok || payload.errors) { + const message = payload.errors?.map((error) => error.message).join("; ") || response.statusText; + throw new Error(`Linear GraphQL request failed: ${message}`); + } + + return payload.data; +} + +function isTerminalReleaseStage(stage) { + const type = (stage?.type || "").toLowerCase(); + const name = (stage?.name || "").toLowerCase(); + return type === "completed" || type === "canceled" || name === "released" || name === "canceled"; +} + +function readIssueIdentifiers(path) { + return readLines(path) + .filter((value) => /^[A-Z][A-Z0-9]+-[0-9]+$/.test(value)); +} + +function readLines(path) { + return readFileSync(path, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((value, index, values) => values.indexOf(value) === index); +} + +function candidateFeatureOsUrls(url) { + const candidates = new Set([url]); + try { + const parsed = new URL(url); + parsed.search = ""; + parsed.hash = ""; + candidates.add(parsed.toString()); + candidates.add(parsed.toString().replace(/\/$/, "")); + } catch { + // Keep the original raw URL when parsing fails. + } + return [...candidates].filter(Boolean); +} + +function requiredEnv(name) { + const value = env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function setOutput(name, value) { + if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`); + } +} + +function dropUndefined(input) { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); +} diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index a95f926d0a..2667c01825 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -15,6 +15,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: linear-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} @@ -30,7 +31,7 @@ jobs: steps: - name: Checkout tag - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} fetch-depth: 0 @@ -41,6 +42,8 @@ jobs: set -Eeuo pipefail IFS=$'\n\t' + git fetch --force --tags origin + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then TAG_NAME="${{ inputs.tag_name }}" else @@ -59,101 +62,142 @@ jobs: if [[ "$TAG_NAME" == *"-"* ]]; then RELEASE_CHANNEL="internal" + RELEASE_NAME="$TAG_NAME" elif [[ "$TAG_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then RELEASE_CHANNEL="public" + RELEASE_NAME="Unraid OS ${TAG_NAME} Stable" else echo "Unsupported release tag format: ${TAG_NAME}" >&2 exit 1 fi - echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" - echo "release_name=Unraid OS ${TAG_NAME}" >> "$GITHUB_OUTPUT" - echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT" + TAG_SHA="$(git rev-list -n 1 "$TAG_NAME")" + SEMVER_TAGS="$( + git tag --list '[0-9]*' \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' \ + | sort -V + )" + PREVIOUS_TAG="$( + printf '%s\n' "$SEMVER_TAGS" \ + | awk -v tag="$TAG_NAME" '$0 == tag { print previous; exit } { previous = $0 }' + )" + + if [ -n "$PREVIOUS_TAG" ]; then + RANGE_SPEC="${PREVIOUS_TAG}..${TAG_NAME}" + else + RANGE_SPEC="$TAG_NAME" + fi - - name: Validate Linear access key + { + echo "tag_name=${TAG_NAME}" + echo "tag_sha=${TAG_SHA}" + echo "previous_tag=${PREVIOUS_TAG}" + echo "range_spec=${RANGE_SPEC}" + echo "release_name=${RELEASE_NAME}" + echo "release_channel=${RELEASE_CHANNEL}" + } >> "$GITHUB_OUTPUT" + + - name: Collect Linear issue IDs from release commits + id: issues env: - RELEASE_CHANNEL: ${{ steps.tag.outputs.release_channel }} - LINEAR_INTERNAL_ACCESS_KEY: ${{ secrets.LINEAR_INTERNAL_ACCESS_KEY }} - LINEAR_PUBLIC_ACCESS_KEY: ${{ secrets.LINEAR_PUBLIC_ACCESS_KEY }} + GH_TOKEN: ${{ github.token }} + RANGE_SPEC: ${{ steps.tag.outputs.range_spec }} + run: | + set -Eeuo pipefail + IFS=$'\n\t' + + LOG_PATH="${RUNNER_TEMP}/linear-release-commit-text.txt" + 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" + : > "$LOG_PATH" + : > "$PR_TEXT_PATH" + : > "$ISSUE_IDS_PATH" + : > "$FEATUREOS_URLS_PATH" + + git log --format='%B%n' "$RANGE_SPEC" > "$LOG_PATH" + + PR_NUMBERS="$( + grep -Eo 'Merge pull request #[0-9]+' "$LOG_PATH" \ + | grep -Eo '[0-9]+' \ + | sort -u || true + )" + + for PR_NUMBER in $PR_NUMBERS; do + 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" + done + + { + grep -Eoh '\b[A-Z][A-Z0-9]+-[0-9]+\b' "$LOG_PATH" "$PR_TEXT_PATH" || true + } | sort -u > "$ISSUE_IDS_PATH" + + { + grep -Eoh 'https://product\.unraid\.net/p/[^[:space:]<>)"]+' "$LOG_PATH" "$PR_TEXT_PATH" || true + } | sed 's/[.,;:]*$//' | sort -u > "$FEATUREOS_URLS_PATH" + + { + echo "issue_ids_path=${ISSUE_IDS_PATH}" + echo "featureos_urls_path=${FEATUREOS_URLS_PATH}" + echo "issue_count=$(wc -l < "$ISSUE_IDS_PATH" | tr -d ' ')" + echo "featureos_url_count=$(wc -l < "$FEATUREOS_URLS_PATH" | tr -d ' ')" + } >> "$GITHUB_OUTPUT" + + - name: Validate Linear API key + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} run: | set -Eeuo pipefail IFS=$'\n\t' - case "$RELEASE_CHANNEL" in - internal) - SECRET_NAME="LINEAR_INTERNAL_ACCESS_KEY" - SECRET_VALUE="$LINEAR_INTERNAL_ACCESS_KEY" - ;; - public) - SECRET_NAME="LINEAR_PUBLIC_ACCESS_KEY" - SECRET_VALUE="$LINEAR_PUBLIC_ACCESS_KEY" - ;; - *) - echo "Unknown release channel: $RELEASE_CHANNEL" >&2 - exit 1 - ;; - esac - - if [ -z "$SECRET_VALUE" ]; then - echo "Missing GitHub Actions secret: $SECRET_NAME" >&2 + if [ -z "$LINEAR_API_KEY" ]; then + echo "Missing GitHub Actions secret: LINEAR_API_KEY" >&2 + echo "This workflow now uses the Linear GraphQL API directly so it can sync release issue membership." >&2 exit 1 fi - - name: Sync internal Linear release - if: steps.tag.outputs.release_channel == 'internal' - id: linear-release-internal - uses: linear/linear-release-action@v0 - with: - access_key: ${{ secrets.LINEAR_INTERNAL_ACCESS_KEY }} - name: ${{ steps.tag.outputs.release_name }} - version: ${{ steps.tag.outputs.tag_name }} - - - name: Sync public Linear release - if: steps.tag.outputs.release_channel == 'public' - id: linear-release-public - uses: linear/linear-release-action@v0 - with: - access_key: ${{ secrets.LINEAR_PUBLIC_ACCESS_KEY }} - name: ${{ steps.tag.outputs.release_name }} - version: ${{ steps.tag.outputs.tag_name }} + - name: Sync Linear release and issue membership + id: sync + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + RELEASE_CHANNEL: ${{ steps.tag.outputs.release_channel }} + RELEASE_NAME: ${{ steps.tag.outputs.release_name }} + TAG_NAME: ${{ steps.tag.outputs.tag_name }} + TAG_SHA: ${{ steps.tag.outputs.tag_sha }} + PREVIOUS_TAG: ${{ steps.tag.outputs.previous_tag }} + 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 }} + run: node .github/scripts/sync-linear-release.mjs - name: Summarize Linear release if: always() env: - INTERNAL_RELEASE_URL: ${{ steps.linear-release-internal.outputs.release-url }} - INTERNAL_RELEASE_NAME: ${{ steps.linear-release-internal.outputs.release-name }} - INTERNAL_RELEASE_VERSION: ${{ steps.linear-release-internal.outputs.release-version }} - PUBLIC_RELEASE_URL: ${{ steps.linear-release-public.outputs.release-url }} - PUBLIC_RELEASE_NAME: ${{ steps.linear-release-public.outputs.release-name }} - PUBLIC_RELEASE_VERSION: ${{ steps.linear-release-public.outputs.release-version }} JOB_STATUS: ${{ job.status }} RELEASE_CHANNEL: ${{ steps.tag.outputs.release_channel }} TAG_NAME: ${{ steps.tag.outputs.tag_name }} + PREVIOUS_TAG: ${{ steps.tag.outputs.previous_tag }} + RELEASE_URL: ${{ steps.sync.outputs.release_url }} + RELEASE_NAME: ${{ steps.sync.outputs.release_name }} + RELEASE_VERSION: ${{ steps.sync.outputs.release_version }} + SYNCED_ISSUES: ${{ steps.sync.outputs.synced_issue_identifiers }} + SKIPPED_ISSUES: ${{ steps.sync.outputs.skipped_issue_identifiers }} + ISSUE_COUNT: ${{ steps.issues.outputs.issue_count }} + FEATUREOS_URL_COUNT: ${{ steps.issues.outputs.featureos_url_count }} run: | set -Eeuo pipefail IFS=$'\n\t' - RELEASE_URL="" - RELEASE_NAME="" - RELEASE_VERSION="" - - case "$RELEASE_CHANNEL" in - internal) - RELEASE_URL="$INTERNAL_RELEASE_URL" - RELEASE_NAME="$INTERNAL_RELEASE_NAME" - RELEASE_VERSION="$INTERNAL_RELEASE_VERSION" - ;; - public) - RELEASE_URL="$PUBLIC_RELEASE_URL" - RELEASE_NAME="$PUBLIC_RELEASE_NAME" - RELEASE_VERSION="$PUBLIC_RELEASE_VERSION" - ;; - esac - { echo "## Linear release" echo echo "- Tag: \`${TAG_NAME}\`" + echo "- Previous tag: \`${PREVIOUS_TAG:-none}\`" echo "- Channel: \`${RELEASE_CHANNEL}\`" if [ "$JOB_STATUS" != "success" ]; then @@ -164,4 +208,9 @@ jobs: else echo "- Release: no Linear release was created or updated" fi + + echo "- Linear issue IDs found: \`${ISSUE_COUNT:-0}\`" + echo "- FeatureOS links found: \`${FEATUREOS_URL_COUNT:-0}\`" + echo "- Issues attached: ${SYNCED_ISSUES:-none}" + echo "- Issues skipped: ${SKIPPED_ISSUES:-none}" } >> "$GITHUB_STEP_SUMMARY"