diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 290fa03..8430e5e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,11 @@ on: push: tags: - "v*.*.*" + pull_request: + types: + - closed + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,8 +27,11 @@ env: jobs: prebuilds: name: prebuilds (${{ matrix.target }}) + if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v')) runs-on: ${{ matrix.os }} timeout-minutes: 60 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -38,6 +46,9 @@ jobs: os: macos-15-intel steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }} - uses: jdx/mise-action@v3 - run: mise run release-prebuilds - uses: actions/upload-artifact@v4 @@ -49,19 +60,28 @@ jobs: publish: name: publish to npm needs: prebuilds + if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v')) runs-on: ubuntu-latest timeout-minutes: 20 permissions: - contents: read + contents: write actions: read id-token: write steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }} - uses: jdx/mise-action@v3 - uses: actions/download-artifact@v4 with: pattern: prebuilds-* path: .release-artifacts + - 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 }} - run: mise run publish env: NPM_PUBLISH_TAG: ${{ inputs.npm_tag }} diff --git a/README.md b/README.md index d6df108..62b94ff 100644 --- a/README.md +++ b/README.md @@ -103,14 +103,19 @@ Windows is documented as unsupported for the initial package. Ghostty has C API ## Releases -Publishing is handled by `.github/workflows/publish.yml` with npm Trusted Publishing. The normal release path is tag-driven: +Publishing is handled by `.github/workflows/publish.yml` with npm Trusted Publishing. The normal release path is a protected-main PR from a branch named `release/v*`: ```sh -npm version prerelease --preid beta -git push origin main --tags +git checkout main +git pull --ff-only origin main +git checkout -b release/v0.1.0-beta.1 +npm version 0.1.0-beta.1 --no-git-tag-version +git add package.json package-lock.json +git commit -m "Release v0.1.0-beta.1" +git push origin release/v0.1.0-beta.1 ``` -The publish workflow runs on `v*.*.*` tags, builds all prebuild artifacts, assembles them into the npm package layout, verifies the local platform prebuild, and publishes to npm. The workflow fails if the Git tag does not match `package.json` exactly, such as `v0.1.0-beta.0`. +Open that branch as a PR into `main`. When the PR is merged, the publish workflow builds all prebuild artifacts from the merge commit, creates the matching Git tag, assembles the npm package layout, verifies the local platform prebuild, and publishes to npm. The workflow fails if the release branch does not match `package.json` exactly, such as `release/v0.1.0-beta.1`. The npm dist-tag is derived from the package version: @@ -118,7 +123,7 @@ The npm dist-tag is derived from the package version: - `0.1.0-rc.0` publishes with `--tag rc` - `0.1.0` publishes with `--tag latest` -Manual dispatch is available for recovery or explicit dist-tag overrides, but release tags should be the default path. +Manual dispatch and direct `v*.*.*` tag pushes are available for recovery, but merged `release/v*` PRs should be the default path. ## Development Notes @@ -136,5 +141,4 @@ The upstream API is still marked unstable by Ghostty. Keep the pinned commit upd - No screenshot, PNG, WebM, browser, or GUI rendering API is provided. - Structured snapshots expose visible cells and optional scrollback lines, not the full Ghostty render-state API. - Grapheme and style extraction follows the current C API and may need adjustment when Ghostty changes ABI. -- Release publishing is not wired yet; the prebuild workflow currently uploads prebuild artifacts for later release handling. - On macOS, Zig native target discovery may require an explicit SDK/sysroot setup depending on the local Xcode/Zig combination. diff --git a/mise.toml b/mise.toml index e7353d5..429d799 100644 --- a/mise.toml +++ b/mise.toml @@ -28,6 +28,10 @@ run = [ description = "Assemble per-platform GitHub Actions prebuild artifacts into npm package layout." run = "npm run assemble:prebuilds" +[tasks.create-release-tag] +description = "Create the package-version Git tag for a merged release/v* PR." +run = "npm run release:create-tag" + [tasks.publish-dry-run] description = "Assemble release artifacts and verify the npm package that would be published." run = [ diff --git a/package.json b/package.json index 24f7b38..002cd72 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "verify": "npm run typecheck && npm test && npm run smoke", "verify:prebuilds": "node scripts/verify-prebuilds.mjs", "assemble:prebuilds": "node scripts/assemble-prebuilds.mjs", + "release:create-tag": "node scripts/create-release-tag.mjs", "resolve:publish-tag": "node scripts/resolve-npm-publish-tag.mjs" }, "dependencies": { diff --git a/scripts/create-release-tag.mjs b/scripts/create-release-tag.mjs new file mode 100755 index 0000000..45a7f59 --- /dev/null +++ b/scripts/create-release-tag.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const version = packageJson.version; +const releaseBranchName = process.env.RELEASE_BRANCH_NAME; +const releaseTargetSha = process.env.RELEASE_TARGET_SHA; +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.-]+)?$/; + +function git(args, options = {}) { + 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", options.allowFailure ? "pipe" : "inherit"], + }).trim(); +} + +function tryGit(args) { + try { + return git(args, { allowFailure: true }); + } catch { + return undefined; + } +} + +assert.equal(typeof version, "string", "package.json version must be a string"); +assert.match(version, semverPattern, `package.json version must be semver: ${version}`); + +const tagName = `v${version}`; + +if (releaseBranchName !== undefined && releaseBranchName !== "") { + assert.equal( + releaseBranchName, + `release/${tagName}`, + `release branch ${releaseBranchName} must match package version ${tagName}`, + ); +} + +const targetSha = releaseTargetSha?.trim() || git(["rev-parse", "HEAD"]); +assert.match(targetSha, /^[0-9a-f]{40}$/i, `release target SHA must be a full commit SHA: ${targetSha}`); + +const currentHead = git(["rev-parse", "HEAD"]); +assert.equal(currentHead, targetSha, "checked-out HEAD must match the release target SHA"); + +const remoteTagSha = tryGit(["ls-remote", "--tags", "origin", `refs/tags/${tagName}`]); +if (remoteTagSha) { + const [existingSha, existingRef] = remoteTagSha.split(/\s+/, 2); + assert.equal(existingRef, `refs/tags/${tagName}`, `unexpected remote tag ref for ${tagName}`); + assert.equal(existingSha, targetSha, `remote tag ${tagName} already exists at ${existingSha}, not ${targetSha}`); + console.log(JSON.stringify({ tagName, targetSha, created: false }, null, 2)); + process.exit(0); +} + +git(["tag", tagName, targetSha]); +git(["push", "origin", `refs/tags/${tagName}`]); +console.log(JSON.stringify({ tagName, targetSha, created: true }, null, 2));