diff --git a/DesignSystemIntroduction.md b/DesignSystemIntroduction.md index 9dfb40f..ad77028 100644 --- a/DesignSystemIntroduction.md +++ b/DesignSystemIntroduction.md @@ -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. +### 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] — ` 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 @@ -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: -- [ ] Push the seed contents to `gitea.example.com/whynot/whynot-design`. -- [ ] Tag `v0.2.0` immediately so consumers can pin. +- [ ] Push the seed contents to `gitea.coulomb.social/coulomb/whynot-design`. +- [ ] 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). - [ ] 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."* diff --git a/Makefile b/Makefile index 19cf0c3..a04c9d6 100644 --- a/Makefile +++ b/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) .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. @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. 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) diff --git a/README.md b/README.md index b8faa45..a3bd39a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Framework-agnostic by design. Consumers do **not** re-implement components per f ### Node-tooled consumer (React, Vite, Next, Vue, …) ```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 diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..8f80be9 --- /dev/null +++ b/scripts/release.mjs @@ -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 (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)`); diff --git a/workplans/WHYNOT-WP-0003-downstream-consumption.md b/workplans/WHYNOT-WP-0003-downstream-consumption.md index 81b5dc8..a997b87 100644 --- a/workplans/WHYNOT-WP-0003-downstream-consumption.md +++ b/workplans/WHYNOT-WP-0003-downstream-consumption.md @@ -84,7 +84,7 @@ follow up at its own pace → npx @whynot/design drift --update (adopt new sy ```task id: WHYNOT-WP-0003-T01 -status: todo +status: progress priority: high state_hub_task_id: "ac6ee3c1-859d-49d4-b5dc-71bdcd2821f9" ```