diff --git a/.cursor/skills/release/SKILL.md b/.cursor/skills/release/SKILL.md new file mode 100644 index 0000000..ed5631a --- /dev/null +++ b/.cursor/skills/release/SKILL.md @@ -0,0 +1,49 @@ +--- +name: release +description: >- + Bumps package.json and jsr.json, pushes branch release/VERSION, opens a PR to + main with auto-merge, and relies on CI to publish a GitHub release after merge. + Use when the user invokes /release or /release VERSION, asks for a release PR, + or wants to ship a new semver version. +--- + +# Release (`/release` [VERSION]) + +In chat, users may type **`/release`** (patch bump) or **`/release 1.2.3`** (explicit semver). Treat that as this workflow. + +## Goal + +Cut a **release PR** from `release/` → `main` with version bumps in `package.json` and `jsr.json`. Optional **VERSION** defaults to **patch bump** from the current `package.json` version. + +## Preconditions + +- Clean git working tree (no uncommitted changes). +- `git` and [`gh`](https://cli.github.com/) installed and authenticated (`gh auth login`). +- Repo default branch is `main`. +- Remote branch `release/` must not already exist. + +## Steps + +1. **Resolve VERSION** (if the user did not pass one): read `package.json` `version`, bump **patch** (e.g. `0.2.1` → `0.2.2`). +2. **Run the automation script** from the repo root (preferred): + + ```bash + npm run release:pr -- [VERSION] + ``` + + Or: `node scripts/create-release-pr.mjs [VERSION]`. Omit `[VERSION]` for patch bump. + +3. If the script cannot enable auto-merge, tell the user to merge the PR manually once CI passes. + +## After merge + +Merging into `main` triggers `.github/workflows/release-on-merge.yml`, which creates GitHub release tag `v-` and release notes. That **published** release runs `publish.yml` (npm + JSR). + +## Tag convention + +GitHub release tag: `v-${VERSION}` (e.g. `0.2.2` → tag `v-0.2.2`), matching `publish.yml` expectations. + +## Do not + +- Commit unrelated files (e.g. stray `site/` or local-only dirs) on the release branch. +- Bump only one of `package.json` / `jsr.json`; both must match for publish. diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml new file mode 100644 index 0000000..6182b30 --- /dev/null +++ b/.github/workflows/release-on-merge.yml @@ -0,0 +1,52 @@ +name: Release on merge + +# When a release/* PR merges into main, create a GitHub release (tag v-). +# The published release triggers publish.yml (npm + JSR). + +on: + pull_request: + types: [closed] + branches: + - main + +concurrency: + group: release-on-merge-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + github-release: + if: >- + github.event.pull_request.merged == true && + startsWith(github.head_ref, 'release/') && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout merge commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Read version + id: ver + run: | + VERSION="$(node -p "require('./package.json').version")" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.ver.outputs.version }}" + TAG="v-${VERSION}" + if gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release ${TAG} already exists; skipping." + exit 0 + fi + gh release create "${TAG}" \ + --repo "${{ github.repository }}" \ + --title "${VERSION}" \ + --generate-notes diff --git a/jsr.json b/jsr.json index b0d0e4e..8329730 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@proxymesh/javascript-proxy-headers", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "exports": "./mod.ts" } diff --git a/package.json b/package.json index c9a9a31..9f30d64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-proxy-headers", - "version": "0.2.0", + "version": "0.2.1", "description": "Extensions for JavaScript HTTP libraries to support sending and receiving custom proxy headers during HTTPS CONNECT tunneling", "type": "module", "main": "index.js", @@ -59,6 +59,7 @@ "LICENSE" ], "scripts": { + "release:pr": "node scripts/create-release-pr.mjs", "test": "node test/test_proxy_headers.js core axios node-fetch got undici superagent ky wretch make-fetch-happen needle typed-rest-client", "test:run": "node run_tests.js", "test:verbose": "node test/test_proxy_headers.js -v", diff --git a/scripts/create-release-pr.mjs b/scripts/create-release-pr.mjs new file mode 100644 index 0000000..363a8b6 --- /dev/null +++ b/scripts/create-release-pr.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Bumps package.json + jsr.json, opens release/VERSION → main PR, enables auto-merge. + * Usage: node scripts/create-release-pr.mjs [VERSION] + * If VERSION is omitted, bumps the patch segment of the current package.json version. + */ +import { execFileSync, execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function sh(cmd, inherit = false) { + return execSync(cmd, { + cwd: repoRoot, + encoding: "utf8", + stdio: inherit ? "inherit" : "pipe", + }).trim(); +} + +function gh(args, inherit = false) { + if (inherit) { + execFileSync("gh", args, { cwd: repoRoot, stdio: "inherit" }); + return ""; + } + return execFileSync("gh", args, { cwd: repoRoot, encoding: "utf8" }).trim(); +} + +function ensureCleanWorkingTree() { + const out = sh("git status --porcelain"); + if (out) { + throw new Error( + "Working tree is not clean. Commit or stash changes before running this script.", + ); + } +} + +function refExists(ref) { + try { + execSync(`git rev-parse --verify --quiet "${ref}"`, { + cwd: repoRoot, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +function remoteHeadsHas(branch) { + const out = sh(`git ls-remote --heads origin "${branch}"`); + return out.length > 0; +} + +function parseSemver(v) { + const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!m) { + throw new Error(`VERSION must be semver X.Y.Z (got ${JSON.stringify(v)})`); + } + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function bumpPatch(version) { + const [a, b, c] = parseSemver(version); + return `${a}.${b}.${c + 1}`; +} + +function cmpVersion(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return 1; + if (pa[i] < pb[i]) return -1; + } + return 0; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, obj) { + writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, "utf8"); +} + +function main() { + process.chdir(repoRoot); + + const arg = process.argv[2]; + const pkgPath = join(repoRoot, "package.json"); + const jsrPath = join(repoRoot, "jsr.json"); + + ensureCleanWorkingTree(); + + const pkg = readJson(pkgPath); + const current = pkg.version; + const target = arg ? String(arg).trim() : bumpPatch(current); + + parseSemver(target); + if (cmpVersion(target, current) <= 0) { + throw new Error( + `New version must be greater than current ${current} (got ${target})`, + ); + } + + sh("git fetch origin main", true); + sh("git checkout main", true); + sh("git pull --ff-only origin main", true); + + const branch = `release/${target}`; + if (refExists(`refs/heads/${branch}`)) { + throw new Error( + `Local branch ${branch} already exists. Delete it or pick another version.`, + ); + } + if (remoteHeadsHas(branch)) { + throw new Error( + `Remote branch origin/${branch} already exists. Delete it or pick another version.`, + ); + } + + sh(`git checkout -b "${branch}"`, true); + + pkg.version = target; + writeJson(pkgPath, pkg); + + const jsr = readJson(jsrPath); + jsr.version = target; + writeJson(jsrPath, jsr); + + sh(`git add package.json jsr.json`, true); + sh(`git commit -m "chore(release): bump version to ${target}"`, true); + sh(`git push -u origin "${branch}"`, true); + + const body = [ + "Automated release PR.", + "", + `- Bumps \`package.json\` and \`jsr.json\` to **${target}**`, + `- Merging publishes GitHub release \`v-${target}\` and triggers npm/JSR publish (see \`.github/workflows/\`).`, + ].join("\n"); + + const prUrl = gh([ + "pr", + "create", + "--base", + "main", + "--head", + branch, + "--title", + `Release ${target}`, + "--body", + body, + ]); + + console.log(prUrl); + + try { + gh(["pr", "merge", prUrl, "--auto", "--merge"], true); + console.log("Auto-merge enabled; PR will merge when required checks pass."); + } catch { + console.warn( + "\nCould not enable auto-merge (repo may not allow it, or checks are pending). Merge the PR manually when ready.", + ); + } +} + +main();