diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 00000000..28937642 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,127 @@ +name: Automated Releases + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +concurrency: + group: automated-release + cancel-in-progress: false + +permissions: + contents: write + pull-requests: read + +jobs: + tag-and-release: + name: Create dated tag and release notes + runs-on: ubuntu-latest + steps: + - name: Checkout current main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Determine release target + id: target + run: | + set -euo pipefail + TARGET_SHA=$(git rev-parse HEAD) + echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT" + echo "Release target: $TARGET_SHA" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Bun for scripts + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: '1.3.14' + + - name: Install JS/TS deps + run: bun install --frozen-lockfile + + - name: Build release notes (TypeScript) + id: build_notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TARGET_SHA: ${{ steps.target.outputs.target_sha }} + run: | + set -euo pipefail + bun ./scripts/release-notes.ts + test -s RELEASE_NOTES.md + + - name: Select or recover dated tag + id: release_tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.build_notes.outputs.tag }} + TAG_ACTION: ${{ steps.build_notes.outputs.tag_action }} + RELEASE_TARGET_SHA: ${{ steps.build_notes.outputs.release_target_sha }} + run: | + set -euo pipefail + + REPOSITORY_URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + case "$TAG_ACTION" in + reuse|recover) + EXISTING_SHA=$(git rev-list -n 1 "$TAG^{commit}") + + if [ "$EXISTING_SHA" != "$RELEASE_TARGET_SHA" ]; then + echo "Selected tag $TAG does not point to release target $RELEASE_TARGET_SHA" + exit 1 + fi + + echo "Using existing tag $TAG at $RELEASE_TARGET_SHA" + ;; + + create) + echo "Creating annotated tag $TAG at $RELEASE_TARGET_SHA" + git tag -a "$TAG" "$RELEASE_TARGET_SHA" -m "Automated release $TAG" + git push "$REPOSITORY_URL" "refs/tags/$TAG" + ;; + + *) + echo "Unknown tag action: $TAG_ACTION" + exit 1 + ;; + esac + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Publish or recover GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release_tag.outputs.tag }} + run: | + set -euo pipefail + + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft -q .isDraft) + PRERELEASE=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease -q .isPrerelease) + + if [ "$DRAFT" = "true" ] || [ "$PRERELEASE" = "true" ]; then + echo "Publishing existing release $TAG" + gh release edit "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --draft=false \ + --prerelease=false \ + --notes-file RELEASE_NOTES.md + else + echo "Release $TAG already exists. Nothing to publish." + fi + + exit 0 + fi + + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md + + echo "Published release $TAG" \ No newline at end of file diff --git a/bun.lock b/bun.lock index 52487c37..6c1e7170 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "orbit", "devDependencies": { "@biomejs/biome": "2.5.7", + "@orbit/shared": "workspace:*", "@types/bun": "1.3.14", "lefthook": "2.1.10", "typescript": "5.9.3", diff --git a/docs/roadmap.md b/docs/roadmap.md index 7501d196..ffc3a9f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -140,9 +140,10 @@ enforced. ## Releases Orbit ships continuously from `main`. There are no long lived release branches -and no backporting, so self-hosted deployments should track `main` or a recent -tag. +and no backporting. To make deployments traceable we publish automated dated +tags and GitHub releases weekly, with manual dispatch available when needed, so +self-hosted deployments can track `main` or a recent dated tag. Anything requiring action from someone self-hosting is labelled [`breaking change`](https://github.com/Noveum/orbit/labels/breaking%20change) -and called out in the release notes. +and called out prominently in the generated release notes. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f7583023..1821e1d5 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -261,11 +261,13 @@ bun run build Always complete the database release before the code that depends on it goes live. The production Vercel build refuses to deploy when the configured database cannot be verified or is missing a required schema object. Additional legacy tables and -indexes are reported and preserved. Orbit ships continuously from `main` and there -is no backporting, so track `main` or a recent tag. +indexes are reported and preserved. Orbit ships continuously from `main`. We +also publish automated weekly dated tags and GitHub releases, with manual +workflow dispatch available when needed, so you can track `main` or a recent +dated tag for deployed versions. Watch the [releases](https://github.com/Noveum/orbit/releases) for anything -labelled `breaking change`. +labelled `breaking change` and follow the upgrade notes in the associated release. ### Backups diff --git a/package.json b/package.json index 68827aa2..f48c00f7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build": "bun run --filter '*' build", "dev": "bun run --filter '*' dev", "typecheck": "tsc -p scripts/tsconfig.json --noEmit && bun run --filter '*' typecheck", - "test": "bun run --filter '*' test", + "test": "bun test ./tests/*.test.ts && bun run --filter '*' test", "test:e2e": "bun run --filter '@orbit/web' test:e2e", "lint": "biome check .", "lint:fix": "biome check --write .", @@ -59,6 +59,7 @@ }, "devDependencies": { "@biomejs/biome": "2.5.7", + "@orbit/shared": "workspace:*", "@types/bun": "1.3.14", "lefthook": "2.1.10", "typescript": "5.9.3" diff --git a/packages/shared/src/validators/github-release.ts b/packages/shared/src/validators/github-release.ts new file mode 100644 index 00000000..d10f984f --- /dev/null +++ b/packages/shared/src/validators/github-release.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +const labelSchema = z.object({ + name: z.string(), +}); + +export const pullRequestSchema = z.object({ + number: z.number(), + title: z.string(), + html_url: z.string().url(), + body: z.string().nullable(), + labels: z.array(labelSchema), + merged_at: z.string().nullable(), + base: z.object({ + ref: z.string(), + }), +}); + +export const pullRequestListSchema = z.array(pullRequestSchema); + +export const githubCommitSchema = z.object({ + sha: z.string().min(1), +}); + +export const commitPageSchema = z.array(githubCommitSchema); + +export const releaseSchema = z.object({ + tag_name: z.string().min(1), + draft: z.boolean(), + prerelease: z.boolean(), + published_at: z.string().nullable(), +}); + +export const releaseListSchema = z.array(releaseSchema); + +export type PullRequest = z.infer; +export type GitHubCommit = z.infer; +export type GitHubRelease = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e4c6149e..ade3ba6b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -6,6 +6,7 @@ export * from './comment.ts'; export * from './common.ts'; export * from './cycle.ts'; export * from './doc.ts'; +export * from './github-release.ts'; export * from './integration.ts'; export * from './issue.ts'; export * from './label.ts'; diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts new file mode 100644 index 00000000..f5fd845f --- /dev/null +++ b/scripts/release-notes.ts @@ -0,0 +1,398 @@ +#!/usr/bin/env bun +import { writeFile } from 'node:fs/promises'; +import { + commitPageSchema, + type GitHubCommit, + type GitHubRelease, + type PullRequest, + pullRequestListSchema, + releaseListSchema, +} from '@orbit/shared/validators'; + +const repo = process.env['GITHUB_REPOSITORY'] ?? ''; +const token = process.env['GITHUB_TOKEN'] ?? ''; +const targetSha = process.env['RELEASE_TARGET_SHA'] ?? ''; + +function getRepositoryParts(): { owner: string; repoName: string } { + const parts = repo.split('/'); + + if (parts.length !== 2) { + throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`); + } + + const [owner, repoName] = parts; + + if (!(owner && repoName)) { + throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`); + } + + return { owner, repoName }; +} + +const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'orbit-release-bot', +}; +if (token) headers['Authorization'] = `Bearer ${token}`; + +const maxFetchAttempts = 4; +const fetchTimeoutMs = 30_000; + +async function fetchJson(url: string, attempt = 1): Promise { + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(fetchTimeoutMs), + }); + + const text = await response.text(); + + if (!response.ok) { + const retryable = response.status === 429 || response.status === 403 || response.status >= 500; + + if (retryable && attempt < maxFetchAttempts) { + await Bun.sleep(2 ** attempt * 1_000); + return await fetchJson(url, attempt + 1); + } + + throw new Error(`GitHub API request failed (${response.status}): ${text}`); + } + + try { + return JSON.parse(text); + } catch { + throw new Error(`GitHub API returned invalid JSON: ${url}`); + } +} + +async function runGit(args: string[]): Promise { + const proc = Bun.spawn(['git', ...args], { + stdout: 'pipe', + stderr: 'pipe', + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${stderr.trim()}`); + } + return stdout.trim(); +} + +function isDatedReleaseTag(tag: string): boolean { + return /^\d{4}\.\d{2}\.\d{2}(?:-\d+)?$/.test(tag); +} + +export function datedTagsFromRefOutput(output: string): string[] { + const tags: string[] = []; + for (const line of output.split('\n')) { + const [tag] = line.split(' '); + if (tag && isDatedReleaseTag(tag)) tags.push(tag); + } + return tags; +} + +export function isPublishedDatedRelease(release: GitHubRelease): boolean { + return ( + !(release.draft || release.prerelease) && + release.published_at !== null && + isDatedReleaseTag(release.tag_name) + ); +} + +async function getPublishedReleases(): Promise { + const { owner, repoName } = getRepositoryParts(); + const published: GitHubRelease[] = []; + + for (let page = 1; ; page++) { + const url = `https://api.github.com/repos/${owner}/${repoName}/releases?per_page=100&page=${page}`; + const releases = releaseListSchema.parse(await fetchJson(url)); + + if (releases.length === 0) break; + + published.push(...releases.filter(isPublishedDatedRelease)); + + if (releases.length < 100) break; + } + + return published; +} + +export function selectReleaseBoundary( + firstParentHistory: readonly string[], + releaseTargetSha: string, + existingTags: Readonly>, + publishedTags: ReadonlySet, +): string { + const commitOrder = new Map(firstParentHistory.map((sha, index) => [sha, index])); + const targetIndex = commitOrder.get(releaseTargetSha); + + if (targetIndex === undefined) { + throw new Error(`Release target ${releaseTargetSha} is not in the first-parent history`); + } + + let boundaryIndex = firstParentHistory.length; + let coveredByNewerRelease = false; + + for (const [tag, sha] of Object.entries(existingTags)) { + if (!publishedTags.has(tag)) continue; + const candidateIndex = commitOrder.get(sha); + if (candidateIndex !== undefined && candidateIndex < targetIndex) { + coveredByNewerRelease = true; + continue; + } + if ( + candidateIndex !== undefined && + candidateIndex >= targetIndex && + candidateIndex < boundaryIndex + ) { + boundaryIndex = candidateIndex; + } + } + + if (coveredByNewerRelease) return releaseTargetSha; + + const boundary = firstParentHistory[boundaryIndex] ?? firstParentHistory.at(-1); + + if (!boundary) { + throw new Error(`No initial commit found for ${releaseTargetSha}`); + } + + return boundary; +} + +type TagAction = 'create' | 'reuse' | 'recover'; + +function compareDatedTags(left: string, right: string): number { + const leftDate = left.slice(0, 10); + const rightDate = right.slice(0, 10); + if (leftDate < rightDate) return -1; + if (leftDate > rightDate) return 1; + const leftSuffix = left.length === 10 ? 0 : Number(left.slice(11)); + const rightSuffix = right.length === 10 ? 0 : Number(right.slice(11)); + return leftSuffix - rightSuffix; +} + +export function selectDatedTag( + baseTag: string, + targetSha: string, + existingTags: Readonly>, + publishedTags: ReadonlySet, + firstParentHistory: readonly string[] = [], +): { tag: string; action: TagAction; releaseTargetSha: string } { + const commitOrder = new Map([...firstParentHistory].reverse().map((sha, index) => [sha, index])); + const orphan = Object.entries(existingTags) + .filter(([tag, sha]) => !publishedTags.has(tag) && commitOrder.has(sha)) + .sort(([leftTag, leftSha], [rightTag, rightSha]) => { + const order = (commitOrder.get(leftSha) ?? 0) - (commitOrder.get(rightSha) ?? 0); + return order === 0 ? compareDatedTags(leftTag, rightTag) : order; + })[0]; + + if (orphan) { + const [tag, releaseTargetSha] = orphan; + return { tag, action: 'recover', releaseTargetSha }; + } + + let count = 0; + + while (true) { + const tag = count === 0 ? baseTag : `${baseTag}-${count}`; + const existingSha = existingTags[tag]; + + if (!existingSha) { + return { tag, action: 'create', releaseTargetSha: targetSha }; + } + + if (existingSha === targetSha) { + return { tag, action: 'reuse', releaseTargetSha: existingSha }; + } + + count += 1; + } +} +async function getExistingDatedTags(): Promise> { + const output = await runGit([ + 'for-each-ref', + 'refs/tags', + '--format=%(refname:strip=2) %(objectname)', + ]); + const tags: Record = {}; + + for (const tag of datedTagsFromRefOutput(output)) { + tags[tag] = await runGit(['rev-list', '-n', '1', `${tag}^{commit}`]); + } + + return tags; +} + +async function writeGitHubOutput(name: string, value: string): Promise { + const outputPath = process.env['GITHUB_OUTPUT']; + if (!outputPath) return; + await writeFile(outputPath, `${name}=${value}\n`, { flag: 'a' }); +} + +async function fetchCommitPage(releaseTargetSha: string, page: number): Promise { + const { owner, repoName } = getRepositoryParts(); + const url = `https://api.github.com/repos/${owner}/${repoName}/commits?sha=${releaseTargetSha}&per_page=100&page=${page}`; + return commitPageSchema.parse(await fetchJson(url)); +} + +async function fetchPullRequestsForCommit(sha: string): Promise { + const { owner, repoName } = getRepositoryParts(); + const url = `https://api.github.com/repos/${owner}/${repoName}/commits/${sha}/pulls`; + return pullRequestListSchema.parse(await fetchJson(url)); +} + +export function collectCommitsInRange(baseSha: string, pages: GitHubCommit[][]): GitHubCommit[] { + const commits: GitHubCommit[] = []; + + for (const page of pages) { + for (const commit of page) { + if (commit.sha === baseSha) return commits; + commits.push(commit); + } + + if (page.length < 100) { + throw new Error(`Release base ${baseSha} was not found in the main history`); + } + } + + throw new Error(`Release base ${baseSha} was not found in the main history`); +} +async function collectPullRequests( + baseSha: string, + releaseTargetsha: string, +): Promise { + const pages: GitHubCommit[][] = []; + + for (let page = 1; ; page++) { + const pageCommits = await fetchCommitPage(releaseTargetsha, page); + pages.push(pageCommits); + + if (pageCommits.some((commit) => commit.sha === baseSha)) break; + if (pageCommits.length < 100) { + throw new Error(`Release base ${baseSha} was not found in the main history`); + } + } + + const commits = collectCommitsInRange(baseSha, pages); + const prs = new Map(); + + for (const commit of commits) { + const associatedPRs = await fetchPullRequestsForCommit(commit.sha); + for (const pr of associatedPRs) { + if (isMainReleasePR(pr)) prs.set(pr.number, pr); + } + } + + return [...prs.values()].sort((a, b) => a.number - b.number); +} + +export function isMainReleasePR(pr: PullRequest): boolean { + return Boolean(pr.merged_at) && pr.base.ref === 'main'; +} + +export function groupByArea(prs: PullRequest[]) { + const areas: Record = {}; + const breaking: PullRequest[] = []; + + for (const pr of prs) { + const labels = pr.labels.map((label) => label.name); + const hasBreakingLabel = labels.some((name) => name.toLowerCase() === 'breaking change'); + const hasBreakingBody = /\bBREAKING CHANGE\b/i.test(pr.body ?? ''); + + if (hasBreakingLabel || hasBreakingBody) { + breaking.push(pr); + continue; + } + + const area = labels.find((name) => name.startsWith('area:')) ?? 'Other'; + areas[area] ??= []; + areas[area].push(pr); + } + + return { areas, breaking }; +} + +export function renderNotes(prs: PullRequest[], baseSha: string, targetSha: string): string { + const { areas, breaking } = groupByArea(prs); + let body = `Automated release for ${targetSha}\n\n`; + body += `Changes: ${baseSha}..${targetSha}\n\n`; + + if (breaking.length) { + body += '## Breaking changes\n\n'; + body += + '> Action required: review the linked pull requests for database migrations, ' + + 'new environment variables, or other deployment changes before upgrading.\n\n'; + for (const pr of breaking) { + body += `- ${pr.title} (#${pr.number}) ${pr.html_url}\n`; + if (pr.body) { + const excerpt = pr.body.split(/\r?\n/).slice(0, 6).join('\n ').trim(); + if (excerpt) body += `\n ${excerpt}\n`; + } + } + body += '\n'; + } + + for (const area of Object.keys(areas).sort()) { + const title = area === 'Other' ? area : area.replace(/^area:/, 'Area: '); + body += `## ${title}\n`; + for (const pr of areas[area] ?? []) { + body += `- ${pr.title} (#${pr.number}) ${pr.html_url}\n`; + } + body += '\n'; + } + + if (!breaking.length && Object.keys(areas).length === 0) { + body += 'No merged pull requests found in this release range.\n'; + } + + return body; +} + +async function main(): Promise { + if (!repo) { + throw new Error('GITHUB_REPOSITORY is not set'); + } + if (!token) { + throw new Error('GITHUB_TOKEN is not set'); + } + if (!targetSha) { + throw new Error('RELEASE_TARGET_SHA is not set'); + } + + const checkedOutSha = await runGit(['rev-parse', 'HEAD']); + if (checkedOutSha !== targetSha) { + throw new Error(`Checked-out main is ${checkedOutSha}, expected release target ${targetSha}`); + } + + const published = await getPublishedReleases(); + const publishedTags = new Set(published.map((release) => release.tag_name)); + const baseTag = new Date().toISOString().slice(0, 10).replaceAll('-', '.'); + const existingTags = await getExistingDatedTags(); + const history = (await runGit(['rev-list', '--first-parent', targetSha])) + .split('\n') + .filter((sha) => sha.length > 0); + const selectedTag = selectDatedTag(baseTag, targetSha, existingTags, publishedTags, history); + const baseSha = selectReleaseBoundary( + history, + selectedTag.releaseTargetSha, + existingTags, + publishedTags, + ); + const prs = await collectPullRequests(baseSha, selectedTag.releaseTargetSha); + const notes = renderNotes(prs, baseSha, selectedTag.releaseTargetSha); + await writeFile('RELEASE_NOTES.md', notes, 'utf8'); + await writeGitHubOutput('tag', selectedTag.tag); + await writeGitHubOutput('tag_action', selectedTag.action); + await writeGitHubOutput('release_target_sha', selectedTag.releaseTargetSha); + console.log(`Release range: ${baseSha}..${selectedTag.releaseTargetSha}`); + console.log(`Release PRs: ${prs.length}`); + console.log('WROTE RELEASE_NOTES.md'); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error('Failed to generate release notes:', error); + process.exitCode = 2; + }); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 7e5b6b2a..017e8329 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -7,5 +7,5 @@ "declarationMap": false, "sourceMap": false }, - "include": ["*.ts"] + "include": ["*.ts", "../tests/**/*.ts"] } diff --git a/tests/auto-release-workflow.test.ts b/tests/auto-release-workflow.test.ts new file mode 100644 index 00000000..0ec4b5dc --- /dev/null +++ b/tests/auto-release-workflow.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { YAML } from 'bun'; + +const workflowPath = new URL('../.github/workflows/auto-release.yml', import.meta.url); + +const scriptPath = new URL('../scripts/release-notes.ts', import.meta.url); + +const workflowSource = await readFile(workflowPath, 'utf8'); +const script = readFileSync(scriptPath, 'utf8'); +const parsedWorkflow = YAML.parse(workflowSource) as { + jobs: { + 'tag-and-release': { + steps: Array<{ + name?: string; + id?: string; + env?: Record; + run?: string; + with?: Record; + }>; + }; + }; +}; + +describe('automated release workflow contract', () => { + test('contains every release step as parsed YAML', () => { + const steps = parsedWorkflow.jobs['tag-and-release'].steps; + + expect(steps.some((step) => step.id === 'target')).toBe(true); + expect(steps.some((step) => step.id === 'build_notes')).toBe(true); + expect(steps.some((step) => step.id === 'release_tag')).toBe(true); + expect(steps.some((step) => step.name === 'Publish or recover GitHub release')).toBe(true); + }); + test('connects every generator output to the workflow', () => { + expect(script).toContain("writeGitHubOutput('tag', selectedTag.tag)"); + expect(script).toContain("writeGitHubOutput('tag_action', selectedTag.action)"); + expect(script).toContain("'release_target_sha',"); + + const releaseTagStep = parsedWorkflow.jobs['tag-and-release'].steps.find( + (step) => step.id === 'release_tag', + ); + + expect(releaseTagStep?.env?.['TAG']).toBe(`\${{ steps.build_notes.outputs.tag }}`); + expect(releaseTagStep?.env?.['TAG_ACTION']).toBe( + `\${{ steps.build_notes.outputs.tag_action }}`, + ); + expect(releaseTagStep?.env?.['RELEASE_TARGET_SHA']).toBe( + `\${{ steps.build_notes.outputs.release_target_sha }}`, + ); + + expect(workflowSource).toMatch( + /RELEASE_TARGET_SHA:\s*\$\{\{\s*steps\.build_notes\.outputs\.release_target_sha\s*\}\}/, + ); + }); + + test('passes the selected tag into the publish step', () => { + expect(workflowSource).toMatch(/TAG:\s*\$\{\{\s*steps\.release_tag\.outputs\.tag\s*\}\}/); + expect(workflowSource).toContain('echo "tag=$TAG" >> "$GITHUB_OUTPUT"'); + }); + + test('keeps checkout credentials disabled and scopes mutation credentials', () => { + expect(workflowSource).toContain('persist-credentials: false'); + expect(workflowSource).toMatch(/GH_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}/); + expect(workflowSource).toContain( + `REPOSITORY_URL="https://x-access-token:\${GH_TOKEN}@github.com/\${GITHUB_REPOSITORY}.git"`, + ); + }); + + test('recovers orphan tags without moving them', () => { + expect(workflowSource).toContain('reuse|recover)'); + expect(workflowSource).not.toContain('repair)'); + expect(workflowSource).not.toContain('--force-with-lease'); + expect(workflowSource).not.toContain('git tag -fa'); + expect(workflowSource).not.toContain('RELEASE_STATUS'); + + expect(workflowSource).toContain('git tag -a "$TAG" "$RELEASE_TARGET_SHA"'); + }); +}); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts new file mode 100644 index 00000000..0fe401e6 --- /dev/null +++ b/tests/release-notes.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, test } from 'bun:test'; +import { + collectCommitsInRange, + datedTagsFromRefOutput, + groupByArea, + isMainReleasePR, + isPublishedDatedRelease, + renderNotes, + selectDatedTag, + selectReleaseBoundary, +} from '../scripts/release-notes'; + +const pr = (overrides: Record = {}) => ({ + number: 1, + title: 'Improve release workflow', + html_url: 'https://github.com/Noveum/orbit/pull/1', + body: null, + labels: [], + merged_at: '2026-08-21T00:00:00Z', + base: { ref: 'main' }, + ...overrides, +}); + +describe('release notes grouping', () => { + test('groups area labels and keeps unlabelled PRs in Other', () => { + const result = groupByArea([ + pr({ number: 1, labels: [{ name: 'area:release' }] }), + pr({ number: 2, labels: [] }), + ]); + + expect(result.areas['area:release']?.map((item) => item.number)).toEqual([1]); + expect(result.areas['Other']?.map((item) => item.number)).toEqual([2]); + expect(result.breaking).toEqual([]); + }); + + test('accepts only PRs merged into main', () => { + expect(isMainReleasePR(pr({ base: { ref: 'main' } }))).toBe(true); + expect(isMainReleasePR(pr({ base: { ref: 'develop' } }))).toBe(false); + }); + + test('detects breaking changes from labels and body', () => { + const result = groupByArea([ + pr({ number: 1, labels: [{ name: 'breaking change' }] }), + pr({ number: 2, body: 'BREAKING CHANGE: update the API' }), + ]); + + expect(result.breaking.map((item) => item.number)).toEqual([1, 2]); + }); +}); + +describe('release range pagination', () => { + test('stops exactly at the base commit across pages', async () => { + const commits = await collectCommitsInRange('base', [ + Array.from({ length: 100 }, (_, index) => ({ sha: `commit-${index}` })), + [{ sha: 'commit-100' }, { sha: 'base' }, { sha: 'older' }], + ]); + + expect(commits).toHaveLength(101); + expect(commits.at(-1)?.sha).toBe('commit-100'); + }); + + test('does not silently stop at a short page before finding the base', () => { + expect(() => collectCommitsInRange('base', [[{ sha: 'commit-1' }]])).toThrow( + 'was not found in the main history', + ); + }); +}); + +describe('published release boundary selection', () => { + const history = [ + 'current-target', + 'newer-published', + 'orphan-target', + 'older-published', + 'root-target', + ]; + const existingTags = { + '2026.08.18': 'older-published', + '2026.08.19': 'orphan-target', + '2026.08.20': 'newer-published', + '2026.08.21': 'off-main', + }; + + test('accepts only published dated releases', () => { + expect( + isPublishedDatedRelease({ + tag_name: '2026.08.20', + draft: false, + prerelease: false, + published_at: '2026-08-20T00:00:00Z', + }), + ).toBe(true); + expect( + isPublishedDatedRelease({ + tag_name: '2026.08.20', + draft: true, + prerelease: false, + published_at: null, + }), + ).toBe(false); + expect( + isPublishedDatedRelease({ + tag_name: '2026.08.20', + draft: false, + prerelease: true, + published_at: '2026-08-20T00:00:00Z', + }), + ).toBe(false); + expect( + isPublishedDatedRelease({ + tag_name: 'v1.0.0', + draft: false, + prerelease: false, + published_at: '2026-08-20T00:00:00Z', + }), + ).toBe(false); + }); + + test('uses the target when a newer published release already covers it', () => { + expect( + selectReleaseBoundary( + history, + 'orphan-target', + existingTags, + new Set(['2026.08.18', '2026.08.20']), + ), + ).toBe('orphan-target'); + }); + + test('uses the nearest older published commit when no newer release covers the target', () => { + expect( + selectReleaseBoundary(history, 'orphan-target', existingTags, new Set(['2026.08.18'])), + ).toBe('older-published'); + }); + + test('uses the newer commit boundary after a historical orphan is published', () => { + expect( + selectReleaseBoundary( + history, + 'current-target', + existingTags, + new Set(['2026.08.18', '2026.08.19', '2026.08.20']), + ), + ).toBe('newer-published'); + }); + + test('ignores published tags outside the target first-parent history', () => { + expect( + selectReleaseBoundary(history, 'current-target', existingTags, new Set(['2026.08.21'])), + ).toBe('root-target'); + }); + + test('uses the root when no published boundary precedes the target', () => { + expect(selectReleaseBoundary(history, 'orphan-target', existingTags, new Set())).toBe( + 'root-target', + ); + expect(selectReleaseBoundary(history, 'root-target', existingTags, new Set())).toBe( + 'root-target', + ); + }); + + test('fails closed when the selected target is outside first-parent history', () => { + expect(() => + selectReleaseBoundary(history, 'off-main', existingTags, new Set(['2026.08.21'])), + ).toThrow('is not in the first-parent history'); + }); +}); + +describe('dated tag selection', () => { + test('discovers dated tags across UTC dates and ignores unrelated tags', () => { + expect( + datedTagsFromRefOutput( + '2026.08.21 old-target\n2026.08.22-2 current-target\nv1.0.0 unrelated-target', + ), + ).toEqual(['2026.08.21', '2026.08.22-2']); + }); + + test('reuses an existing tag when it already points at the target', () => { + expect( + selectDatedTag( + '2026.08.21', + 'target', + { + '2026.08.21': 'target', + }, + new Set(), + ), + ).toEqual({ + tag: '2026.08.21', + action: 'reuse', + releaseTargetSha: 'target', + }); + }); + + test('does not create a suffix for a same-target retry', () => { + expect( + selectDatedTag( + '2026.08.21', + 'target', + { + '2026.08.21': 'target', + '2026.08.21-1': 'other', + }, + new Set(), + ), + ).toEqual({ + tag: '2026.08.21', + action: 'reuse', + releaseTargetSha: 'target', + }); + }); + + test('recovers an unpublished orphan without moving its tag', () => { + expect( + selectDatedTag( + '2026.08.21', + 'new-target', + { + '2026.08.21': 'old-target', + }, + new Set(), + ['new-target', 'old-target'], + ), + ).toEqual({ + tag: '2026.08.21', + action: 'recover', + releaseTargetSha: 'old-target', + }); + }); + + test('uses a suffix when the existing tag already has a published release', () => { + expect( + selectDatedTag( + '2026.08.21', + 'new-target', + { + '2026.08.21': 'old-target', + }, + new Set(['2026.08.21']), + ), + ).toEqual({ + tag: '2026.08.21-1', + action: 'create', + releaseTargetSha: 'new-target', + }); + }); + + test('recovers a prior-date orphan before creating a tag for today', () => { + expect( + selectDatedTag( + '2026.08.22', + 'current-target', + { + '2026.08.21': 'orphan-target', + }, + new Set(), + ['current-target', 'orphan-target'], + ), + ).toEqual({ + tag: '2026.08.21', + action: 'recover', + releaseTargetSha: 'orphan-target', + }); + }); + + test('recovers an orphan older than a later published release', () => { + const history = [ + 'current-target', + 'newer-published', + 'orphan-target', + 'older-published', + 'root-target', + ]; + const existingTags = { + '2026.08.18': 'older-published', + '2026.08.19': 'orphan-target', + '2026.08.20': 'newer-published', + }; + const publishedTags = new Set(['2026.08.18', '2026.08.20']); + const selection = selectDatedTag( + '2026.08.21', + 'current-target', + existingTags, + publishedTags, + history, + ); + + expect(selection).toEqual({ + tag: '2026.08.19', + action: 'recover', + releaseTargetSha: 'orphan-target', + }); + expect( + selectReleaseBoundary(history, selection.releaseTargetSha, existingTags, publishedTags), + ).toBe('orphan-target'); + + publishedTags.add(selection.tag); + const nextSelection = selectDatedTag( + '2026.08.21', + 'current-target', + existingTags, + publishedTags, + history, + ); + + expect(nextSelection).toEqual({ + tag: '2026.08.21', + action: 'create', + releaseTargetSha: 'current-target', + }); + expect( + selectReleaseBoundary(history, nextSelection.releaseTargetSha, existingTags, publishedTags), + ).toBe('newer-published'); + }); + + test('recovers multiple covered historical orphans without overlapping ranges', () => { + const history = [ + 'current-target', + 'newer-published', + 'later-orphan', + 'earlier-orphan', + 'older-published', + 'root-target', + ]; + const existingTags = { + '2026.08.18': 'older-published', + '2026.08.19': 'earlier-orphan', + '2026.08.20': 'later-orphan', + '2026.08.21': 'newer-published', + }; + const publishedTags = new Set(['2026.08.18', '2026.08.21']); + const first = selectDatedTag( + '2026.08.22', + 'current-target', + existingTags, + publishedTags, + history, + ); + + expect(first.releaseTargetSha).toBe('earlier-orphan'); + expect( + selectReleaseBoundary(history, first.releaseTargetSha, existingTags, publishedTags), + ).toBe('earlier-orphan'); + + publishedTags.add(first.tag); + const second = selectDatedTag( + '2026.08.22', + 'current-target', + existingTags, + publishedTags, + history, + ); + + expect(second.releaseTargetSha).toBe('later-orphan'); + expect( + selectReleaseBoundary(history, second.releaseTargetSha, existingTags, publishedTags), + ).toBe('later-orphan'); + + publishedTags.add(second.tag); + const current = selectDatedTag( + '2026.08.22', + 'current-target', + existingTags, + publishedTags, + history, + ); + + expect(current.releaseTargetSha).toBe('current-target'); + expect( + selectReleaseBoundary(history, current.releaseTargetSha, existingTags, publishedTags), + ).toBe('newer-published'); + }); + + test('publishes an orphan range before the remaining current range', () => { + const existingTags = { '2026.08.21': 'orphan-target' }; + const history = ['current-target', 'orphan-target']; + const first = selectDatedTag('2026.08.22', 'current-target', existingTags, new Set(), history); + const second = selectDatedTag( + '2026.08.22', + 'current-target', + existingTags, + new Set([first.tag]), + history, + ); + + expect(first.releaseTargetSha).toBe('orphan-target'); + expect(second).toEqual({ + tag: '2026.08.22', + action: 'create', + releaseTargetSha: 'current-target', + }); + }); + + test('recovers multiple orphans in first-parent order', () => { + expect( + selectDatedTag( + '2026.08.23', + 'current-target', + { + '2026.08.20': 'later-target', + '2026.08.21': 'earlier-target', + }, + new Set(), + ['current-target', 'later-target', 'earlier-target'], + ), + ).toEqual({ + tag: '2026.08.21', + action: 'recover', + releaseTargetSha: 'earlier-target', + }); + }); + + test('ignores orphans outside the eligible release path', () => { + expect( + selectDatedTag( + '2026.08.22', + 'current-target', + { + '2026.08.19': 'before-boundary', + '2026.08.20': 'off-main', + }, + new Set(), + ['current-target'], + ), + ).toEqual({ + tag: '2026.08.22', + action: 'create', + releaseTargetSha: 'current-target', + }); + }); + + test('preserves an off-main tag for today and creates a suffix', () => { + expect( + selectDatedTag( + '2026.08.22', + 'current-target', + { + '2026.08.22': 'off-main', + }, + new Set(), + ['current-target'], + ), + ).toEqual({ + tag: '2026.08.22-1', + action: 'create', + releaseTargetSha: 'current-target', + }); + }); + + test('recovers an unpublished tag on the repository root', () => { + const history = ['current-target', 'middle-target', 'root-target']; + + expect( + selectDatedTag( + '2026.08.22', + 'current-target', + { '2026.08.21': 'root-target' }, + new Set(), + history, + ), + ).toEqual({ + tag: '2026.08.21', + action: 'recover', + releaseTargetSha: 'root-target', + }); + }); +}); + +describe('release notes rendering', () => { + test('includes the exact release range', () => { + const notes = renderNotes([pr()], 'base123', 'target456'); + expect(notes).toContain('Changes: base123..target456'); + }); + + test('includes breaking-change guidance', () => { + const notes = renderNotes( + [pr({ body: 'BREAKING CHANGE: migrate this setting' })], + 'base123', + 'target456', + ); + expect(notes).toContain( + 'Action required: review the linked pull requests for database migrations', + ); + }); + + test('reports an empty exact range without referring to a wall-clock cutoff', () => { + const notes = renderNotes([], 'base123', 'target456'); + expect(notes).toContain('No merged pull requests found in this release range.'); + expect(notes).not.toContain('since the last tag'); + }); +});