Skip to content
Draft
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
26 changes: 26 additions & 0 deletions .github/workflows/release.yml

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

27 changes: 27 additions & 0 deletions .github/workflows/release.yml.genie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,33 @@ fi`,
name: 'Publish stable package release',
run: runDevenvTasksBefore('release:stable:publish'),
},
{
name: 'Create or update GitHub Release',
run: `set -euo pipefail
tag="v\${LIVESTORE_RELEASE_VERSION}"
notes_path="release/release-notes.md"

# The release PR commits nonempty notes. Missing or blank notes mean the plan is malformed.
if [[ ! -f "$notes_path" ]] || ! grep -q '[^[:space:]]' "$notes_path"; then
echo "::error::Missing or empty committed GitHub Release notes: $notes_path" >&2
exit 1
fi

if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$notes_path"
else
prerelease_args=()
if [[ "$LIVESTORE_RELEASE_VERSION" == *-* ]]; then
prerelease_args+=(--prerelease)
fi
gh release create "$tag" \\
--repo "$GITHUB_REPOSITORY" \\
--target "$GITHUB_SHA" \\
--title "$tag" \\
--notes-file "$notes_path" \\
"\${prerelease_args[@]}"
fi`,
},
{
name: 'Certify DevTools artifact liveness',
run: runDevenvTasksBefore('release:devtools-artifact:certify-liveness:no-install'),
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

For maintainers and contributors:

- **Release tooling:** GitHub Release and git tag creation now runs directly
after npm publication instead of depending on the DevTools artifact publisher
([#1497](https://github.com/livestorejs/livestore/issues/1497)).
- **Tooling:** Shell entry no longer runs the full TypeScript build after
dependency and generated-source setup. The shared Effect-utils
`otel:profile:setup` task captures the strict setup graph through native
Expand Down
11 changes: 6 additions & 5 deletions context/03-delivery/02-release/release-workflows-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,19 @@ the `release:notes:extract` task (which calls
release-plan PR so reviewers see exactly what will land on the GitHub Release
page.

The DevTools artifact publish step then uses that file when it creates or
updates the GitHub Release tag:
After npm publication succeeds, the `publish-release` job uses that file when
it creates or updates the GitHub Release tag:

- On `gh release create`, it passes `--notes-file release/release-notes.md`
instead of the legacy hardcoded `Release <version>` body.
and creates the git tag for the published version.
- On subsequent reruns (the release already exists), it also calls
`gh release edit --notes-file release/release-notes.md` so a corrected
`CHANGELOG.md` section actually lands on the GitHub Release page.

If `release/release-notes.md` is missing at publish time, the publish step
falls back to the legacy `Release <version>` body and logs a warning. To
refresh it locally for a planned release:
fails because the committed release plan is malformed. It does not fall back
to the legacy `Release <version>` body, which could silently publish stale
notes. To refresh the file locally for a planned release:

```bash
mono release extract-release-notes
Expand Down
62 changes: 3 additions & 59 deletions scripts/src/commands/devtools-artifact.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
createWriteStream,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { createWriteStream, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { get as httpGet } from 'node:http'
import { get as httpsGet } from 'node:https'
Expand Down Expand Up @@ -587,56 +578,9 @@ const materializeChromeZipAsset = (version: string, chromeZipPath: string, workD
return assetPath
}

/**
* Resolves the release notes file emitted by `mono release extract-release-notes`.
* Returns `undefined` (with a warning) when the file is missing so DevTools-artifact
* publishing remains unblocked. In that case the GitHub Release falls back to the
* legacy `Release <version>` body.
*/
const resolveReleaseNotesPath = (version: string): string | undefined => {
const workspaceRoot = process.env.WORKSPACE_ROOT ?? process.cwd()
const candidate = path.resolve(workspaceRoot, 'release/release-notes.md')
if (existsSync(candidate) === false) {
console.warn(
`[publishChromeZipReleaseAsset] release/release-notes.md not found for v${version}; ` +
'falling back to "Release <version>" body. Run `mono release extract-release-notes` to populate it.',
)
return undefined
}
return candidate
}

const publishChromeZipReleaseAsset = (version: string, assetPath: string) => {
const uploadChromeZipReleaseAsset = (version: string, assetPath: string) => {
const repo = process.env.GITHUB_REPOSITORY ?? 'livestorejs/livestore'
const tag = `v${version}`
const notesPath = resolveReleaseNotesPath(version)

const releaseExists = spawnSync('gh', ['release', 'view', tag, '--repo', repo], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})

if (releaseExists.status === 0) {
/**
* Refresh the body on reruns so a corrected `release/release-notes.md` actually lands
* on the GitHub Release page. Without this, only the very first create call sets the
* body, and later DevTools-artifact uploads silently leave a stale "Release <version>"
* body in place — which is exactly the regression we hit on v0.4.0.
*/
if (notesPath !== undefined) {
run(['gh', 'release', 'edit', tag, '--repo', repo, '--notes-file', notesPath])
}
} else {
const createArgs = ['gh', 'release', 'create', tag, '--repo', repo, '--title', tag]
if (notesPath === undefined) {
createArgs.push('--notes', `Release ${version}`)
} else {
createArgs.push('--notes-file', notesPath)
}
if (version.includes('-') === true) createArgs.push('--prerelease')
run(createArgs)
}

run(['gh', 'release', 'upload', tag, assetPath, '--repo', repo, '--clobber'])
}

Expand Down Expand Up @@ -751,7 +695,7 @@ const repackArtifact = async (flags: Map<string, string | true>) => {
if (isSnapshotVersion(version) === true) {
console.log(`Snapshot Chrome zip prepared for workflow artifact upload: ${chromeZipAssetPath}`)
} else {
publishChromeZipReleaseAsset(version, chromeZipAssetPath)
uploadChromeZipReleaseAsset(version, chromeZipAssetPath)
}
}
}
Expand Down
64 changes: 64 additions & 0 deletions scripts/src/commands/release.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'

import { Schema } from '@livestore/utils/effect'

import { sliceChangelogSection } from './release.ts'

const WorkflowStep = Schema.Struct({
name: Schema.optional(Schema.String),
run: Schema.optional(Schema.String),
'continue-on-error': Schema.optional(Schema.Boolean),
})

const ReleaseWorkflow = Schema.Struct({
jobs: Schema.Struct({
'publish-release': Schema.Struct({
steps: Schema.Array(WorkflowStep),
}),
}),
})

describe('sliceChangelogSection', () => {
it('extracts the verbatim block for a stable version with date heading', () => {
const changelog = [
Expand Down Expand Up @@ -61,6 +81,16 @@ describe('sliceChangelogSection', () => {
)
})

it('throws when the matching section is empty or whitespace-only', () => {
const empty = ['## 0.4.0', '', '## 0.3.0', '', 'old'].join('\n')
const whitespaceOnly = ['## 0.4.0', '', ' ', '\t', '', '## 0.3.0', '', 'old'].join('\n')

expect(() => sliceChangelogSection(empty, '0.4.0')).toThrow(/Changelog section for version 0\.4\.0 is empty/)
expect(() => sliceChangelogSection(whitespaceOnly, '0.4.0')).toThrow(
/Changelog section for version 0\.4\.0 is empty/,
)
})

it('reads up to the next ## heading even with deeper ### subheadings in between', () => {
const changelog = [
'## 0.4.0 - 2026-06-02',
Expand Down Expand Up @@ -93,3 +123,37 @@ describe('sliceChangelogSection', () => {
expect(sliceChangelogSection(changelog, '0.4.0')).toBe('final notes\n')
})
})

describe('publish-release workflow', () => {
const workflowPath = fileURLToPath(new URL('../../../.github/workflows/release.yml', import.meta.url))
const workflow = Schema.decodeUnknownSync(ReleaseWorkflow)(parse(readFileSync(workflowPath, 'utf8')))
const steps = workflow.jobs['publish-release'].steps

it('creates or updates the GitHub Release only after npm publishing succeeds', () => {
const npmPublishIndex = steps.findIndex((step) => step.name === 'Publish stable package release')
const githubReleaseIndex = steps.findIndex((step) => step.name === 'Create or update GitHub Release')
const devtoolsPublishIndex = steps.findIndex((step) => step.name === 'Publish DevTools artifact release')

expect(npmPublishIndex).toBeGreaterThan(-1)
expect(githubReleaseIndex).toBeGreaterThan(npmPublishIndex)
expect(devtoolsPublishIndex).toBeGreaterThan(githubReleaseIndex)

const npmPublishStep = steps[npmPublishIndex]!
const githubReleaseStep = steps[githubReleaseIndex]!
const githubReleaseScript = githubReleaseStep.run!

expect(npmPublishStep['continue-on-error']).not.toBe(true)
expect(githubReleaseStep['continue-on-error']).not.toBe(true)
expect(githubReleaseScript).toContain('::error::Missing or empty committed GitHub Release notes: $notes_path')
expect(githubReleaseScript).toContain(`grep -q '[^[:space:]]' "$notes_path"`)
expect(githubReleaseScript).toContain('exit 1')
expect(githubReleaseScript).toContain('gh release view')
expect(githubReleaseScript).toContain('gh release edit')
expect(githubReleaseScript).toContain('gh release create')
expect(githubReleaseScript).toContain('--target "$GITHUB_SHA"')
expect(githubReleaseScript).toContain('--notes-file "$notes_path"')
expect(githubReleaseScript).toContain('prerelease_args+=(--prerelease)')
expect(githubReleaseScript).not.toMatch(/--notes(?:\s|")/)
expect(githubReleaseScript).not.toContain('gh release upload')
})
})
9 changes: 7 additions & 2 deletions scripts/src/commands/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ const releaseNotesPath = (cwd: string) => `${cwd}/release/release-notes.md`
* stopping at the next `## ` heading. Trailing blank lines are trimmed; a
* single trailing newline is normalized.
*
* Throws when the heading is not found, or when more than one `## <version>`
* heading exists (defensive — should never happen, but cheap to guard).
* Throws when the heading is not found, is empty, or when more than one
* `## <version>` heading exists (defensive — should never happen, but cheap
* to guard).
*/
export const sliceChangelogSection = (changelog: string, version: string): string => {
const lines = changelog.split('\n')
Expand Down Expand Up @@ -168,6 +169,10 @@ export const sliceChangelogSection = (changelog: string, version: string): strin
let end = endIndex
while (end > start && lines[end - 1]!.trim() === '') end -= 1

if (start === end) {
throw new Error(`Changelog section for version ${version} is empty in CHANGELOG.md`)
}

return `${lines.slice(start, end).join('\n')}\n`
}

Expand Down
Loading