Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/npm-release-channel.md
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.
9 changes: 6 additions & 3 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ jobs:
# skips install on a pack cache hit.
- uses: gtbuchanan/tooling/.github/actions/pnpm-tasks@main

# `gtb publish` runs `changeset publish` (npm) then every non-npm channel.
# OIDC trusted publishing + provenance flow through the ambient env set
# here (the `release` environment + `id-token: write` grant them).
# `gtb publish` runs `changeset publish` (npm), creates the GitHub
# releases that land each published package's release tag (changesets
# only tags the local clone), then every non-npm channel. OIDC trusted
# publishing + provenance flow through the ambient env set here (the
# `release` environment + `id-token: write` grant them); GH_TOKEN covers
# the release creation.
- env:
GH_TOKEN: ${{ github.token }}
NPM_CONFIG_PROVENANCE: 'true'
Expand Down
20 changes: 13 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ coverage, setupFiles, and mock reset.
(root-only) `PklProject` — detection, `typecheck:pkl`, and the `*.pkl`
cache inputs are all top-level, not recursive. Keep sources at the root
rather than under a `src/` subdirectory.
- **Release tag mirrors changesets** (`pklReleaseTag`). The
- **Release tag mirrors changesets** (`releaseTag`). The
`packageZipUrl` tag segment and the `gtb publish` release tag follow
changesets' own convention by repo shape: plain `v<version>` for a
single-package (root) repo, unscoped `<name>@<version>` for a monorepo
Expand Down Expand Up @@ -321,12 +321,18 @@ through `package.json` scripts backed by `gtb` leaf commands.
execs it without a shell, so a `&&` chain would be passed to changesets
as bogus args.
- **`gtb publish`** runs `changeset publish` (npm, honoring the ambient
OIDC + `NPM_CONFIG_PROVENANCE` env) and then dispatches every non-npm
channel (the Pkl zip to a `hk-config@<ver>` GitHub release), using the
assets the `pack` step already built. Each channel is idempotent and
no-ops when the workspace ships no such package, so CD runs `gtb publish`
unconditionally; a future `.NET` channel adds just another
`executePublish*` with no workflow change.
OIDC + `NPM_CONFIG_PROVENANCE` env), then creates a GitHub release per
published npm package, and then dispatches every non-npm channel (the
Pkl zip to a `hk-config@<ver>` GitHub release), using the assets the
`pack` step already built. The API-side releases are what land the
release tags on GitHub — `changeset publish` only creates tags in the
local clone, and changesets/action (which would push them) never runs
the publish phase here. Creating releases instead of pushing tags keeps
a manual `gtb publish` reproducible locally (only `gh` auth needed, with
the `workflow` scope) and sidesteps ref-count push rules. Each channel
is idempotent and no-ops when the workspace ships no such package, so CD
runs `gtb publish` unconditionally; a future `.NET` channel adds just
another `executePublish*` with no workflow change.
- The `gtb-from-source` input (default `false`) flips the gtb invocation:
consumers run their installed bin (`pnpm exec gtb`); the only repo that
vendors `@gtbuchanan/cli` as a workspace package (tooling itself) sets it
Expand Down
35 changes: 23 additions & 12 deletions packages/cli/src/commands/root/publish.ts
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,
}),
});
},
});
159 changes: 159 additions & 0 deletions packages/cli/src/lib/github-release.ts
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}`);
}
};
12 changes: 0 additions & 12 deletions packages/cli/src/lib/manifest-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,6 @@ export interface ManifestContext {
readonly repoPath: string;
}

/**
* Release tag for a Pkl package, mirroring changesets' convention: plain
* `v<version>` for a single-package (root) repo, `<name>@<version>` for a
* monorepo member (where the name disambiguates). The asset basename stays
* `<name>@<version>` either way — pkl derives it from `PklProject`.
*/
export const pklReleaseTag = (
name: string,
version: string,
isMonorepo: boolean,
): string => (isMonorepo ? `${name}@${version}` : `v${version}`);

/**
* Renders the native manifest for one package "kind". `render` may read the
* existing file and patch it (e.g. a `.csproj`), but the Pkl writer fully
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/lib/npm-release.ts
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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading