diff --git a/.github/scripts/create-or-update-github-release.mjs b/.github/scripts/create-or-update-github-release.mjs new file mode 100644 index 0000000..8bc8231 --- /dev/null +++ b/.github/scripts/create-or-update-github-release.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const [tagName, communiqueNotesPath] = process.argv.slice(2); +const repository = process.env.GITHUB_REPOSITORY?.trim(); +const githubSha = process.env.GITHUB_SHA?.trim(); +const npmDistTag = process.env.NPM_DIST_TAG?.trim(); +const githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; +const semverPattern = + /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/; +const distTagPattern = /^[A-Za-z][A-Za-z0-9._-]*$/; + +assert.ok(tagName, "tag name argument is required"); +assert.ok(communiqueNotesPath, "Communique notes path argument is required"); +assert.ok(repository, "GITHUB_REPOSITORY must be set"); +assert.ok(githubSha, "GITHUB_SHA must be set"); +assert.match(githubSha, /^[0-9a-f]{40}$/i, `GITHUB_SHA must be a full commit SHA: ${githubSha}`); +assert.ok(githubToken, "GH_TOKEN or GITHUB_TOKEN must be set"); +assert.ok(npmDistTag, "NPM_DIST_TAG must be set"); +assert.match(npmDistTag, distTagPattern, `invalid npm dist-tag: ${npmDistTag}`); + +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +assert.equal(typeof packageJson.name, "string", "package.json name must be a string"); +assert.equal(typeof packageJson.version, "string", "package.json version must be a string"); +assert.equal(typeof packageJson.engines?.node, "string", "package.json engines.node must be a string"); +const versionMatch = semverPattern.exec(packageJson.version); +assert.notEqual(versionMatch, null, `package.json version must be semver: ${packageJson.version}`); +assert.equal(tagName, `v${packageJson.version}`, `release tag ${tagName} must match package version`); + +function parseCommuniqueNotes(path) { + const raw = readFileSync(path, "utf8").trim(); + assert.ok(raw.length > 0, "Communique notes file must not be empty"); + const [firstLine, ...bodyLines] = raw.split(/\r?\n/); + assert.ok(firstLine.startsWith("# "), "Communique notes must start with a level-one title"); + const title = firstLine.slice(2).trim(); + const body = bodyLines.join("\n").trim(); + assert.ok(title.length > 0, "release title must not be empty"); + assert.ok(!title.includes("\n") && !title.includes("\r"), "release title must be one line"); + assert.ok( + title.startsWith(`${tagName}: `), + `release title must start with '${tagName}: ' after Communique normalization`, + ); + assert.ok(body.length > 0, "release body must not be empty"); + return { title, body }; +} + +function stripAppendedInstallNotes(body) { + const lines = body.trim().split(/\r?\n/); + const firstGeneratedSection = lines.findIndex((line) => { + const heading = line.trim(); + return heading === "## Installation" || heading === "## Platform Support"; + }); + if (firstGeneratedSection === -1) { + return body.trim(); + } + return lines.slice(0, firstGeneratedSection).join("\n").trim(); +} + +function appendInstallNotes(body) { + const installTarget = `${packageJson.name}@${packageJson.version}`; + return `${stripAppendedInstallNotes(body)} + +## Installation + +\`\`\`sh +npm install ${installTarget} +\`\`\` + +This release was published to npm as \`${installTarget}\` with the \`${npmDistTag}\` dist-tag. + +## Platform Support + +The npm package includes Node-API prebuilds for linux-x64, linux-arm64, and macos-arm64. Node.js ${packageJson.engines.node} is required. Native binaries are distributed through npm; this GitHub Release does not attach separate binary assets.`; +} + +function gh(args, options = {}) { + assert.ok(Array.isArray(args), "gh args must be an array"); + assert.ok(args.every((arg) => typeof arg === "string"), "gh args must be strings"); + return execFileSync("gh", args, { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GH_TOKEN: githubToken, + }, + stdio: ["ignore", "pipe", options.allowFailure ? "pipe" : "inherit"], + }).trim(); +} + +function ghSucceeds(args) { + try { + gh(args, { allowFailure: true }); + return true; + } catch { + return false; + } +} + +const { title, body } = parseCommuniqueNotes(communiqueNotesPath); +const finalBody = appendInstallNotes(body); +const finalNotesPath = join(mkdtempSync(join(tmpdir(), "libghostty-vt-release-")), "notes.md"); +writeFileSync(finalNotesPath, `${finalBody.trimEnd()}\n`); + +const prerelease = versionMatch.groups?.prerelease !== undefined; +const releaseExists = ghSucceeds(["release", "view", tagName, "--repo", repository]); + +if (releaseExists) { + const args = [ + "release", + "edit", + tagName, + "--repo", + repository, + "--title", + title, + "--notes-file", + finalNotesPath, + "--verify-tag", + ]; + if (prerelease) { + args.push("--prerelease"); + } else { + args.push("--latest"); + } + gh(args); + console.log(JSON.stringify({ tagName, title, updated: true }, null, 2)); +} else { + const args = [ + "release", + "create", + tagName, + "--repo", + repository, + "--title", + title, + "--notes-file", + finalNotesPath, + "--target", + githubSha, + "--verify-tag", + ]; + if (prerelease) { + args.push("--prerelease", "--latest=false"); + } + gh(args); + console.log(JSON.stringify({ tagName, title, created: true }, null, 2)); +} diff --git a/.github/scripts/prepare-release-changelog.mjs b/.github/scripts/prepare-release-changelog.mjs new file mode 100644 index 0000000..32a3240 --- /dev/null +++ b/.github/scripts/prepare-release-changelog.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const [baseRefArg] = process.argv.slice(2); +const baseRef = baseRefArg?.trim(); +const releaseBranchName = process.env.RELEASE_BRANCH_NAME?.trim(); +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z.-]+)?$/; + +assert.ok(baseRef, "base git ref argument is required"); + +function git(args) { + assert.ok(Array.isArray(args), "git args must be an array"); + assert.ok(args.every((arg) => typeof arg === "string"), "git args must be strings"); + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} + +function parsePackageJson(contents, description) { + assert.equal(typeof contents, "string", `${description} package.json contents must be a string`); + const parsed = JSON.parse(contents); + assert.equal(typeof parsed, "object", `${description} package.json must parse to an object`); + assert.notEqual(parsed, null, `${description} package.json must not be null`); + assert.equal(typeof parsed.version, "string", `${description} package.json version must be a string`); + assert.match(parsed.version, semverPattern, `${description} package.json version must be semver`); + return parsed; +} + +function hasChangelogEntry(tagName) { + const changelogPath = join(root, "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return false; + } + + const changelog = readFileSync(changelogPath, "utf8"); + const version = tagName.replace(/^v/, ""); + const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const entryPattern = new RegExp(String.raw`^##\s+(?:\[?(?:${escapedVersion}|${escapedTag})\]?)(?:\s|$)`, "m"); + return entryPattern.test(changelog); +} + +function writeOutput(values) { + for (const [key, value] of Object.entries(values)) { + assert.match(key, /^[A-Za-z_][A-Za-z0-9_]*$/, `invalid GitHub output key: ${key}`); + const stringValue = String(value); + if (process.env.GITHUB_OUTPUT) { + execFileSync("sh", ["-c", "printf '%s=%s\\n' \"$1\" \"$2\" >> \"$GITHUB_OUTPUT\"", "sh", key, stringValue], { + cwd: root, + env: process.env, + stdio: ["ignore", "inherit", "inherit"], + }); + } else { + console.log(`${key}=${stringValue}`); + } + } +} + +const headPackage = parsePackageJson(readFileSync(join(root, "package.json"), "utf8"), "head"); +const basePackage = parsePackageJson(git(["show", `${baseRef}:package.json`]), "base"); +const tagName = `v${headPackage.version}`; + +if (releaseBranchName) { + assert.equal( + releaseBranchName, + `release/${tagName}`, + `release branch ${releaseBranchName} must match package version ${tagName}`, + ); +} + +let shouldGenerate = "true"; +let reason = "version-changed"; + +if (headPackage.version === basePackage.version) { + shouldGenerate = "false"; + reason = "no-version-change"; +} else if (hasChangelogEntry(tagName)) { + shouldGenerate = "false"; + reason = "changelog-entry-exists"; +} + +writeOutput({ + should_generate: shouldGenerate, + reason, + base_version: basePackage.version, + package_version: headPackage.version, + tag_name: tagName, +}); + +console.log( + JSON.stringify( + { + baseVersion: basePackage.version, + packageVersion: headPackage.version, + tagName, + shouldGenerate: shouldGenerate === "true", + reason, + }, + null, + 2, + ), +); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 711896f..b8ee86d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ concurrency: env: MISE_EXPERIMENTAL: true + MISE_LOCKED: true jobs: ci: @@ -28,8 +29,6 @@ jobs: os: ubuntu-24.04-arm - target: macos-arm64 os: macos-14 - - target: macos-x64 - os: macos-15-intel steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8430e5e..cad4f50 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,7 @@ concurrency: env: MISE_EXPERIMENTAL: true + MISE_LOCKED: true jobs: prebuilds: @@ -42,8 +43,6 @@ jobs: os: ubuntu-24.04-arm - target: macos-arm64 os: macos-14 - - target: macos-x64 - os: macos-15-intel steps: - uses: actions/checkout@v6 with: @@ -66,7 +65,9 @@ jobs: permissions: contents: write actions: read + issues: read id-token: write + pull-requests: read steps: - uses: actions/checkout@v6 with: @@ -77,11 +78,62 @@ jobs: with: pattern: prebuilds-* path: .release-artifacts + - id: release + name: Resolve release metadata + run: | + set -euo pipefail + package_version="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")" + npm_dist_tag="$(node scripts/resolve-npm-publish-tag.mjs)" + echo "tag_name=v${package_version}" >> "$GITHUB_OUTPUT" + echo "npm_dist_tag=${npm_dist_tag}" >> "$GITHUB_OUTPUT" + env: + NPM_PUBLISH_TAG: ${{ github.event.inputs.npm_tag || '' }} - run: mise run create-release-tag if: github.event_name == 'pull_request' env: RELEASE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} RELEASE_TARGET_SHA: ${{ github.event.pull_request.merge_commit_sha }} + - name: Verify release tag + run: | + set -euo pipefail + git fetch --tags origin + git rev-parse --verify --quiet "refs/tags/${TAG_NAME}^{commit}" >/dev/null + env: + TAG_NAME: ${{ steps.release.outputs.tag_name }} + - id: communique + name: Generate GitHub Release notes + run: | + set -euo pipefail + + notes_file="${RUNNER_TEMP}/communique-release.md" + args=(--repo "$GITHUB_REPOSITORY" --output "$notes_file") + + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + : + elif [[ -n "${OPENAI_API_KEY:-}" && -n "${COMMUNIQUE_MODEL:-}" ]]; then + args+=(--provider openai --model "$COMMUNIQUE_MODEL") + else + echo "::error::Set ANTHROPIC_API_KEY, or set OPENAI_API_KEY and repository variable COMMUNIQUE_MODEL, before publishing." + exit 1 + fi + + communique generate "$TAG_NAME" "${args[@]}" + test -s "$notes_file" + echo "notes_file=${notes_file}" >> "$GITHUB_OUTPUT" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + COMMUNIQUE_MODEL: ${{ vars.COMMUNIQUE_MODEL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TAG_NAME: ${{ steps.release.outputs.tag_name }} - run: mise run publish env: - NPM_PUBLISH_TAG: ${{ inputs.npm_tag }} + NPM_PUBLISH_TAG: ${{ github.event.inputs.npm_tag || '' }} + - name: Create or update GitHub Release + run: node .github/scripts/create-or-update-github-release.mjs "$TAG_NAME" "$NOTES_FILE" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_DIST_TAG: ${{ steps.release.outputs.npm_dist_tag }} + NOTES_FILE: ${{ steps.communique.outputs.notes_file }} + TAG_NAME: ${{ steps.release.outputs.tag_name }} diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml new file mode 100644 index 0000000..856caf6 --- /dev/null +++ b/.github/workflows/release-changelog.yml @@ -0,0 +1,93 @@ +name: release-changelog + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + MISE_EXPERIMENTAL: true + MISE_LOCKED: true + +jobs: + changelog: + name: update changelog + if: github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: write + contents: write + issues: read + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: jdx/mise-action@v3 + - id: prepare + name: Check release version + run: node .github/scripts/prepare-release-changelog.mjs "$BASE_SHA" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + RELEASE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + - name: Generate CHANGELOG.md entry + if: steps.prepare.outputs.should_generate == 'true' + run: | + set -euo pipefail + + args=(--changelog --repo "$GITHUB_REPOSITORY") + + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + : + elif [[ -n "${OPENAI_API_KEY:-}" && -n "${COMMUNIQUE_MODEL:-}" ]]; then + args+=(--provider openai --model "$COMMUNIQUE_MODEL") + else + echo "::error::Set ANTHROPIC_API_KEY, or set OPENAI_API_KEY and repository variable COMMUNIQUE_MODEL, before generating release changelogs." + exit 1 + fi + + communique generate "$TAG_NAME" "${args[@]}" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + COMMUNIQUE_MODEL: ${{ vars.COMMUNIQUE_MODEL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TAG_NAME: ${{ steps.prepare.outputs.tag_name }} + - name: Commit changelog update + if: steps.prepare.outputs.should_generate == 'true' + id: commit + run: | + set -euo pipefail + + if git diff --quiet -- CHANGELOG.md; then + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs: update changelog for ${TAG_NAME}" + git push origin "HEAD:${PR_BRANCH}" + echo "committed=true" >> "$GITHUB_OUTPUT" + env: + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + TAG_NAME: ${{ steps.prepare.outputs.tag_name }} + - name: Dispatch CI for updated release branch + if: steps.prepare.outputs.should_generate == 'true' && steps.commit.outputs.committed == 'true' + run: gh workflow run ci.yml --ref "$PR_BRANCH" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/release-prebuilds.yml b/.github/workflows/release-prebuilds.yml index 09fb814..b0e11ba 100644 --- a/.github/workflows/release-prebuilds.yml +++ b/.github/workflows/release-prebuilds.yml @@ -9,6 +9,7 @@ concurrency: env: MISE_EXPERIMENTAL: true + MISE_LOCKED: true jobs: release-prebuilds: @@ -25,8 +26,6 @@ jobs: os: ubuntu-24.04-arm - target: macos-arm64 os: macos-14 - - target: macos-x64 - os: macos-15-intel steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3aabab6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable user-facing changes to this package will be documented in this file. + +## [Unreleased] diff --git a/communique.toml b/communique.toml new file mode 100644 index 0000000..b82e2c2 --- /dev/null +++ b/communique.toml @@ -0,0 +1,19 @@ +system_extra = """ +Additional project-specific rules: +- Write plain Markdown with no emoji. +- Prioritize user-facing npm package, TypeScript API, native binding, and terminal behavior changes before internal maintenance. +- Call out packaging, prebuild, Node-API, Node.js version, and platform support changes when they affect installation or runtime behavior. +- Keep release notes concise and factual. Do not inflate internal CI, release automation, or dependency-only work unless it changes what package users can install or run. +- Ignore version-only release commits, package-lock version-only updates, release tag commits, and changelog automation commits unless they include user-facing release content. +- For CHANGELOG.md entries, use Keep a Changelog-style categories and omit empty sections. +""" + +context = """ +@coder/libghostty-vt-node is an npm package that ships ABI-stable Node-API bindings for Ghostty's libghostty-vt terminal semantics. The package publishes compiled native prebuilds through npm for linux-x64, linux-arm64, macos-arm64, and macos-x64. Users install the package from npm and load the native binding from Node.js; GitHub Releases document the release but do not carry binary assets. +""" + +[defaults] +repo = "coder/libghostty-vt-node" +emoji = false +verify_links = true +match_style = true diff --git a/mise.lock b/mise.lock index 1b45083..403d20e 100644 --- a/mise.lock +++ b/mise.lock @@ -1,5 +1,39 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html +[[tools.communique]] +version = "1.0.3" +backend = "github:jdx/communique" + +[tools.communique."platforms.linux-arm64"] +checksum = "sha256:b8425a0193c0c14f45c7d2454bc3d7ce6203930765f912fe75fff83a8eb04c14" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403764139" + +[tools.communique."platforms.linux-arm64-musl"] +checksum = "sha256:b8425a0193c0c14f45c7d2454bc3d7ce6203930765f912fe75fff83a8eb04c14" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403764139" + +[tools.communique."platforms.linux-x64"] +checksum = "sha256:ec51b288886506409b6526044fe9bbcea7b07d2088729ae70f9ffcbeabf82871" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403764051" + +[tools.communique."platforms.linux-x64-musl"] +checksum = "sha256:ec51b288886506409b6526044fe9bbcea7b07d2088729ae70f9ffcbeabf82871" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403764051" + +[tools.communique."platforms.macos-arm64"] +checksum = "sha256:4efa78274b808b90b6bd2a40d3454c761f331211a891c3afce1815da19853d9f" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403764017" + +[tools.communique."platforms.windows-x64"] +checksum = "sha256:7747987ad9f5b212198699422dfb65da955ed21f125a4626c2f90d4d045abf67" +url = "https://github.com/jdx/communique/releases/download/v1.0.3/communique-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/communique/releases/assets/403765157" + [[tools.node]] version = "25.9.0" backend = "core:node" diff --git a/mise.toml b/mise.toml index 429d799..abe42db 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] +communique = "1.0.3" node = "25.9" zig = "0.15.2" diff --git a/package.json b/package.json index 002cd72..3c2d1aa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "scripts", "binding.gyp", "prebuilds", + "CHANGELOG.md", "README.md", "LICENSE" ], diff --git a/scripts/assemble-prebuilds.mjs b/scripts/assemble-prebuilds.mjs index 5be8fd1..fec5c6e 100755 --- a/scripts/assemble-prebuilds.mjs +++ b/scripts/assemble-prebuilds.mjs @@ -8,7 +8,7 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const sourceRoot = resolve(process.argv[2] ?? join(root, ".release-artifacts")); const targetRoot = resolve(process.argv[3] ?? join(root, "prebuilds")); const expectedPlatforms = ( - process.env.EXPECTED_PREBUILD_PLATFORMS ?? "darwin-arm64,darwin-x64,linux-arm64,linux-x64" + process.env.EXPECTED_PREBUILD_PLATFORMS ?? "darwin-arm64,linux-arm64,linux-x64" ) .split(",") .map((value) => value.trim())