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
153 changes: 153 additions & 0 deletions .github/scripts/create-or-update-github-release.mjs
Original file line number Diff line number Diff line change
@@ -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 =
/^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>[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));
}
110 changes: 110 additions & 0 deletions .github/scripts/prepare-release-changelog.mjs
Original file line number Diff line number Diff line change
@@ -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,
),
);
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ concurrency:

env:
MISE_EXPERIMENTAL: true
MISE_LOCKED: true

jobs:
ci:
Expand All @@ -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
Expand Down
58 changes: 55 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ concurrency:

env:
MISE_EXPERIMENTAL: true
MISE_LOCKED: true

jobs:
prebuilds:
Expand All @@ -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:
Expand All @@ -66,7 +65,9 @@ jobs:
permissions:
contents: write
actions: read
issues: read
id-token: write
pull-requests: read
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -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 }}
Loading
Loading