-
Notifications
You must be signed in to change notification settings - Fork 1
#84 - github-release-for-libs #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mumenthalers
wants to merge
6
commits into
main
Choose a base branch
from
#84-github-release-for-libs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b969a69
docs: add release communication domain glossary
mumenthalers c17145f
feat(publish-helper): create consolidated GitHub Releases on publish
Copilot d21a686
refactor(publish-helper): extract getNewPackageTags helper, derive re…
Copilot c0d4d46
build(release): next version [skip_build]
actions-user ddee74a
fix(publish-helper): capture new package tags before git tag deletion…
Copilot f97c73b
refactor(publish-helper): export ApiError, make buildReleaseBody ref …
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
mumenthalers marked this conversation as resolved.
|
||
| _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 | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>( | ||
| method: 'GET' | 'POST' | 'PATCH' | 'DELETE', | ||
| repoPath: string, | ||
| token: string, | ||
| data?: object, | ||
| ): Promise<T> { | ||
| const payload = data ? JSON.stringify(data) : undefined | ||
| return new Promise<T>((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)) | ||
|
Comment on lines
+58
to
+59
|
||
| 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<GithubRelease | null> { | ||
| try { | ||
| return await githubApiRequest<GithubRelease>('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<void> { | ||
| await githubApiRequest<void>('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<void> { | ||
| await githubApiRequest<void>('DELETE', `/repos/${repository}/git/refs/tags/${tagName}`, token) | ||
| } | ||
|
|
||
| /** | ||
| * Creates a new GitHub Release. | ||
| */ | ||
| async function createRelease(repository: string, token: string, payload: CreateReleasePayload): Promise<GithubRelease> { | ||
| return githubApiRequest<GithubRelease>('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<void> { | ||
| 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}`) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.