diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..470d188d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,33 @@ +# Library Release Communication + +This context defines the language for how this repository communicates published library versions through GitHub Releases. It exists to keep release discussion and implementation consistent. + +## Language + +**Release Run**: +A single publishing execution that may publish multiple packages and yields one consolidated GitHub Release. +_Avoid_: deploy, rollout, package release + +**Release Tag**: +The Git tag attached to a Release Run record in GitHub Releases, optionally suffixing a PR tag for pull request runs. +_Avoid_: version tag, package tag + +**PR Tag**: +The identifier derived from a pull request context and used as an optional suffix in a Release Tag. +_Avoid_: branch name, build number + +**Stable Release**: +A GitHub Release created from `main` that represents a non-prerelease publication event. +_Avoid_: production release, final publish + +**Pre-release**: +A GitHub Release created from a pull request run and explicitly marked as prerelease. +_Avoid_: draft release, beta by default + +**Package Version Set**: +The set of package name/version pairs published in one Release Run. +_Avoid_: changelog, artifact list + +**Changelog Link**: +A navigable reference from the GitHub Release body to package changelog entries relevant to the Release Run. +_Avoid_: release note text, commit log diff --git a/package-lock.json b/package-lock.json index 8e0ee2d7..2276029c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18796,7 +18796,7 @@ }, "packages/publish-helper": { "name": "@shiftcode/publish-helper", - "version": "6.0.0", + "version": "6.1.0-pr84.0", "license": "MIT", "dependencies": { "conventional-changelog-angular": "^8.0.0", diff --git a/packages/publish-helper/package.json b/packages/publish-helper/package.json index 3fa70595..59088c3c 100644 --- a/packages/publish-helper/package.json +++ b/packages/publish-helper/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/publish-helper", - "version": "6.0.0", + "version": "6.1.0-pr84.0", "description": "scripts for conventional (pre)releases", "repository": "https://github.com/shiftcode/sc-commons-public", "license": "MIT", diff --git a/packages/publish-helper/src/github-release.spec.ts b/packages/publish-helper/src/github-release.spec.ts new file mode 100644 index 00000000..77b0477a --- /dev/null +++ b/packages/publish-helper/src/github-release.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' + +import { buildReleaseBody, buildReleaseTag } from './github-release.js' + +describe('buildReleaseTag', () => { + test('stable release uses ISO timestamp prefix', () => { + const tag = buildReleaseTag(false, 'main') + expect(tag).toMatch(/^releases\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/) + }) + + test('pre-release uses stage prefix', () => { + expect(buildReleaseTag(true, 'pr84')).toBe('releases/pr84') + expect(buildReleaseTag(true, 'pr123')).toBe('releases/pr123') + }) +}) + +describe('buildReleaseBody', () => { + test('lists all published packages with changelog links', () => { + const tags = ['@shiftcode/branch-utilities@6.1.0', '@shiftcode/logger@3.0.0'] + const body = buildReleaseBody(tags, 'shiftcode/sc-commons-public', 'main') + expect(body).toContain('## Package Version Set') + expect(body).toContain('**@shiftcode/branch-utilities** `6.1.0`') + expect(body).toContain( + 'https://github.com/shiftcode/sc-commons-public/blob/main/packages/branch-utilities/CHANGELOG.md', + ) + expect(body).toContain('**@shiftcode/logger** `3.0.0`') + expect(body).toContain('https://github.com/shiftcode/sc-commons-public/blob/main/packages/logger/CHANGELOG.md') + }) + + test('returns empty section when no package tags provided', () => { + const body = buildReleaseBody([], 'shiftcode/sc-commons-public', 'main') + expect(body).toBe('## Package Version Set\n') + }) + + test('uses custom ref in changelog links when provided', () => { + const tags = ['@shiftcode/logger@3.0.0-pr84.0'] + const sha = 'abc1234def5678' + const body = buildReleaseBody(tags, 'shiftcode/sc-commons-public', sha) + expect(body).toContain(`https://github.com/shiftcode/sc-commons-public/blob/${sha}/packages/logger/CHANGELOG.md`) + }) + + test('skips tags with unexpected format', () => { + const tags = ['@shiftcode/logger@3.0.0', 'invalid-tag'] + const body = buildReleaseBody(tags, 'shiftcode/sc-commons-public', 'main') + expect(body).toContain('**@shiftcode/logger**') + expect(body).not.toContain('invalid-tag') + }) +}) diff --git a/packages/publish-helper/src/github-release.ts b/packages/publish-helper/src/github-release.ts new file mode 100644 index 00000000..a3095604 --- /dev/null +++ b/packages/publish-helper/src/github-release.ts @@ -0,0 +1,205 @@ +import * as https from 'node:https' + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message) + } +} + +/* eslint-disable @typescript-eslint/naming-convention */ +interface GithubRelease { + id: number + tag_name: string + html_url: string +} + +interface CreateReleasePayload { + tag_name: string + name: string + body: string + prerelease: boolean + target_commitish?: string +} +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Executes a GitHub REST API request and returns the parsed JSON response body. + */ +function githubApiRequest( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + repoPath: string, + token: string, + data?: object, +): Promise { + const payload = data ? JSON.stringify(data) : undefined + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.github.com', + path: repoPath, + method, + headers: { + Authorization: 'Bearer ' + token, + Accept: 'application/vnd.github+json', + 'User-Agent': 'publish-helper', + ...(payload + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + } + : {}), + }, + }, + (res) => { + res.setEncoding('utf8') + let body = '' + res.on('data', (chunk: string) => (body += chunk)) + res.on('end', () => { + if (res.statusCode === 204) { + resolve(undefined as T) + return + } + let parsed: T + try { + parsed = JSON.parse(body) as T + } catch { + reject(new Error(`Failed to parse GitHub API response (${res.statusCode}): ${body}`)) + return + } + if (res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300) { + resolve(parsed) + } else { + reject( + new ApiError( + res.statusCode ?? 0, + `GitHub API ${method} ${repoPath} failed with ${res.statusCode}: ${body}`, + ), + ) + } + }) + }, + ) + req.on('error', reject) + if (payload) { + req.write(payload) + } + req.end() + }) +} + +/** + * Returns the existing GitHub Release for the given tag name, or null if none exists. + */ +export async function getExistingRelease( + repository: string, + token: string, + tagName: string, +): Promise { + try { + return await githubApiRequest('GET', `/repos/${repository}/releases/tags/${tagName}`, token) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return null + } + throw err + } +} + +/** + * Deletes the GitHub Release with the given id. + */ +async function deleteRelease(repository: string, token: string, releaseId: number): Promise { + await githubApiRequest('DELETE', `/repos/${repository}/releases/${releaseId}`, token) +} + +/** + * Deletes the git tag with the given name from the remote repository. + */ +async function deleteRemoteTag(repository: string, token: string, tagName: string): Promise { + await githubApiRequest('DELETE', `/repos/${repository}/git/refs/tags/${tagName}`, token) +} + +/** + * Creates a new GitHub Release. + */ +async function createRelease(repository: string, token: string, payload: CreateReleasePayload): Promise { + return githubApiRequest('POST', `/repos/${repository}/releases`, token, payload) +} + +/** + * Builds the release tag name for a run. + * Stable runs: `releases/YYYY-MM-DDTHH-MM-SS` + * Pre-release runs: `releases/{stage}` (e.g. `releases/pr84`) + */ +export function buildReleaseTag(isPrerelease: boolean, stage: string): string { + if (isPrerelease) { + return `releases/${stage}` + } + const now = new Date() + const ts = now.toISOString().slice(0, 19).replace(/:/g, '-') + return `releases/${ts}` +} + +/** + * Builds the release body listing all published packages with Changelog Links. + */ +export function buildReleaseBody(packageTags: string[], repository: string, ref: string): string { + const lines: string[] = ['## Package Version Set', ''] + for (const tag of packageTags) { + const match = tag.match(/^(@shiftcode\/[^@]+)@(.+)$/) + if (!match) continue + const [, packageName, version] = match + const packageDir = packageName.replace('@shiftcode/', '') + const changelogUrl = `https://github.com/${repository}/blob/${ref}/packages/${packageDir}/CHANGELOG.md` + lines.push(`- **${packageName}** \`${version}\` — [Changelog](${changelogUrl})`) + } + return lines.join('\n') +} + +/** + * Publishes a consolidated GitHub Release for the given package tags. + * For pre-releases (PR runs) the existing release for the same tag is replaced. + */ +export async function publishConsolidatedRelease( + repository: string, + token: string, + packageTags: string[], + isPrerelease: boolean, + stage: string, + targetCommitish: string, +): Promise { + if (packageTags.length === 0) { + console.log('publish-libs:: No new package tags – skipping GitHub Release creation.') + return + } + + const releaseTag = buildReleaseTag(isPrerelease, stage) + // Derive date for stable release name from the computed tag to avoid a second Date() call. + const releaseName = isPrerelease + ? `Pre-release ${stage}` + : `Release ${releaseTag.replace('releases/', '').slice(0, 10)}` + const body = buildReleaseBody(packageTags, repository, targetCommitish) + + // For PR pre-releases, remove any existing release+tag so the new one points to the latest commit. + if (isPrerelease) { + const existing = await getExistingRelease(repository, token, releaseTag) + if (existing !== null) { + console.log(`publish-libs:: Replacing existing GitHub Release for ${releaseTag}`) + await deleteRelease(repository, token, existing.id) + await deleteRemoteTag(repository, token, releaseTag) + } + } + + const release = await createRelease(repository, token, { + tag_name: releaseTag, + name: releaseName, + body, + prerelease: isPrerelease, + target_commitish: targetCommitish, + }) + + console.log(`publish-libs:: GitHub Release created: ${release.html_url}`) +} diff --git a/packages/publish-helper/src/publish-lib.ts b/packages/publish-helper/src/publish-lib.ts index edd101c5..4fa2908e 100644 --- a/packages/publish-helper/src/publish-lib.ts +++ b/packages/publish-helper/src/publish-lib.ts @@ -15,7 +15,8 @@ import yargs from 'yargs' // eslint-disable-next-line import/no-internal-modules import { hideBin } from 'yargs/helpers' -import { exec } from './helpers.js' +import { publishConsolidatedRelease } from './github-release.js' +import { exec, execReturn } from './helpers.js' interface Options { canary: boolean @@ -39,7 +40,7 @@ async function run() { log('START') try { const options: Options = await argv - publish(options, process.env) + await publish(options, process.env) log('DONE') } catch (err) { log('FAIL') @@ -54,19 +55,21 @@ function log(...args: any[]) { console.log(`publish-libs::`, ...(args || []).map((v) => (typeof v === 'string' ? v : JSON.stringify(v)))) } -function publish(opts: Options, env: unknown) { +async function publish(opts: Options, env: unknown): Promise { const branchInfo = getBranchInfo(env) log(`start publishing for branch ${branchInfo.branchName}`) const isGhWorkflow = isGithubWorkflow(env) if (branchInfo.isProd) { - publishMaster(opts) + const ghToken = tryGetGhToken(env) + const repository = isGithubWorkflow(env) ? env.GITHUB_REPOSITORY : undefined + await publishMaster(opts, repository, ghToken) } else if (isGhWorkflow && branchInfo.isPr) { if (opts.canary) { publishCanary(opts, branchInfo) } else if (hasGithubContext(env)) { - publishPreRelease(opts, branchInfo, JSON.parse(env.GITHUB_CONTEXT) as GitHubContext, tryGetGhToken(env)) + await publishPreRelease(opts, branchInfo, JSON.parse(env.GITHUB_CONTEXT) as GitHubContext, tryGetGhToken(env)) } else { throw new Error('GITHUB_CONTEXT not defined as env var. Use `GITHUB_CONTEXT: ${{ toJson(github) }}` for action ') } @@ -75,22 +78,42 @@ function publish(opts: Options, env: unknown) { } } -function publishMaster(opts: Options) { +/** Returns the set of all local git tags. */ +function getTagsSet(): Set { + return new Set(execReturn('git tag -l').split('\n').filter(Boolean)) +} + +/** Returns package tags that are new since `tagsBefore` was captured. */ +function getNewPackageTags(tagsBefore: Set): string[] { + return [...getTagsSet()].filter((t) => !tagsBefore.has(t) && /^@shiftcode\/[^@]+@\d/.test(t)) +} + +async function publishMaster(opts: Options, repository?: string, ghToken?: string | null): Promise { log('PUBLISH MASTER') + const tagsBefore = getTagsSet() execLerna( 'version', ['--conventional-commits', '--conventional-graduate', '--changelog-preset conventional-changelog-angular'], opts.verbose, ) execLerna('publish', ['from-package'], opts.verbose, null) + + if (repository && ghToken) { + const newPackageTags = getNewPackageTags(tagsBefore) + log(`New package tags: ${newPackageTags.join(', ') || 'none'}`) + const targetCommitish = execReturn('git rev-parse HEAD') + await publishConsolidatedRelease(repository, ghToken, newPackageTags, false, 'main', targetCommitish) + } else { + log('Skipping GitHub Release creation: no repository or token available') + } } -function publishPreRelease( +async function publishPreRelease( opts: Options, branchInfo: BranchInfo, { event, repository }: GitHubContext, ghToken: string | null, -) { +): Promise { log('PUBLISH PreRelease') const preId = branchInfo.stage @@ -103,6 +126,7 @@ function publishPreRelease( log('pr.base.ref', event.pull_request.base.sha) throw new Error(`Cannot proceed since there's a new commit on branch ${event.pull_request.head.ref}`) } + const tagsBefore = getTagsSet() execLerna( 'version', [ @@ -116,8 +140,16 @@ function publishPreRelease( opts.verbose, ) execLerna('publish', [`from-package`, `--dist-tag ${preId}`], opts.verbose, null) + const newPackageTags = getNewPackageTags(tagsBefore) exec('git tag -d $(git describe --abbrev=0)') exec('git push') + + if (ghToken) { + log(`New package tags: ${newPackageTags.join(', ') || 'none'}`) + await publishConsolidatedRelease(repository, ghToken, newPackageTags, true, preId, event.pull_request.head.sha) + } else { + log('Skipping GitHub Release creation: no token available') + } } function publishCanary(opts: Options, branchInfo: BranchInfo) {