Skip to content

Commit 29fb821

Browse files
authored
Support release/v publish automation (#1)
1 parent 8c39784 commit 29fb821

5 files changed

Lines changed: 100 additions & 7 deletions

File tree

‎.github/workflows/publish.yml‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ on:
1111
push:
1212
tags:
1313
- "v*.*.*"
14+
pull_request:
15+
types:
16+
- closed
17+
branches:
18+
- main
1419

1520
concurrency:
1621
group: ${{ github.workflow }}-${{ github.ref }}
@@ -22,8 +27,11 @@ env:
2227
jobs:
2328
prebuilds:
2429
name: prebuilds (${{ matrix.target }})
30+
if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v'))
2531
runs-on: ${{ matrix.os }}
2632
timeout-minutes: 60
33+
permissions:
34+
contents: read
2735
strategy:
2836
fail-fast: false
2937
matrix:
@@ -38,6 +46,9 @@ jobs:
3846
os: macos-15-intel
3947
steps:
4048
- uses: actions/checkout@v6
49+
with:
50+
fetch-depth: 0
51+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }}
4152
- uses: jdx/mise-action@v3
4253
- run: mise run release-prebuilds
4354
- uses: actions/upload-artifact@v4
@@ -49,19 +60,28 @@ jobs:
4960
publish:
5061
name: publish to npm
5162
needs: prebuilds
63+
if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v'))
5264
runs-on: ubuntu-latest
5365
timeout-minutes: 20
5466
permissions:
55-
contents: read
67+
contents: write
5668
actions: read
5769
id-token: write
5870
steps:
5971
- uses: actions/checkout@v6
72+
with:
73+
fetch-depth: 0
74+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }}
6075
- uses: jdx/mise-action@v3
6176
- uses: actions/download-artifact@v4
6277
with:
6378
pattern: prebuilds-*
6479
path: .release-artifacts
80+
- run: mise run create-release-tag
81+
if: github.event_name == 'pull_request'
82+
env:
83+
RELEASE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
84+
RELEASE_TARGET_SHA: ${{ github.event.pull_request.merge_commit_sha }}
6585
- run: mise run publish
6686
env:
6787
NPM_PUBLISH_TAG: ${{ inputs.npm_tag }}

‎README.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,27 @@ Windows is documented as unsupported for the initial package. Ghostty has C API
103103

104104
## Releases
105105

106-
Publishing is handled by `.github/workflows/publish.yml` with npm Trusted Publishing. The normal release path is tag-driven:
106+
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*`:
107107

108108
```sh
109-
npm version prerelease --preid beta
110-
git push origin main --tags
109+
git checkout main
110+
git pull --ff-only origin main
111+
git checkout -b release/v0.1.0-beta.1
112+
npm version 0.1.0-beta.1 --no-git-tag-version
113+
git add package.json package-lock.json
114+
git commit -m "Release v0.1.0-beta.1"
115+
git push origin release/v0.1.0-beta.1
111116
```
112117

113-
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`.
118+
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`.
114119

115120
The npm dist-tag is derived from the package version:
116121

117122
- `0.1.0-beta.0` publishes with `--tag beta`
118123
- `0.1.0-rc.0` publishes with `--tag rc`
119124
- `0.1.0` publishes with `--tag latest`
120125

121-
Manual dispatch is available for recovery or explicit dist-tag overrides, but release tags should be the default path.
126+
Manual dispatch and direct `v*.*.*` tag pushes are available for recovery, but merged `release/v*` PRs should be the default path.
122127

123128
## Development Notes
124129

@@ -136,5 +141,4 @@ The upstream API is still marked unstable by Ghostty. Keep the pinned commit upd
136141
- No screenshot, PNG, WebM, browser, or GUI rendering API is provided.
137142
- Structured snapshots expose visible cells and optional scrollback lines, not the full Ghostty render-state API.
138143
- Grapheme and style extraction follows the current C API and may need adjustment when Ghostty changes ABI.
139-
- Release publishing is not wired yet; the prebuild workflow currently uploads prebuild artifacts for later release handling.
140144
- On macOS, Zig native target discovery may require an explicit SDK/sysroot setup depending on the local Xcode/Zig combination.

‎mise.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ run = [
2828
description = "Assemble per-platform GitHub Actions prebuild artifacts into npm package layout."
2929
run = "npm run assemble:prebuilds"
3030

31+
[tasks.create-release-tag]
32+
description = "Create the package-version Git tag for a merged release/v* PR."
33+
run = "npm run release:create-tag"
34+
3135
[tasks.publish-dry-run]
3236
description = "Assemble release artifacts and verify the npm package that would be published."
3337
run = [

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"verify": "npm run typecheck && npm test && npm run smoke",
5555
"verify:prebuilds": "node scripts/verify-prebuilds.mjs",
5656
"assemble:prebuilds": "node scripts/assemble-prebuilds.mjs",
57+
"release:create-tag": "node scripts/create-release-tag.mjs",
5758
"resolve:publish-tag": "node scripts/resolve-npm-publish-tag.mjs"
5859
},
5960
"dependencies": {

‎scripts/create-release-tag.mjs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env node
2+
import assert from "node:assert/strict";
3+
import { execFileSync } from "node:child_process";
4+
import { readFileSync } from "node:fs";
5+
import { join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const root = fileURLToPath(new URL("..", import.meta.url));
9+
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
10+
const version = packageJson.version;
11+
const releaseBranchName = process.env.RELEASE_BRANCH_NAME;
12+
const releaseTargetSha = process.env.RELEASE_TARGET_SHA;
13+
const semverPattern =
14+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z.-]+)?$/;
15+
16+
function git(args, options = {}) {
17+
assert.ok(Array.isArray(args), "git args must be an array");
18+
assert.ok(args.every((arg) => typeof arg === "string"), "git args must be strings");
19+
return execFileSync("git", args, {
20+
cwd: root,
21+
encoding: "utf8",
22+
stdio: ["ignore", "pipe", options.allowFailure ? "pipe" : "inherit"],
23+
}).trim();
24+
}
25+
26+
function tryGit(args) {
27+
try {
28+
return git(args, { allowFailure: true });
29+
} catch {
30+
return undefined;
31+
}
32+
}
33+
34+
assert.equal(typeof version, "string", "package.json version must be a string");
35+
assert.match(version, semverPattern, `package.json version must be semver: ${version}`);
36+
37+
const tagName = `v${version}`;
38+
39+
if (releaseBranchName !== undefined && releaseBranchName !== "") {
40+
assert.equal(
41+
releaseBranchName,
42+
`release/${tagName}`,
43+
`release branch ${releaseBranchName} must match package version ${tagName}`,
44+
);
45+
}
46+
47+
const targetSha = releaseTargetSha?.trim() || git(["rev-parse", "HEAD"]);
48+
assert.match(targetSha, /^[0-9a-f]{40}$/i, `release target SHA must be a full commit SHA: ${targetSha}`);
49+
50+
const currentHead = git(["rev-parse", "HEAD"]);
51+
assert.equal(currentHead, targetSha, "checked-out HEAD must match the release target SHA");
52+
53+
const remoteTagSha = tryGit(["ls-remote", "--tags", "origin", `refs/tags/${tagName}`]);
54+
if (remoteTagSha) {
55+
const [existingSha, existingRef] = remoteTagSha.split(/\s+/, 2);
56+
assert.equal(existingRef, `refs/tags/${tagName}`, `unexpected remote tag ref for ${tagName}`);
57+
assert.equal(existingSha, targetSha, `remote tag ${tagName} already exists at ${existingSha}, not ${targetSha}`);
58+
console.log(JSON.stringify({ tagName, targetSha, created: false }, null, 2));
59+
process.exit(0);
60+
}
61+
62+
git(["tag", tagName, targetSha]);
63+
git(["push", "origin", `refs/tags/${tagName}`]);
64+
console.log(JSON.stringify({ tagName, targetSha, created: true }, null, 2));

0 commit comments

Comments
 (0)