Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
41 changes: 41 additions & 0 deletions packages/publish-helper/src/github-release.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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')
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')
expect(body).toBe('## Package Version Set\n')
})

test('skips tags with unexpected format', () => {
const tags = ['@shiftcode/logger@3.0.0', 'invalid-tag']
const body = buildReleaseBody(tags, 'shiftcode/sc-commons-public')
expect(body).toContain('**@shiftcode/logger**')
expect(body).not.toContain('invalid-tag')
})
})
187 changes: 187 additions & 0 deletions packages/publish-helper/src/github-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as https from 'node:https'

/* 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) => {
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 Error(`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 {
return null
}
Comment on lines +101 to +108
}

/**
* 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): 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/main/packages/${packageDir}/CHANGELOG.md`
lines.push(`- **${packageName}** \`${version}\` — [Changelog](${changelogUrl})`)
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
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)
Comment thread
Copilot marked this conversation as resolved.
Outdated

// 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}`)
}
48 changes: 40 additions & 8 deletions packages/publish-helper/src/publish-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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<void> {
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 ')
}
Expand All @@ -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<string> {
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>): string[] {
return [...getTagsSet()].filter((t) => !tagsBefore.has(t) && /^@shiftcode\/[^@]+@\d/.test(t))
}

async function publishMaster(opts: Options, repository?: string, ghToken?: string | null): Promise<void> {
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<void> {
log('PUBLISH PreRelease')
const preId = branchInfo.stage

Expand All @@ -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',
[
Expand All @@ -118,6 +142,14 @@ function publishPreRelease(
execLerna('publish', [`from-package`, `--dist-tag ${preId}`], opts.verbose, null)
exec('git tag -d $(git describe --abbrev=0)')
exec('git push')

if (ghToken) {
const newPackageTags = getNewPackageTags(tagsBefore)
log(`New package tags: ${newPackageTags.join(', ') || 'none'}`)
await publishConsolidatedRelease(repository, ghToken, newPackageTags, true, preId, event.pull_request.head.sha)
Comment on lines 144 to +149
} else {
log('Skipping GitHub Release creation: no token available')
}
}

function publishCanary(opts: Options, branchInfo: BranchInfo) {
Expand Down
Loading