feat(release): make release tooling + versioning ritual (WHYNOT-WP-0003 T01)
- scripts/release.mjs + `make release VERSION=x.y.z`: guarded release cut — bumps package.json, relabels CHANGELOG [Unreleased] → [x.y.z], commits, and creates the annotated git tag. Refuses on dirty tree, existing tag, duplicate section, or empty [Unreleased]. Never pushes (outward step stays manual). - DesignSystemIntroduction.md §6: document the release ritual; §9: fix the stale bootstrap host + tag note. - README: bump the install pin to the first real tag (v0.3.0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -286,6 +286,27 @@ Stay in `0.x.x` until something built with this system is in production. While i
|
|||||||
|
|
||||||
Promotion past `1.0.0` should appear in `whynot-control/DECISIONS.md`. Same rule as promotion to Helix or Coulomb: it's a deliberate act, not a release-script side-effect.
|
Promotion past `1.0.0` should appear in `whynot-control/DECISIONS.md`. Same rule as promotion to Helix or Coulomb: it's a deliberate act, not a release-script side-effect.
|
||||||
|
|
||||||
|
### Release ritual
|
||||||
|
|
||||||
|
A release is a **git tag** (`vX.Y.Z`) — the immutable anchor a consuming repo pins
|
||||||
|
(`pnpm add …#vX.Y.Z`, or the published package version). Tags are cut from
|
||||||
|
`CHANGELOG.md`'s running `[Unreleased]` section:
|
||||||
|
|
||||||
|
1. Land all the work for the release on `main`, each change adding a `CHANGELOG.md`
|
||||||
|
`[Unreleased]` entry (the `pnpm check` gate enforces this).
|
||||||
|
2. Pick the bump per the table above (`patch`/`minor`/`major`).
|
||||||
|
3. Run **`make release VERSION=x.y.z`** (`scripts/release.mjs`). It:
|
||||||
|
- guards — refuses if the tree is dirty, if `vx.y.z` is already tagged, if a
|
||||||
|
`[x.y.z]` section already exists, or if `[Unreleased]` is empty;
|
||||||
|
- bumps `package.json`, relabels `[Unreleased]` → `[x.y.z] — <date>` and opens a
|
||||||
|
fresh empty `[Unreleased]`;
|
||||||
|
- commits `release: vx.y.z` and creates the annotated tag.
|
||||||
|
4. `git push --follow-tags origin main`.
|
||||||
|
5. Publish the package (see WHYNOT-WP-0003 T02) so consumers can `npm i` the version.
|
||||||
|
|
||||||
|
The script never half-applies and never pushes — pushing the tag is the one explicit,
|
||||||
|
outward step you take by hand.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Where Claude fits
|
## 7. Where Claude fits
|
||||||
@@ -324,8 +345,8 @@ This staging is exactly the *"low-cost learning first"* posture in `whynot-contr
|
|||||||
|
|
||||||
For whoever is bootstrapping this repo right now:
|
For whoever is bootstrapping this repo right now:
|
||||||
|
|
||||||
- [ ] Push the seed contents to `gitea.example.com/whynot/whynot-design`.
|
- [ ] Push the seed contents to `gitea.coulomb.social/coulomb/whynot-design`.
|
||||||
- [ ] Tag `v0.2.0` immediately so consumers can pin.
|
- [ ] Cut the first real tag with `make release VERSION=x.y.z` (see §6 — *Release ritual*) so consumers can pin.
|
||||||
- [ ] Add the repo as a remote dependency in **one** consuming app (the Django one) and verify imports work end-to-end. Follow [`MultiFrameworkSupport.md` §Django](./MultiFrameworkSupport.md#django-server-rendered-templates--htmx).
|
- [ ] Add the repo as a remote dependency in **one** consuming app (the Django one) and verify imports work end-to-end. Follow [`MultiFrameworkSupport.md` §Django](./MultiFrameworkSupport.md#django-server-rendered-templates--htmx).
|
||||||
- [ ] Open one trivial PR against `whynot-design` (e.g. a CHANGELOG typo) to confirm CI passes end-to-end.
|
- [ ] Open one trivial PR against `whynot-design` (e.g. a CHANGELOG typo) to confirm CI passes end-to-end.
|
||||||
- [ ] Record this bootstrap in `whynot-control/DECISIONS.md` as DEC-004 — *"Established whynot-design as the implementation surface, three-layer architecture, Lit web components as the canonical component layer."*
|
- [ ] Record this bootstrap in `whynot-control/DECISIONS.md` as DEC-004 — *"Established whynot-design as the implementation surface, three-layer architecture, Lit web components as the canonical component layer."*
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -10,7 +10,7 @@ NODE ?= node
|
|||||||
PYTHON ?= $(shell [ -x $(HOME)/llm-connect/.venv/bin/python ] && echo $(HOME)/llm-connect/.venv/bin/python || echo python3)
|
PYTHON ?= $(shell [ -x $(HOME)/llm-connect/.venv/bin/python ] && echo $(HOME)/llm-connect/.venv/bin/python || echo python3)
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help designbook-pull designbook-sync designbook-check ir adapt-lit recent-changes sync-styles test
|
.PHONY: help designbook-pull designbook-sync designbook-check ir adapt-lit recent-changes sync-styles test release
|
||||||
|
|
||||||
help: ## Show this help.
|
help: ## Show this help.
|
||||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||||
@@ -42,3 +42,7 @@ sync-styles: ## Regenerate src/elements/_styles.js from components.css.
|
|||||||
|
|
||||||
test: ## Run the Playwright visual-regression suite.
|
test: ## Run the Playwright visual-regression suite.
|
||||||
pnpm test:visual
|
pnpm test:visual
|
||||||
|
|
||||||
|
release: ## Cut a release: bump + cut CHANGELOG + tag. Usage: make release VERSION=0.3.0
|
||||||
|
@test -n "$(VERSION)" || { echo "usage: make release VERSION=x.y.z"; exit 2; }
|
||||||
|
$(NODE) scripts/release.mjs $(VERSION)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Framework-agnostic by design. Consumers do **not** re-implement components per f
|
|||||||
### Node-tooled consumer (React, Vite, Next, Vue, …)
|
### Node-tooled consumer (React, Vite, Next, Vue, …)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm add git+ssh://git@gitea.coulomb.social/coulomb/whynot-design.git#v0.2.0
|
pnpm add git+ssh://git@gitea.coulomb.social/coulomb/whynot-design.git#v0.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
88
scripts/release.mjs
Normal file
88
scripts/release.mjs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Cut a release: bump package.json, close the CHANGELOG [Unreleased] section,
|
||||||
|
// commit, and create an annotated git tag. The tag is the immutable version
|
||||||
|
// anchor consumers pin (WHYNOT-WP-0003). Publishing is a separate step (T02).
|
||||||
|
//
|
||||||
|
// make release VERSION=0.3.0 # or: node scripts/release.mjs 0.3.0
|
||||||
|
//
|
||||||
|
// Guards (refuses, never half-applies):
|
||||||
|
// - VERSION must be semver `x.y.z`
|
||||||
|
// - working tree must be clean (untracked files are allowed)
|
||||||
|
// - tag `vX.Y.Z` must not already exist
|
||||||
|
// - CHANGELOG `## [Unreleased]` must exist and be non-empty
|
||||||
|
// - CHANGELOG must not already have a `## [X.Y.Z]` section
|
||||||
|
//
|
||||||
|
// Exit codes (match adapters/ADAPTER_CONTRACT.md): 0 ok · 2 usage/config · 3 guard fail.
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const CHANGELOG = "CHANGELOG.md";
|
||||||
|
const PKG = "package.json";
|
||||||
|
|
||||||
|
function die(code, msg) {
|
||||||
|
console.error(`release: ${msg}`);
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
function git(args) {
|
||||||
|
return execSync(`git ${args}`, { encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (process.argv[2] || "").replace(/^v/, "");
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(raw)) {
|
||||||
|
die(2, `usage: node scripts/release.mjs <x.y.z> (got "${process.argv[2] ?? ""}")`);
|
||||||
|
}
|
||||||
|
const version = raw;
|
||||||
|
const tag = `v${version}`;
|
||||||
|
|
||||||
|
// Working tree must be clean (ignore untracked `??` lines, e.g. pnpm-lock.yaml).
|
||||||
|
const dirty = git("status --porcelain")
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l && !l.startsWith("??"));
|
||||||
|
if (dirty.length) {
|
||||||
|
die(2, `working tree not clean — commit or stash first:\n${dirty.join("\n")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag must not exist.
|
||||||
|
try {
|
||||||
|
git(`rev-parse --verify --quiet refs/tags/${tag}`);
|
||||||
|
die(3, `tag ${tag} already exists`);
|
||||||
|
} catch {
|
||||||
|
/* not found — good */
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelog = readFileSync(CHANGELOG, "utf8");
|
||||||
|
if (changelog.includes(`## [${version}]`)) {
|
||||||
|
die(3, `CHANGELOG already has a [${version}] section`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolate the [Unreleased] body (between its header and the next `## [`).
|
||||||
|
const unreleased = changelog.match(/## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/);
|
||||||
|
if (!unreleased) die(3, `CHANGELOG has no "## [Unreleased]" section`);
|
||||||
|
if (!unreleased[1].trim()) {
|
||||||
|
die(3, `CHANGELOG [Unreleased] is empty — nothing to release`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Cut: keep a fresh empty [Unreleased] above, relabel the old one to [version] — date.
|
||||||
|
const newChangelog = changelog.replace(
|
||||||
|
"## [Unreleased]\n",
|
||||||
|
`## [Unreleased]\n\n## [${version}] — ${today}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bump package.json with a targeted replace so the hand-aligned formatting survives.
|
||||||
|
const pkg = readFileSync(PKG, "utf8");
|
||||||
|
const bumped = pkg.replace(/("version":\s*)"[^"]+"/, `$1"${version}"`);
|
||||||
|
if (bumped === pkg) die(2, `could not find "version" in ${PKG}`);
|
||||||
|
|
||||||
|
writeFileSync(CHANGELOG, newChangelog);
|
||||||
|
writeFileSync(PKG, bumped);
|
||||||
|
|
||||||
|
git(`add ${PKG} ${CHANGELOG}`);
|
||||||
|
execSync(`git commit -m "release: ${tag}"`, { stdio: "inherit" });
|
||||||
|
execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log(`\n✓ released ${tag}`);
|
||||||
|
console.log(` next: git push --follow-tags origin main`);
|
||||||
|
console.log(` then: publish (WHYNOT-WP-0003 T02)`);
|
||||||
@@ -84,7 +84,7 @@ follow up at its own pace → npx @whynot/design drift --update (adopt new sy
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WHYNOT-WP-0003-T01
|
id: WHYNOT-WP-0003-T01
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ac6ee3c1-859d-49d4-b5dc-71bdcd2821f9"
|
state_hub_task_id: "ac6ee3c1-859d-49d4-b5dc-71bdcd2821f9"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user