-
Notifications
You must be signed in to change notification settings - Fork 0
Create GitHub releases for npm packages in gtb publish #341
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
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
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,10 @@ | ||
| --- | ||
| '@gtbuchanan/cli': minor | ||
| --- | ||
|
|
||
| `gtb publish` now creates a GitHub release for every published npm package | ||
| (title = tag, notes = the version's CHANGELOG section), landing the release | ||
| tag on GitHub via the API — previously `changeset publish` created tags only | ||
| in the runner's local clone, so npm releases shipped untagged. Both release | ||
| channels (npm and Pkl) now also pass `--target HEAD` so a local re-run tags | ||
| the commit actually being published rather than the remote default branch. |
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
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 |
|---|---|---|
| @@ -1,46 +1,57 @@ | ||
| import { defineCommand } from 'citty'; | ||
| import { createLogger } from '../../lib/logger.ts'; | ||
| import { executePublishNpmReleases } from '../../lib/npm-release.ts'; | ||
| import { executePublishPkl } from '../../lib/pkl-release.ts'; | ||
| import { type RunOptions, capture, run } from '../../lib/process.ts'; | ||
| import { rootNames } from './names.ts'; | ||
|
|
||
| /** Injected side effects for {@link executePublish}. */ | ||
| export interface PublishDeps { | ||
| readonly publishNonNpm: () => Promise<void>; | ||
| readonly releaseNpm: () => Promise<void>; | ||
| readonly run: (command: string, options?: RunOptions) => Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * Publishes every package for the current release: `changeset publish` to the | ||
| * npm registry first (honoring the ambient OIDC trusted-publishing and | ||
| * provenance env that CD sets on the step), then each non-npm channel. Both | ||
| * halves are idempotent — `changeset publish` skips versions already on the | ||
| * registry, and the non-npm channels no-op when the workspace ships no such | ||
| * package — so CD runs this unconditionally. | ||
| * provenance env that CD sets on the step), then the GitHub releases for the | ||
| * packages it published (which land the release tags — `changeset publish` | ||
| * only creates tags in the local clone), then each non-npm channel. Every | ||
| * step is idempotent — `changeset publish` skips versions already on the | ||
| * registry, and both release channels skip tags that already have a release — | ||
| * so CD runs this unconditionally and a local re-run resumes where a partial | ||
| * failure left off. | ||
| */ | ||
| export const executePublish = async ({ | ||
| publishNonNpm, | ||
| releaseNpm, | ||
| run: runCommand, | ||
| }: PublishDeps): Promise<void> => { | ||
| await runCommand('pnpm', { args: ['exec', 'changeset', 'publish'] }); | ||
| await releaseNpm(); | ||
| await publishNonNpm(); | ||
| }; | ||
|
|
||
| /** | ||
| * `gtb publish` — publishes all packages for the current release: npm via | ||
| * changesets, then every non-npm channel. Channel dispatch for the latter | ||
| * lives in {@link executePublishPkl} (today the Pkl GitHub-release channel; a | ||
| * future channel adds its own `executePublish*`). | ||
| * changesets (plus per-package GitHub releases/tags), then every non-npm | ||
| * channel. Channel dispatch for the latter lives in {@link executePublishPkl} | ||
| * (today the Pkl GitHub-release channel; a future channel adds its own | ||
| * `executePublish*`). | ||
| */ | ||
| export const publish = defineCommand({ | ||
| meta: { | ||
| description: 'Publish all packages for the current release (idempotent)', | ||
| name: rootNames.publish, | ||
| }, | ||
| run: () => | ||
| executePublish({ | ||
| publishNonNpm: () => | ||
| executePublishPkl({ capture, cwd: process.cwd(), logger: createLogger(), run }), | ||
| run: () => { | ||
| const deps = { capture, cwd: process.cwd(), logger: createLogger(), run }; | ||
|
|
||
| return executePublish({ | ||
| publishNonNpm: () => executePublishPkl(deps), | ||
| releaseNpm: () => executePublishNpmReleases(deps), | ||
| run, | ||
| }), | ||
| }); | ||
| }, | ||
| }); |
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,159 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import type { Logger } from './logger.ts'; | ||
| import type { RunOptions } from './process.ts'; | ||
|
|
||
| /** | ||
| * Side-effecting I/O the GitHub-release channels depend on. Injected so the | ||
| * orchestration (discover packages, skip-if-exists, create) is unit-testable | ||
| * without spawning gh; the citty wrapper wires the real implementations. | ||
| */ | ||
| export interface GithubReleaseDeps { | ||
| readonly capture: (command: string, args: readonly string[]) => Promise<string>; | ||
| readonly cwd: string; | ||
| readonly logger: Logger; | ||
| readonly run: (command: string, options?: RunOptions) => Promise<void>; | ||
| } | ||
|
|
||
| /** | ||
| * Release tag following changesets' own convention by repo shape: plain | ||
| * `v<version>` for a single-package (root) repo, `<name>@<version>` for a | ||
| * monorepo member (where the name disambiguates). | ||
| */ | ||
| export const releaseTag = ( | ||
| name: string, | ||
| version: string, | ||
| isMonorepo: boolean, | ||
| ): string => (isMonorepo ? `${name}@${version}` : `v${version}`); | ||
|
|
||
| /** | ||
| * Extracts a version's section from CHANGELOG.md content — the `## <version>` | ||
| * heading's body up to the next `## ` (or EOF) — so the GitHub release notes | ||
| * match what changesets writes for npm packages. Returns undefined when the | ||
| * section is absent or empty. | ||
| */ | ||
| export const extractChangelogNotes = ( | ||
| changelog: string, | ||
| version: string, | ||
| ): string | undefined => { | ||
| const lines = changelog.split('\n'); | ||
| const start = lines.findIndex(line => line.trim() === `## ${version}`); | ||
| if (start === -1) { | ||
| return undefined; | ||
| } | ||
| const after = lines.slice(start + 1); | ||
| const end = after.findIndex(line => line.startsWith('## ')); | ||
| const section = (end === -1 ? after : after.slice(0, end)).join('\n').trim(); | ||
|
|
||
| return section === '' ? undefined : section; | ||
| }; | ||
|
|
||
| /** Reads the CHANGELOG.md section for the version, if any. */ | ||
| export const releaseNotes = ( | ||
| pkgDir: string, | ||
| version: string | undefined, | ||
| ): string | undefined => { | ||
| if (version === undefined) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| return extractChangelogNotes(readFileSync(path.join(pkgDir, 'CHANGELOG.md'), 'utf8'), version); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| }; | ||
|
|
||
| /** True when a release already exists for the tag (`gh release view` exits 0). */ | ||
| export const releaseExists = async ( | ||
| deps: GithubReleaseDeps, | ||
| tag: string, | ||
| ): Promise<boolean> => { | ||
| try { | ||
| await deps.capture('gh', ['release', 'view', tag]); | ||
|
|
||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Resolves the commit a new release's tag should point at: the local HEAD. | ||
| * Passing it explicitly (`--target`) matters for reproducibility — without | ||
| * it, gh targets the *remote default branch* HEAD, which is only coincidentally | ||
| * right when publishing from a fresh CI checkout and silently wrong for a | ||
| * local re-run on an older commit. | ||
| */ | ||
| export const resolveHeadSha = async (deps: GithubReleaseDeps): Promise<string> => { | ||
| const sha = await deps.capture('git', ['rev-parse', 'HEAD']); | ||
|
|
||
| return sha.trim(); | ||
| }; | ||
|
|
||
| /** A planned GitHub release: one tag, its notes, and any uploadable assets. */ | ||
| export interface GithubReleasePlan { | ||
| readonly assets?: readonly string[]; | ||
| readonly notes: string; | ||
| readonly tag: string; | ||
| readonly target: string; | ||
| } | ||
|
|
||
| /** | ||
| * Creates the release via gh, which also creates the tag server-side at | ||
| * `target` — no git push needed, keeping the whole flow reproducible from a | ||
| * local `gtb publish`. Mirrors changesets' release shape: title = tag, body = | ||
| * the changelog section. | ||
| */ | ||
| export const createGithubRelease = ( | ||
| deps: GithubReleaseDeps, | ||
| plan: GithubReleasePlan, | ||
| ): Promise<void> => | ||
| deps.run('gh', { | ||
| args: [ | ||
| 'release', 'create', plan.tag, | ||
| '--target', plan.target, | ||
| '--title', plan.tag, | ||
| '--notes', plan.notes, | ||
| ...(plan.assets ?? []), | ||
| ], | ||
| }); | ||
|
|
||
| /** | ||
| * A package pending release: its resolved identity (validated by the channel, | ||
| * which knows its own manifest format) plus any uploadable assets. | ||
| */ | ||
| export interface PendingRelease { | ||
| readonly assets?: readonly string[]; | ||
| readonly dir: string; | ||
| readonly name: string; | ||
| readonly version: string; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a GitHub release for each pending package, idempotently: a tag that | ||
| * already has a release is skipped, so re-running on an unchanged version is | ||
| * a no-op (and a run that died between channels backfills on the next). The | ||
| * shared loop for every release channel — tag convention, changelog notes, | ||
| * skip-if-exists, HEAD targeting — so channels can't drift apart. | ||
| */ | ||
| export const publishReleases = async ( | ||
| deps: GithubReleaseDeps, | ||
| packages: readonly PendingRelease[], | ||
| isMonorepo: boolean, | ||
| ): Promise<void> => { | ||
| const target = await resolveHeadSha(deps); | ||
| for (const { assets, dir, name, version } of packages) { | ||
| const tag = releaseTag(name, version, isMonorepo); | ||
| if (await releaseExists(deps, tag)) { | ||
| deps.logger.info(`release ${tag} already exists — skipping`); | ||
| continue; | ||
| } | ||
| await createGithubRelease(deps, { | ||
| ...(assets !== undefined && { assets }), | ||
| notes: releaseNotes(dir, version) ?? tag, | ||
| tag, | ||
| target, | ||
| }); | ||
| deps.logger.info(`created release ${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
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 @@ | ||
| import { discoverWorkspace } from './discovery.ts'; | ||
| import { type GithubReleaseDeps, publishReleases } from './github-release.ts'; | ||
| import { readParsedManifest } from './workspace.ts'; | ||
|
|
||
| /** | ||
| * Creates a GitHub release for every published npm package's current version, | ||
| * idempotently: a tag that already has a release is skipped, so re-running on | ||
| * an unchanged version is a no-op. `changeset publish` creates its release | ||
| * tags only in the local clone and changesets/action (which would push them) | ||
| * is not part of this pipeline — creating the release through the API lands | ||
| * the tag on GitHub without a git push, mirroring the Pkl channel and keeping | ||
| * the whole release reproducible from a local `gtb publish`. No assets: the | ||
| * npm registry hosts the artifact; the release is the tag + notes. | ||
| */ | ||
| export const executePublishNpmReleases = async (deps: GithubReleaseDeps): Promise<void> => { | ||
| const discovery = discoverWorkspace({ cwd: deps.cwd }); | ||
| const packages = discovery.packages.filter(pkg => pkg.isPublished); | ||
| if (packages.length === 0) { | ||
| deps.logger.info('no published npm packages to release'); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| const pending = packages.map((pkg) => { | ||
| const { name, version } = readParsedManifest(pkg.dir); | ||
| if (name === undefined || version === undefined) { | ||
| throw new Error(`${pkg.dir}: package.json is missing name or version`); | ||
| } | ||
|
|
||
| return { dir: pkg.dir, name, version }; | ||
| }); | ||
| await publishReleases(deps, pending, discovery.isMonorepo); | ||
| }; | ||
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.