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
17 changes: 15 additions & 2 deletions .agents/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: release
description: "Plan and publish a GitHub Release in a tag-driven repository. Use when a user asks to cut, prepare, or publish a software release, propose the next vX.Y.Z tag, draft better release notes from PRs and direct commits since the last release, update CHANGELOG.md, create the tag pinned to an exact commit, and watch the publish workflow."
description: "Plan and publish a GitHub Release in a tag-driven repository. Use when a user asks to cut, prepare, or publish a software release, propose the next vX.Y.Z tag, support prerelease tags like vX.Y.Z-beta.N, draft better release notes from PRs and direct commits since the last release, update CHANGELOG.md, create the tag pinned to an exact commit, and watch the publish workflow."
---

# Release
Expand All @@ -16,9 +16,10 @@ Use this skill for repos that publish from GitHub Releases and want human-writte
- Fast-forward the default branch before editing: `git pull --ff-only origin <default-branch>`.
- Never force-push the default branch.
- Never use GitHub generated release notes for this workflow.
- Always create the release tag with a leading `v`, for example `v0.6.0`.
- Always create the release tag with a leading `v`, for example `v0.6.0` or `v0.6.0-beta.1`.
- Always pin the release to the exact changelog commit SHA with `gh release create --target <sha>`.
- If `origin/<default-branch>` moves after planning or before pushing, stop and regenerate the release plan.
- If the tag is a prerelease such as `v0.6.0-beta.1`, the GitHub release must be created as a prerelease too.

## Helper Script

Expand Down Expand Up @@ -123,8 +124,20 @@ release_sha=$(git rev-parse HEAD)

5. Create the release from that exact commit.

Stable release:

```bash
gh release create "<tag>" \
--target "$release_sha" \
--title "<tag>" \
--notes-file .local/release/release-notes.md
```

Prerelease:

```bash
gh release create "<tag>" \
--prerelease \
--target "$release_sha" \
--title "<tag>" \
--notes-file .local/release/release-notes.md
Expand Down
29 changes: 25 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: Release tag in the form vX.Y.Z
description: Release tag in the form vX.Y.Z or vX.Y.Z-beta.N
required: true
type: string

Expand All @@ -28,6 +28,21 @@ jobs:
- name: Install Node.js and dependencies
uses: ./.github/actions/configure-nodejs

- name: Resolve release metadata
id: release_meta
run: pnpm release:metadata "$RELEASE_TAG"

- name: Validate GitHub prerelease flag
if: ${{ github.event_name == 'release' }}
env:
EXPECTED_PRERELEASE: ${{ steps.release_meta.outputs.is_prerelease }}
ACTUAL_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
if [ "$EXPECTED_PRERELEASE" != "$ACTUAL_PRERELEASE" ]; then
echo "Release tag prerelease state ($EXPECTED_PRERELEASE) does not match GitHub release.prerelease ($ACTUAL_PRERELEASE)." >&2
exit 1
fi

- name: Apply release version from tag
run: pnpm release:apply-version "$RELEASE_TAG"

Expand All @@ -48,10 +63,16 @@ jobs:
EOF

- name: Publish @ghcrawl/api-contract
run: pnpm --filter @ghcrawl/api-contract publish --no-git-checks --access public
env:
NPM_DIST_TAG: ${{ steps.release_meta.outputs.npm_dist_tag }}
run: pnpm --filter @ghcrawl/api-contract publish --no-git-checks --access public --tag "$NPM_DIST_TAG"

- name: Publish @ghcrawl/api-core
run: pnpm --filter @ghcrawl/api-core publish --no-git-checks --access public
env:
NPM_DIST_TAG: ${{ steps.release_meta.outputs.npm_dist_tag }}
run: pnpm --filter @ghcrawl/api-core publish --no-git-checks --access public --tag "$NPM_DIST_TAG"

- name: Publish ghcrawl
run: pnpm --filter ghcrawl publish --no-git-checks --access public
env:
NPM_DIST_TAG: ${{ steps.release_meta.outputs.npm_dist_tag }}
run: pnpm --filter ghcrawl publish --no-git-checks --access public --tag "$NPM_DIST_TAG"
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"project:sync": "node ./.agents/skills/project-manager/scripts/sync-work-items.mjs",
"test:cluster-perf": "pnpm --filter @ghcrawl/api-core test:cluster-perf",
"pack:smoke": "node ./scripts/pack-smoke.mjs",
"release:metadata": "node ./scripts/release-metadata.mjs",
"release:apply-version": "node ./scripts/apply-release-version.mjs",
"typecheck": "pnpm build && pnpm -r typecheck",
"test": "pnpm -r test"
Expand Down
32 changes: 11 additions & 21 deletions scripts/apply-release-version.mjs
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseReleaseTag } from "./release-tag.mjs";

const workspaceRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const version = parseReleaseVersion(process.argv[2]);
const version = parseReleaseTag(process.argv[2]).version;

const packageJsonPaths = [
'package.json',
'apps/cli/package.json',
'apps/web/package.json',
'packages/api-contract/package.json',
'packages/api-core/package.json',
"package.json",
"apps/cli/package.json",
"apps/web/package.json",
"packages/api-contract/package.json",
"packages/api-core/package.json",
];

for (const relativePath of packageJsonPaths) {
const absolutePath = path.join(workspaceRoot, relativePath);
const packageJson = JSON.parse(readFileSync(absolutePath, 'utf8'));
const packageJson = JSON.parse(readFileSync(absolutePath, "utf8"));
packageJson.version = version;
writeFileSync(absolutePath, `${JSON.stringify(packageJson, null, 2)}\n`);
process.stdout.write(`updated ${relativePath} -> ${version}\n`);
}

function parseReleaseVersion(tagName) {
if (!tagName) {
throw new Error('Missing release tag. Expected vX.Y.Z');
}
const match = tagName.trim().match(/^v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/);
if (!match) {
throw new Error(`Invalid release tag: ${tagName}. Expected vX.Y.Z`);
}
return match[1];
}
17 changes: 17 additions & 0 deletions scripts/release-metadata.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { appendFileSync } from "node:fs";
import { parseReleaseTag } from "./release-tag.mjs";

const metadata = parseReleaseTag(process.argv[2]);

if (process.env.GITHUB_OUTPUT) {
appendGitHubOutput("version", metadata.version);
appendGitHubOutput("is_prerelease", String(metadata.isPrerelease));
appendGitHubOutput("release_channel", metadata.channel ?? "stable");
appendGitHubOutput("npm_dist_tag", metadata.npmDistTag);
}

process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);

function appendGitHubOutput(name, value) {
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
33 changes: 33 additions & 0 deletions scripts/release-tag.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const RELEASE_TAG_RE =
/^v(?<version>\d+\.\d+\.\d+(?:-(?<prerelease>[0-9A-Za-z.-]+))?(?:\+(?<build>[0-9A-Za-z.-]+))?)$/;
const CHANNEL_RE = /^[A-Za-z][0-9A-Za-z-]*$/;

export function parseReleaseTag(tagName) {
if (!tagName) {
throw new Error("Missing release tag. Expected vX.Y.Z or vX.Y.Z-beta.N");
}

const match = tagName.trim().match(RELEASE_TAG_RE);
if (!match?.groups?.version) {
throw new Error(`Invalid release tag: ${tagName}. Expected vX.Y.Z or vX.Y.Z-beta.N`);
}

const prerelease = match.groups.prerelease ?? null;
const firstIdentifier = prerelease ? prerelease.split(".")[0] : null;
const channel = firstIdentifier ? firstIdentifier.toLowerCase() : null;

if (channel && !CHANNEL_RE.test(channel)) {
throw new Error(
`Invalid prerelease channel in tag ${tagName}: ${firstIdentifier}. Expected a leading alphabetic identifier like beta or rc.`,
);
}

return {
tagName: tagName.trim(),
version: match.groups.version,
prerelease,
isPrerelease: prerelease !== null,
channel,
npmDistTag: channel ?? "latest",
};
}
32 changes: 32 additions & 0 deletions scripts/release-tag.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseReleaseTag } from "./release-tag.mjs";

test("parseReleaseTag returns latest for stable tags", () => {
assert.deepEqual(parseReleaseTag("v0.7.0"), {
tagName: "v0.7.0",
version: "0.7.0",
prerelease: null,
isPrerelease: false,
channel: null,
npmDistTag: "latest",
});
});

test("parseReleaseTag returns prerelease metadata for beta tags", () => {
assert.deepEqual(parseReleaseTag("v0.7.0-beta.2"), {
tagName: "v0.7.0-beta.2",
version: "0.7.0-beta.2",
prerelease: "beta.2",
isPrerelease: true,
channel: "beta",
npmDistTag: "beta",
});
});

test("parseReleaseTag rejects channels that do not start with a letter", () => {
assert.throws(
() => parseReleaseTag("v0.7.0-1beta.2"),
/Expected a leading alphabetic identifier like beta or rc/,
);
});
Loading