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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
id: dec-create-changelog-backed-github-releases-in-publi--fh6bsn7jr296q93d
effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve
title: 'Create changelog-backed GitHub releases in publish:ci'
state: accepted
created_at: '2026-08-22T19:28:52.332Z'
derives_from:
- con-public-npm-releases-use-one-lockstep-version--0c4eg8frxys4fv2s
---

## Context

The 1.0.1 packages reached npm without a GitHub release or release notes. A manual second step can be missed, can draft notes from a different source, and can leave npm and GitHub with different release records. The repository already keeps release notes in `CHANGELOG.md` and publishes all public packages at one lockstep version.

## Decision

Treat npm publication and its GitHub release as one operator step in `pnpm publish:ci`. Before npm publication, require a non-empty lockstep version section in `CHANGELOG.md`, verify GitHub access and the remote tag state, and format GitHub notes from that section. Publish every public package first. Then create and push the annotated `v<version>` tag and create the GitHub release from the prepared notes. A retry skips packages and a GitHub release only when the existing release body matches those notes, and it rejects a tag at another commit.

## Alternatives

We rejected manual `gh release create`, notes drafted in the GitHub UI, and a separate release job. Each option splits one release across two sources or two triggers and preserves the failure mode that left 1.0.1 without notes. We also rejected creating the remote tag or GitHub release before npm because that could announce a release whose packages did not publish.

## Consequences

`publish:ci` needs npm, git, and GitHub credentials. Its dry run must test the same hard gates without writing. A failure after npm may still need a retry, so the tag and release steps must be idempotent. `CHANGELOG.md` is the source for public release notes.

## Reversal criteria

Split the GitHub release into a separate job only if the release system can prove it runs once for every successful lockstep npm publication, consumes the same committed changelog section, checks the exact release commit, and exposes a failed or missing GitHub release as a blocking release error.
85 changes: 61 additions & 24 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ https://github.com/FlatbreadLabs/oven.

There are two steps:

1. Bump every public package to one version
2. Publish the release
1. Bump every public package to one version and file `CHANGELOG.md`
2. Publish the release to npm and GitHub together

### Lockstep versions

Expand Down Expand Up @@ -123,17 +123,35 @@ What the script does:
- Skipping packages that are not yet published on npm
- Passes every public package manifest to one `bumpp` command so one chosen
version is written across the set
- Moves `CHANGELOG.md` Unreleased list items under a new `## <version>`
heading and leaves the empty Unreleased section (plus any trailing file
note) in place. Those filed items become the GitHub release notes, after
the centered Flatbread mark and a `Flatbread - v<version> Release Notes`
title.
- Stops before publishing if any public package version differs

Notes:

- Commit the version bumps after the script completes. For example:
- Commit the version bumps and changelog after the script completes. For
example:

```bash
git add packages/**/package.json
git add packages/**/package.json packages/proof/skills/proof/release.json .agents/skills/proof/release.json CHANGELOG.md
git commit -m "release: bump public packages"
```

- If Unreleased has no list items, the bump creates an empty version heading
and warns instead of claiming that it moved notes. Add release notes under
that heading before publishing.

- If versions are already bumped but Unreleased still has items, file them
without bumping again:

```bash
pnpm changelog:shift --dry-run
pnpm changelog:shift
```

- Debugging: set `FLATBREAD_BUMP_DEBUG=1` to see detection details

```bash
Expand All @@ -142,9 +160,11 @@ Notes:

- New public packages join the same version as the rest of the release set.

### 2) Publish packages
### 2) Publish packages and the GitHub release
Comment thread
cursor[bot] marked this conversation as resolved.

> Note: you must have access permissions on NPM
> Note: you must have access permissions on NPM and a logged-in GitHub CLI
> (`gh auth status`). Push the release commit before publishing so GitHub
> can see the SHA.

When changing the Proof skill, edit the source files under
`packages/proof/skills/proof/`, then run these checks in order:
Expand All @@ -159,17 +179,37 @@ Bump and publish `@flatbread/proof` and `flatbread` together when the
skill and runtime need matching versions. The publish script checks the copied
skill files and package contents first. It then publishes ordinary packages,
`@flatbread/proof`, and finally `flatbread`, stopping at the first
failure.
failure. After every package is on npm, it creates the annotated
`v<flatbread-version>` tag, pushes that tag to `origin`, and opens a GitHub
release. The notes start with the centered Flatbread mark and a
`Flatbread - v<version> Release Notes` title, then the filed changelog
section.

Publish all public packages (the script checks for one shared version, builds,
then attempts to publish each package):
Preview both sides without publishing:

```bash
pnpm publish:dry
```

The dry run uses the same clean-tree, changelog, GitHub CLI, pushed-commit,
remote-tag, and npm registry gates as a real publish. It exits with an error
if the release is not ready.

Publish all public packages and the matching GitHub release (the script
checks for one shared version, builds, then attempts to publish each
package):

```bash
pnpm publish:ci
```

Details:

- Requires a clean working tree, a `CHANGELOG.md` section for the release
version, and `gh` authenticated against this repository
- Before publishing any package, checks `origin` for the release tag. An absent
tag or a tag on the release commit is safe. A tag on another commit stops the
release.
- Builds the repo: `pnpm run build`
- Iterates public packages in dependency-safe deterministic order and runs:

Expand All @@ -183,24 +223,16 @@ Details:
network, and other errors abort before that package is published.
- If a release stops after some packages publish, rerun `pnpm publish:ci`
safely. Exact versions already published are skipped, and the script resumes
with the first package that still needs publishing.
with the first package that still needs publishing. The GitHub release is
created only after every package is on npm. The script writes the annotated
tag locally, pushes `refs/tags/v<version>` to `origin`, then runs
`gh release create --verify-tag`. An existing GitHub release is skipped only
when its body matches the filed changelog notes; a mismatch stops the release.
- Unpublished packages will be published for the first time
- Dist-tags (alpha/beta) are currently disabled in the script. If you need them, bump with a pre-release version (`x.y.z-alpha.n`) and add tagging logic in `scripts/publish.ts`

### Post-publish

- Only after every package publishes successfully, create an annotated,
immutable `v<flatbread-version>` Git tag at the exact release commit SHA
printed by `pnpm publish:ci`, then push the release commit and tag:

```bash
git tag -a v<flatbread-version> <release-commit-sha> -m "Release v<flatbread-version>"
git push
git push origin v<flatbread-version>
```

Protect release tags in the repository settings so they cannot be moved or
deleted after publication.
Protect release tags in the repository settings so they cannot be moved or
deleted after publication.

End users install the skill from that release tag and install the matching
`flatbread` version. Replace `X` with the released version — `1.0.0` for the
Expand All @@ -226,7 +258,12 @@ install a newer release tag and its matching `flatbread` version.
- Unpublished packages are skipped during bump but will be published during `publish:ci`

- First-time publish of a new package

- Set an appropriate initial version in `packages/<name>/package.json`
- Run `pnpm publish:ci` (the script will publish it)

- Publish stops because Unreleased still has items
- Versions were bumped without filing the changelog. Run
`pnpm changelog:shift`, commit `CHANGELOG.md`, then publish again.

If something’s unclear or you hit an issue, please open an issue or ask in Slack.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"play:build": "pnpm build && cd examples/nextjs && pnpm build",
"prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types",
"publish:ci": "esno scripts/publish.ts",
"publish:dry": "esno scripts/publish.ts --dry-run",
"changelog:shift": "esno scripts/changelog.ts",
"bump": "esno scripts/bumpVersions.ts",
"test:ava": "ava",
"test:vitest": "pnpm --filter @flatbread/codegen --filter @flatbread/utils test",
Expand Down
27 changes: 27 additions & 0 deletions scripts/bumpVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getMonorepoPublicPackages,
PathedFlatbreadPackage,
} from './utils/packageManifest';
import { prepareReleaseChangelog } from './utils/changelog';

type PackageChangeInfo = PathedFlatbreadPackage & {
changedSinceLastPublish: boolean;
Expand Down Expand Up @@ -362,6 +363,32 @@ async function main(): Promise<void> {
)}\n`
);
execSync('pnpm skills:sync', { stdio: 'inherit' });

const changelogMarkdown = await fs.readFile('CHANGELOG.md', 'utf8');
const preparedChangelog = prepareReleaseChangelog(
changelogMarkdown,
flatbreadManifest.version
);
if (preparedChangelog.didShift) {
await fs.writeFile('CHANGELOG.md', preparedChangelog.markdown);
if (preparedChangelog.didMoveItems) {
console.log(
colors
.bold()
.green(
`Moved CHANGELOG.md Unreleased items under ## ${flatbreadManifest.version}`
)
);
} else {
console.log(
colors
.bold()
.yellow(
`Created empty CHANGELOG.md heading ## ${flatbreadManifest.version}. Add release notes before publishing.`
)
);
}
}
}

const invokedScript = process.argv[1]
Expand Down
31 changes: 31 additions & 0 deletions scripts/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import test from 'ava';
import { parseChangelogArgs } from './changelog';

test('parseChangelogArgs accepts a dry run and optional version', (t) => {
t.deepEqual(parseChangelogArgs(['--dry-run']), {
dryRun: true,
version: undefined,
});
t.deepEqual(parseChangelogArgs(['--dry-run', '--version', '1.2.3']), {
dryRun: true,
version: '1.2.3',
});
t.deepEqual(parseChangelogArgs(['--version=1.2.3']), {
dryRun: false,
version: '1.2.3',
});
t.deepEqual(parseChangelogArgs(['--', '--dry-run']), {
dryRun: true,
version: undefined,
});
});

test('parseChangelogArgs rejects a missing version value', (t) => {
const error = t.throws(() => parseChangelogArgs(['--version']));
t.regex(error?.message ?? '', /requires a semver value/);
});

test('parseChangelogArgs rejects unknown flags', (t) => {
const error = t.throws(() => parseChangelogArgs(['--oops']));
t.regex(error?.message ?? '', /Unknown changelog flag/);
});
135 changes: 135 additions & 0 deletions scripts/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { promises as fs } from 'node:fs';
import { fileURLToPath } from 'node:url';
import colors from 'kleur';
import {
formatGithubReleaseNotes,
prepareReleaseChangelog,
} from './utils/changelog';

export type ChangelogCliOptions = {
readonly dryRun: boolean;
readonly version?: string;
};

export function parseChangelogArgs(
argv: readonly string[]
): ChangelogCliOptions {
let dryRun = false;
let version: string | undefined;

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--') continue;
if (arg === '--dry-run') {
dryRun = true;
continue;
}
if (arg === '--version') {
version = argv[index + 1];
if (!version || version.startsWith('-')) {
throw new Error('--version requires a semver value');
}
index += 1;
continue;
}
if (arg.startsWith('--version=')) {
version = arg.slice('--version='.length);
if (!version) throw new Error('--version requires a semver value');
continue;
}
throw new Error(`Unknown changelog flag: ${arg}`);
}

return { dryRun, version };
}

export async function readLockstepVersion(): Promise<string> {
const manifest = JSON.parse(
await fs.readFile('packages/flatbread/package.json', 'utf8')
) as { version?: string };
if (!manifest.version) {
throw new Error('packages/flatbread/package.json is missing a version');
}
return manifest.version;
}

export async function shiftChangelog(
options: ChangelogCliOptions
): Promise<void> {
const version = options.version ?? (await readLockstepVersion());
const path = 'CHANGELOG.md';
const markdown = await fs.readFile(path, 'utf8');
const prepared = prepareReleaseChangelog(markdown, version);

if (options.dryRun) {
console.log(
colors.bold().yellow(`Dry run: changelog for ${version} (no file write)`)
);
}

if (!prepared.didShift) {
console.log(
colors
.bold()
.green(
`CHANGELOG.md already has ## ${version}; Unreleased has no items to move`
)
);
printNotes(prepared.notes, version);
Comment on lines +70 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW, not blocking: when ## <version> already exists with an empty body, prepareReleaseChangelog returns didShift: false and notes: fallbackReleaseNotes(version) (Release ${version}.). This branch then prints a green “already has” line and those synthetic notes.

publish:ci still rejects the empty section, so empty GitHub notes cannot ship. An operator who only runs changelog:shift / --dry-run can still treat that printed body as ready.

Use the same yellow empty-section warning as the new-heading path (didShift && !didMoveItems just below), and skip printing fallback notes until the section has real list items.

return;
}

if (!prepared.didMoveItems) {
if (options.dryRun) {
console.log(
colors
.bold()
.yellow(
`Would create empty heading ## ${version}. Add release notes before publishing.`
)
);
printPreview(prepared.markdown);
return;
}

await fs.writeFile(path, prepared.markdown);
console.log(
colors
.bold()
.yellow(
`Created empty heading ## ${version}. Add release notes before publishing.`
)
);
return;
}

if (options.dryRun) {
printNotes(prepared.notes, version);
printPreview(prepared.markdown);
return;
}

await fs.writeFile(path, prepared.markdown);
console.log(
colors.bold().green(`Moved Unreleased items under ## ${version}`)
);
printNotes(prepared.notes, version);
}

function printNotes(notes: string, version: string): void {
console.log(colors.bold('\nGitHub release notes\n'));
console.log(formatGithubReleaseNotes(notes, version));
}

function printPreview(markdown: string): void {
const lines = markdown.split('\n');
const preview = lines.slice(0, 40).join('\n');
const omitted =
lines.length > 40 ? `\n… ${lines.length - 40} more lines` : '';
console.log(colors.bold('\nCHANGELOG.md preview\n'));
console.log(`${preview}${omitted}`);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
await shiftChangelog(parseChangelogArgs(process.argv.slice(2)));
}
Loading
Loading