diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..9160ce0 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,108 @@ +--- +name: release +description: Cut a Quantum release — bump the ChronVer version, generate and curate the changelog, commit, tag, and push (the pushed tag triggers the CI container build). Use when the user wants to cut, ship, publish, or tag a new release or version of Quantum. +--- + +# Cut a Quantum release + +Quantum versions follow [ChronVer](https://chronver.org) +(`YYYY.MM.DD[.CHANGESET]`). `package.json` `version` is the source of truth; the +changelog keys off version-bump commits (no tags needed for it), and a pushed +**git tag** triggers the CI image build in `.tangled/workflows/build-image.yml`. +The repo uses **jj** (colocated git). + +Run these steps in order. Stop and surface anything unexpected rather than +forcing through. + +## 1. Start from a clean release commit + +The release commit should contain **only** the version bump + changelog. Run +`jj status`. If there is unrelated uncommitted work, commit it first with its +own description (`jj describe -m "..."; jj new`) so it is not folded into the +release. + +## 2. Preview the notes (optional) + +``` +deno task changelog +``` + +Shows the section that will be generated from commits since the last release. + +## 3. Cut the version + changelog + +``` +deno task release # normal release +deno task release --break # breaking change (appends -break) +deno task release --feature # feature branch (appends -feature) +``` + +This bumps `package.json` and prepends a `## ` section to +`CHANGELOG.md`. Same-day repeats auto-increment the changeset (`2026.07.16` → +`2026.07.16.1`). + +## 4. Curate CHANGELOG.md + +The generated section is a **draft**. Edit it to be user-facing: + +- Group entries under `### area` headings (match the `area:` commit-prefix + style). +- Move colon-less commits out of `### other` into a real area, or drop them. +- Cut internal churn (OpenSpec `propose`/`archive` commits, pure refactors, + tooling). +- Tighten wording to describe what changed for a user, not the commit history. + +Keep the format consistent with existing entries. Confirm the curated notes with +the user before committing. + +## 5. Commit the release + +Get the version, then commit and start a fresh working copy so the release +commit freezes at `@-`: + +``` +deno task version # e.g. 2026.07.16 +jj describe -m "release " +jj new +``` + +## 6. Advance main and push the branch + +``` +jj bookmark set main -r @- +jj git push +``` + +Commits are signed via 1Password on push; a desktop prompt may appear. If it +fails with a signing error, the user needs to unlock 1Password, then retry. + +## 7. Tag and trigger CI + +``` +deno task tag --push +``` + +Creates the annotated ChronVer tag on the release commit and pushes it. The tag +push fires the CI workflow, which builds and publishes +`atcr.io/graham.systems/quantum` at `:` and `:latest`. (Tag push also +uses 1Password SSH auth.) + +## 8. Verify + +Point the user to the Tangled CI run and, once it succeeds, the registry page +(https://atcr.io/r/graham.systems/quantum) to confirm the new version's image +and OCI labels. + +--- + +## Notes + +- **Build locally instead of via CI** (e.g. to test before tagging): + `deno task image`, or `deno task image --push` to publish manually. +- **Order matters**: release → commit → tag. `deno task tag` refuses to run + until the version bump is committed, and errors if the tag already exists. +- **Pieces**: `scripts/version.ts` (ChronVer derivation), `release.ts` (bump + + changelog), `changelog.ts` (section generation), `tag.ts` (tagging), + `build-image.ts` (`deno task + image`), `.tangled/workflows/build-image.yml` + (CI). diff --git a/.tangled/workflows/build-image.yml b/.tangled/workflows/build-image.yml new file mode 100644 index 0000000..ec8d2ca --- /dev/null +++ b/.tangled/workflows/build-image.yml @@ -0,0 +1,42 @@ +# Build and publish the Quantum container image to atcr.io on every ChronVer +# release tag (e.g. 2026.07.16, 2026.07.16.1). Cut a release with the `release` +# skill / `deno task release` + `deno task tag --push`; the pushed tag triggers +# this pipeline. +# +# Required repository secrets (Settings -> Secrets): +# REGISTRY_USER - atcr.io username (your handle or DID) +# REGISTRY_TOKEN - atcr.io access token / password +# +# Skip a run with: git push -o skip-ci +when: + - event: ["push"] + # ChronVer tags are date-led (YYYY.MM.DD[.CHANGESET][-feature|-break]). + tag: ["2*"] + +engine: microvm +image: nixos + +virtualisation: + docker: true + +steps: + - name: "Build and push atcr.io/graham.systems/quantum" + command: | + set -euo pipefail + + image="atcr.io/graham.systems/quantum" + version="$TANGLED_REF_NAME" + + echo "$REGISTRY_TOKEN" | docker login atcr.io -u "$REGISTRY_USER" --password-stdin + + # VERSION/CREATED/REVISION feed the OCI image labels (see Dockerfile). + docker build \ + --build-arg VERSION="$version" \ + --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ + -t "$image:$version" \ + -t "$image:latest" \ + . + + docker push "$image:$version" + docker push "$image:latest" diff --git a/.tangled/workflows/ci.yml b/.tangled/workflows/ci.yml new file mode 100644 index 0000000..2e72e64 --- /dev/null +++ b/.tangled/workflows/ci.yml @@ -0,0 +1,30 @@ +# Continuous checks: typecheck and tests on every push and pull request to main. +# The container image is built separately, on release tags (build-image.yml). +# +# Skip a run with: git push -o skip-ci +when: + - event: ["push", "pull_request"] + branch: ["main"] + +engine: "nixery" + +dependencies: + nixpkgs: + - deno + - git + +environment: + DENO_NO_UPDATE_CHECK: "1" + +steps: + - name: "Check formatting" + command: deno task fmt:check + + - name: "Install dependencies" + command: deno install + + - name: "Typecheck (svelte-check)" + command: deno task check + + - name: "Test" + command: deno task test diff --git a/deno.json b/deno.json index 8fb60df..80c7b91 100644 --- a/deno.json +++ b/deno.json @@ -1,16 +1,24 @@ { "license": "AGPL-3.0-or-later", "nodeModulesDir": "auto", + "unstable": ["fmt-component"], + "fmt": { + "useTabs": true, + "exclude": [".svelte-kit", "build", "node_modules"] + }, "tasks": { "dev": "deno run --env-file --unstable-cron -A npm:vite dev", "build": "deno run -A npm:vite build", "start": "deno run --env-file --unstable-cron -A build/index.js", "check": "deno run -A npm:@sveltejs/kit/svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json", "test": "deno test -A src", + "fmt": "deno fmt", + "fmt:check": "deno fmt --check", "generate-key": "deno run scripts/generate-oauth-key.ts", "version": "deno run --allow-read scripts/version.ts", "changelog": "deno run --allow-read --allow-run=git scripts/changelog.ts", "release": "deno run --allow-read --allow-write --allow-run=git scripts/release.ts", + "tag": "deno run --allow-read --allow-run=git scripts/tag.ts", "image": "deno run --allow-read --allow-run=git,docker scripts/build-image.ts" } } diff --git a/scripts/tag.ts b/scripts/tag.ts new file mode 100644 index 0000000..bc3f977 --- /dev/null +++ b/scripts/tag.ts @@ -0,0 +1,88 @@ +// Create (and optionally push) the ChronVer git tag for the current release. +// The version is read from package.json; the tag is an annotated tag on the +// commit that set it. Run this AFTER committing the release bump — pushing the +// tag is what triggers the CI image build (.tangled/workflows/build-image.yml). +// +// deno task tag # create the annotated tag locally +// deno task tag --push # also push it to origin (triggers CI) +import { isChronVer, pkgVersion } from "./version.ts"; +import { lastReleaseCommit } from "./changelog.ts"; + +async function git(args: string[]) { + const { code, stdout, stderr } = await new Deno.Command("git", { + args, + stdout: "piped", + stderr: "piped", + }).output(); + return { + code, + out: new TextDecoder().decode(stdout).trim(), + err: new TextDecoder().decode(stderr).trim(), + }; +} + +const version = await pkgVersion(); +if (!isChronVer(version)) { + console.error( + `package.json version "${version}" is not a ChronVer release — run \`deno task release\` first.`, + ); + Deno.exit(1); +} + +const commit = await lastReleaseCommit(); +if (!commit) { + console.error( + "No commit has set the version yet — commit the release bump first.", + ); + Deno.exit(1); +} + +// Guard: make sure the release bump is actually committed (not just in the +// working copy), so the tag lands on the right commit. +const shown = await git(["show", `${commit}:package.json`]); +let committedVersion = ""; +try { + committedVersion = JSON.parse(shown.out).version; +} catch { /* ignore */ } +if (committedVersion !== version) { + console.error( + `The bump to ${version} isn't committed yet (that commit's package.json is ${ + committedVersion || "?" + }). Commit the release, then re-run.`, + ); + Deno.exit(1); +} + +if ((await git(["tag", "--list", version])).out === version) { + console.error(`Tag ${version} already exists.`); + Deno.exit(1); +} + +const created = await git([ + "tag", + "-a", + version, + "-m", + `release ${version}`, + commit, +]); +if (created.code !== 0) { + console.error(created.err); + Deno.exit(1); +} +console.log(`Created tag ${version} at ${commit.slice(0, 12)}.`); + +if (Deno.args.includes("--push")) { + console.log("Pushing tag to origin (may prompt 1Password for SSH auth)..."); + const pushed = await git(["push", "origin", version]); + if (pushed.code !== 0) { + console.error(pushed.err); + Deno.exit(1); + } + console.log(pushed.err || pushed.out || "Pushed."); + console.log("CI will build and publish the image for this tag."); +} else { + console.log( + `Next: \`deno task tag --push\` (or \`git push origin ${version}\`) to trigger CI.`, + ); +}