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
22 changes: 21 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ on:
push:
tags:
- "v*.*.*"
pull_request:
types:
- closed
branches:
- main

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 }}
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,27 @@ 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:

- `0.1.0-beta.0` publishes with `--tag beta`
- `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

Expand All @@ -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.
4 changes: 4 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
64 changes: 64 additions & 0 deletions scripts/create-release-tag.mjs
Original file line number Diff line number Diff line change
@@ -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));
Loading