Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CONTEXT.md
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.
Comment thread
mumenthalers marked this conversation as resolved.

## 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.
Comment thread
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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/publish-helper/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
48 changes: 48 additions & 0 deletions packages/publish-helper/src/github-release.spec.ts
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')
})
})
205 changes: 205 additions & 0 deletions packages/publish-helper/src/github-release.ts
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}`)
}
Loading