From 704024c347dedacde44b892b5b75292464dbf312 Mon Sep 17 00:00:00 2001 From: h4or Date: Wed, 6 May 2026 17:59:33 +0200 Subject: [PATCH] feat: add named ports and release automation --- .github/workflows/publish-npm.yml | 98 ---------------------------- .github/workflows/release-please.yml | 26 ++++++++ .release-please-manifest.json | 3 + CHANGELOG.md | 3 + README.md | 16 ++++- ROADMAP.md | 1 + release-please-config.json | 15 +++++ scripts/release.d.mts | 16 +++++ scripts/release.mjs | 24 ++++++- test/release-please-config.test.ts | 47 +++++++++++++ test/release-script.test.ts | 33 +++++++++- 11 files changed, 178 insertions(+), 104 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json create mode 100644 scripts/release.d.mts create mode 100644 test/release-please-config.test.ts diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 5b15f9a..c5bea6a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -90,101 +90,3 @@ jobs: PACKAGE_VERSION="${{ steps.package.outputs.version }}" echo "Publishing ${PACKAGE_VERSION} with dist-tag ${NPM_DIST_TAG}." npm publish --tag "${NPM_DIST_TAG}" - - - name: Extract release notes from CHANGELOG.md - id: release-notes - env: - PACKAGE_VERSION: ${{ steps.package.outputs.version }} - run: | - node --input-type=module - <<'NODE' - import fs from 'node:fs'; - - const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); - const version = process.env.PACKAGE_VERSION; - const headings = [`## ${version}`, `## v${version}`]; - - let releaseBody = ''; - for (const heading of headings) { - const start = changelog.indexOf(`${heading}\n`); - if (start === -1) { - continue; - } - - const contentStart = start + heading.length + 1; - const remaining = changelog.slice(contentStart); - const nextHeading = remaining.search(/\n##\s+/); - releaseBody = remaining - .slice(0, nextHeading === -1 ? undefined : nextHeading) - .trim(); - break; - } - - fs.writeFileSync( - 'release-notes.md', - releaseBody - ? `# ${version}\n\n${releaseBody}\n` - : `# ${version}\n\nNo matching CHANGELOG.md section was found for this package version.\n`, - ); - NODE - - if grep -q "No matching CHANGELOG.md section" release-notes.md; then - echo "found=false" >> "$GITHUB_OUTPUT" - else - echo "found=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create or update GitHub Release - uses: actions/github-script@v7 - env: - PACKAGE_VERSION: ${{ steps.package.outputs.version }} - PACKAGE_PRERELEASE: ${{ steps.package.outputs.prerelease }} - CHANGELOG_FOUND: ${{ steps.release-notes.outputs.found }} - with: - script: | - const fs = require('node:fs'); - - const owner = context.repo.owner; - const repo = context.repo.repo; - const version = process.env.PACKAGE_VERSION; - const tag = `v${version}`; - const prerelease = process.env.PACKAGE_PRERELEASE === 'true'; - const body = fs.readFileSync('release-notes.md', 'utf8'); - const releasePayload = { - owner, - repo, - tag_name: tag, - name: tag, - body, - prerelease, - draft: false, - target_commitish: context.sha, - }; - - if (process.env.CHANGELOG_FOUND !== 'true') { - core.warning(`No matching CHANGELOG.md section was found for ${version}. The GitHub Release will include a placeholder note.`); - } - - try { - const existing = await github.rest.repos.getReleaseByTag({ - owner, - repo, - tag, - }); - - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: existing.data.id, - tag_name: tag, - name: tag, - body, - prerelease, - draft: false, - }); - } catch (error) { - if (error.status !== 404) { - throw error; - } - - await github.rest.repos.createRelease(releasePayload); - } diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..a7cb3f8 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,26 @@ +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + + steps: + - name: Create or update release pull request + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..001eb2c --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.2" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d6e17..4647262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Updated release preparation to keep the README package status version aligned with `package.json`. +- Added Release Please configuration to generate changelog entries and release pull requests from Conventional Commits. + ## 0.1.2 - 2026-05-02 - Added named node ports with `.port(id, { side })` and `connect(..., { sourcePort, targetPort })`. diff --git a/README.md b/README.md index d253093..da84611 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Browser-first TypeScript library for interactive node canvases. ## Package Status -`0.1.1` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet. +`0.1.2` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet. Supported in this alpha: @@ -278,7 +278,17 @@ npm run check ### Release Automation -Publishing stays in GitHub Actions through npm trusted publishing. The local release script prepares version files and changelog entries, but it does not commit, tag, push, or publish. +Release Please creates release pull requests from Conventional Commit messages. Those pull requests update package versions, `CHANGELOG.md`, and the README package status. When a Release Please PR is merged, Release Please creates the GitHub Release and the existing npm workflow publishes the already-versioned package through trusted publishing. + +Use commit prefixes consistently so Release Please can calculate the right version: + +```bash +feat: add packet payload model +fix: correct packet route validation +docs: update port examples +``` + +The local release script remains available as a manual fallback. It prepares version files, the README package status, and changelog entries, but it does not commit, tag, push, or publish. ```bash npm run release:prepare -- patch --yes @@ -293,7 +303,7 @@ Useful options: - `--allow-empty`: create a release section even when `Unreleased` has no entries - `--dispatch`: trigger the publish workflow after release prep -Recommended release flow: +Manual fallback release flow: ```bash npm run release:prepare -- patch --yes diff --git a/ROADMAP.md b/ROADMAP.md index fac2141..8f35b1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,7 @@ - Packet routing through required waypoint nodes - Named ports and connect-to-specific-port API - Release preparation script and CI workflow improvements +- Release Please automation for changelog and release pull requests - Documentation and usage examples ## Next diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..199c64f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,15 @@ +{ + "packages": { + ".": { + "extra-files": [ + { + "path": "README.md", + "type": "generic" + } + ], + "include-component-in-tag": false, + "package-name": "@darbsen/cnodes", + "release-type": "node" + } + } +} diff --git a/scripts/release.d.mts b/scripts/release.d.mts new file mode 100644 index 0000000..932558e --- /dev/null +++ b/scripts/release.d.mts @@ -0,0 +1,16 @@ +export function computeNextVersion( + version: string, + bump: string, + preid?: string, +): string; + +export function promoteUnreleasedChangelog( + changelog: string, + version: string, + date: string, + options?: Readonly<{ + allowEmpty?: boolean; + }>, +): string; + +export function updateReadmeVersion(readme: string, version: string): string; diff --git a/scripts/release.mjs b/scripts/release.mjs index 480ad61..532fb55 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -95,6 +95,20 @@ export function promoteUnreleasedChangelog( ].join('\n'); } +export function updateReadmeVersion(readme, version) { + const statusVersionPattern = + /`(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+\.\d+)?)` is an early public release\./; + + if (!statusVersionPattern.test(readme)) { + throw new Error('README.md package status version marker was not found.'); + } + + return readme.replace( + statusVersionPattern, + `\`${version}\` is an early public release.`, + ); +} + function parseVersion(version) { const match = version.match( /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+)\.(\d+))?$/, @@ -220,6 +234,8 @@ function prepareRelease(options) { getToday(), { allowEmpty: options.allowEmpty }, ); + const readme = readFileSync('README.md', 'utf8'); + const nextReadme = updateReadmeVersion(readme, nextVersion); if (options.dryRun) { console.log(`Would prepare ${packageJson.name}@${nextVersion}.`); @@ -234,6 +250,7 @@ function prepareRelease(options) { run('npm', ['version', nextVersion, '--no-git-tag-version']); writeFileSync('CHANGELOG.md', nextChangelog); + writeFileSync('README.md', nextReadme); console.log(`Prepared ${packageJson.name}@${nextVersion}.`); if (options.dispatch) { @@ -253,7 +270,12 @@ function dispatchPublishWorkflow() { } function ensureRepositoryFiles() { - for (const file of ['package.json', 'package-lock.json', 'CHANGELOG.md']) { + for (const file of [ + 'package.json', + 'package-lock.json', + 'CHANGELOG.md', + 'README.md', + ]) { if (!existsSync(file)) { throw new Error(`Expected ${file} in the current working directory.`); } diff --git a/test/release-please-config.test.ts b/test/release-please-config.test.ts new file mode 100644 index 0000000..e90d05e --- /dev/null +++ b/test/release-please-config.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +type ReleasePleaseConfig = Readonly<{ + packages: Readonly< + Record< + string, + Readonly<{ + 'include-component-in-tag'?: boolean; + 'package-name'?: string; + 'release-type'?: string; + }> + > + >; +}>; + +describe('release-please configuration', () => { + it('tracks the current package version in the release manifest', () => { + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { + version: string; + }; + const manifest = JSON.parse( + readFileSync('.release-please-manifest.json', 'utf8'), + ) as Record; + + expect(manifest['.']).toBe(packageJson.version); + }); + + it('configures the root node package without component-prefixed tags', () => { + const config = JSON.parse( + readFileSync('release-please-config.json', 'utf8'), + ) as ReleasePleaseConfig; + + expect(config.packages['.']).toMatchObject({ + 'include-component-in-tag': false, + 'package-name': '@darbsen/cnodes', + 'release-type': 'node', + }); + }); + + it('marks the README package status version for release-please updates', () => { + const readme = readFileSync('README.md', 'utf8'); + + expect(readme).toContain('x-release-please-version'); + }); +}); diff --git a/test/release-script.test.ts b/test/release-script.test.ts index 8657168..b2b7e25 100644 --- a/test/release-script.test.ts +++ b/test/release-script.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; -// @ts-expect-error The release helper is a Node ESM script, not TS source. -import { computeNextVersion, promoteUnreleasedChangelog } from '../scripts/release.mjs'; +import { + computeNextVersion, + promoteUnreleasedChangelog, + updateReadmeVersion, +} from '../scripts/release.mjs'; describe('release script helpers', () => { it('computes stable semver bumps', () => { @@ -63,4 +66,30 @@ describe('release script helpers', () => { 'CHANGELOG.md has no Unreleased entries.', ); }); + + it('updates the README package status version', () => { + const readme = [ + '# cnodes', + '', + '## Package Status', + '', + '`0.1.1` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet.', + '', + ].join('\n'); + + expect(updateReadmeVersion(readme, '0.1.2')).toBe([ + '# cnodes', + '', + '## Package Status', + '', + '`0.1.2` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet.', + '', + ].join('\n')); + }); + + it('throws when the README package status version marker is missing', () => { + expect(() => updateReadmeVersion('# cnodes\n', '0.1.2')).toThrowError( + 'README.md package status version marker was not found.', + ); + }); });