From aed6a571645edd2dbcd6da6e04e9de470f31df7d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 13 Jul 2026 11:56:01 -0700 Subject: [PATCH] codex: add manual patch release workflow --- .github/workflows/ci.yaml | 1 + .github/workflows/release.yaml | 60 ++++++++++++++++++++++++++------ README.md | 4 ++- package.json | 3 +- scripts/release-version.mjs | 55 +++++++++++++++++++++++++++++ scripts/release-version.test.mjs | 30 ++++++++++++++++ 6 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 scripts/release-version.mjs create mode 100644 scripts/release-version.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fd2e6e8..dbc62f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,6 +26,7 @@ jobs: cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm test:release - run: pnpm test - run: pnpm build - run: pnpm test:integration diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 91ad0d1..ef7aeb5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,21 +1,24 @@ name: Release -on: - release: - types: [published] +on: workflow_dispatch permissions: - contents: read + contents: write id-token: write +concurrency: + group: release + cancel-in-progress: false + jobs: - publish: + release: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v6 with: - ref: ${{ github.event.release.tag_name }} fetch-depth: 0 + persist-credentials: false - uses: pnpm/action-setup@v4 with: version: 10.33.0 @@ -24,18 +27,55 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" package-manager-cache: false - - name: Verify release source and tag + - name: Verify release source and resolve version + id: release run: | - git merge-base --is-ancestor "$GITHUB_SHA" origin/main - node -e "const expected = 'v' + require('./package.json').version; if (process.env.GITHUB_REF_NAME !== expected) { throw new Error('Expected release tag ' + expected + ', got ' + process.env.GITHUB_REF_NAME); }" + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release workflow must be run from main; got $GITHUB_REF" >&2 + exit 1 + fi + + git fetch --force origin main:refs/remotes/origin/main --tags + remote_main="$(git rev-parse origin/main)" + if [[ "$GITHUB_SHA" != "$remote_main" ]]; then + echo "Release source $GITHUB_SHA is not current origin/main $remote_main" >&2 + exit 1 + fi + + version="$(node scripts/release-version.mjs)" + tag="v$version" + if git show-ref --verify --quiet "refs/tags/$tag"; then + echo "Release tag $tag already exists" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Releasing dialcache@$version from $GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY" + - run: pnpm test:release - run: pnpm install --frozen-lockfile + - name: Stamp release version + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version - run: pnpm typecheck - run: pnpm test - run: pnpm build - run: pnpm test:package - - run: npm publish --provenance --access public + - name: Create draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: gh release create "$RELEASE_TAG" --draft --generate-notes --target "$GITHUB_SHA" --title "$RELEASE_TAG" + - name: Publish to npm + run: npm publish --provenance --access public env: # Required only for the first publish. Once dialcache exists on npm, # configure release.yaml as its trusted GitHub publisher and remove # this secret; npm will then use the workflow's OIDC token. NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: gh release edit "$RELEASE_TAG" --draft=false --latest diff --git a/README.md b/README.md index 5e35aea..05f9fe3 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,8 @@ Not included yet: ## Releasing -Publishing is driven by `.github/workflows/release.yaml`. Create a GitHub release whose tag exactly matches `v`; the workflow validates, builds, package-tests, and publishes the public npm package with provenance. +Publishing is driven manually from the `Release` workflow in GitHub Actions. Run it from `main`; the workflow rejects any other ref or a stale main commit. It calculates the next patch version from the highest stable `vX.Y.Z` tag, using the `package.json` version as the seed when no release tag exists. For example, the current `0.1.0` seed produces the first `v0.1.1` release, followed by `v0.1.2`. + +Every release is a patch bump. Semantic PR-title prefixes such as `feat:`, `fix:`, and `docs:` remain in the generated release notes but do not change the version magnitude. After validating, building, and package-testing the release artifact, the workflow creates a draft GitHub release, publishes the public npm package with provenance, and publishes the GitHub release only after npm succeeds. The first publish requires a granular npm token in the repository's `NPM_TOKEN` secret because npm trusted publishing can only be configured after the package exists. After the bootstrap release, configure `lan17/DialCache` and `release.yaml` as the package's trusted GitHub publisher, then remove the long-lived token. diff --git a/package.json b/package.json index 31f66c0..521ee99 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,12 @@ ], "scripts": { "build": "tsup src/index.ts src/node-redis.ts src/redis-protocol.ts src/valkey-glide.ts --format esm,cjs --dts --clean", - "check": "pnpm typecheck && pnpm test && pnpm build && pnpm test:package", + "check": "pnpm typecheck && pnpm test:release && pnpm test && pnpm build && pnpm test:package", "typecheck": "tsc --noEmit", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:package": "node scripts/test-package.mjs", + "test:release": "node --test scripts/release-version.test.mjs", "test:watch": "vitest" }, "packageManager": "pnpm@10.33.0", diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000..bb0a024 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,55 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const STABLE_VERSION_PATTERN = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function parseStableVersion(value, label) { + const match = STABLE_VERSION_PATTERN.exec(value); + if (match === null) { + throw new Error(`${label} must be a stable semantic version, got ${JSON.stringify(value)}`); + } + + const parts = match.slice(1).map(Number); + if (!parts.every(Number.isSafeInteger)) { + throw new Error(`${label} contains a component larger than Number.MAX_SAFE_INTEGER`); + } + return parts; +} + +function compareVersions(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return left[index] - right[index]; + } + } + return 0; +} + +export function nextPatchVersion(packageVersion, tags) { + const stableTags = tags + .filter((tag) => STABLE_VERSION_PATTERN.test(tag)) + .map((tag) => parseStableVersion(tag, "release tag")); + const baseline = stableTags.length === 0 + ? parseStableVersion(packageVersion, "package.json version") + : stableTags.reduce((highest, candidate) => compareVersions(candidate, highest) > 0 ? candidate : highest); + const [major, minor, patch] = baseline; + + if (patch === Number.MAX_SAFE_INTEGER) { + throw new Error("Cannot increment patch version beyond Number.MAX_SAFE_INTEGER"); + } + return `${major}.${minor}.${patch + 1}`; +} + +const isEntrypoint = process.argv[1] !== undefined + && fileURLToPath(import.meta.url) === process.argv[1]; + +if (isEntrypoint) { + const root = dirname(dirname(fileURLToPath(import.meta.url))); + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const tags = execFileSync("git", ["tag", "--list"], { cwd: root, encoding: "utf8" }) + .split("\n") + .filter(Boolean); + process.stdout.write(`${nextPatchVersion(packageJson.version, tags)}\n`); +} diff --git a/scripts/release-version.test.mjs b/scripts/release-version.test.mjs new file mode 100644 index 0000000..4d7a562 --- /dev/null +++ b/scripts/release-version.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nextPatchVersion } from "./release-version.mjs"; + +test("increments the package seed when there are no release tags", () => { + assert.equal(nextPatchVersion("0.1.0", []), "0.1.1"); +}); + +test("increments the highest stable release tag", () => { + assert.equal(nextPatchVersion("0.1.0", ["v0.1.2", "v0.1.10", "v0.2.0"]), "0.2.1"); +}); + +test("ignores unrelated and prerelease tags", () => { + assert.equal(nextPatchVersion("1.2.3", ["latest", "v2.0.0-rc.1"]), "1.2.4"); +}); + +test("rejects an invalid package seed", () => { + assert.throws( + () => nextPatchVersion("01.2.3", []), + /package\.json version must be a stable semantic version/, + ); +}); + +test("rejects an overflowing patch component", () => { + assert.throws( + () => nextPatchVersion(`1.2.${Number.MAX_SAFE_INTEGER}`, []), + /Cannot increment patch version/, + ); +});