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
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 50 additions & 10 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<package.json version>`; 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions scripts/release-version.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
30 changes: 30 additions & 0 deletions scripts/release-version.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading