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
86 changes: 86 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
name: release
description: Cut a Scratchwork release end to end — version bump PR, tag, GitHub Release, npm publish, homepage republish, install smoke test. Use when asked to release, ship, or cut vX.Y.Z.
---

# Release

Drive the release loop in `RELEASING.md` (the normative doc — read it first; if it
and this skill disagree, follow it and fix the drift in the same PR). One lockstep
version for the whole repo, one tag per release.

**Confirmation gates:** pushing the tag, publishing to npm, and republishing the
homepage are public and effectively irreversible. Pause and confirm with the user
before each one, unless they explicitly asked for an unattended release up front.
Everything else (branches, PRs, dry runs) proceeds without asking.

## Procedure

### 1. Preflight

- Start from a clean, up-to-date `main` (`git status`, `git pull`).
- Determine the version. If the user didn't give one, propose it from the
`## Unreleased` section of `CHANGELOG.md` (breaking → minor while pre-1.0,
otherwise patch) and confirm.
- `## Unreleased` must have real content; if it's empty, stop and ask what this
release is.
- `gh auth status` and `npm whoami` must both succeed. Fail fast, not at step 5.

### 2. Bump PR

- Branch: `git checkout -b release-vX.Y.Z`.
- `bun scripts/set-version.ts X.Y.Z` (stamps root + every workspace).
- In `CHANGELOG.md`: retitle `## Unreleased` to `## vX.Y.Z` and add a fresh empty
`## Unreleased` above it.
- `bun run ci` locally (enforces lockstep; requires Docker running for the e2e
LocalStack lane).
- Commit, push, `gh pr create`. Watch CI with `gh pr checks --watch`.

### 3. Merge and tag

- **Gate:** confirm the user is ready to ship, then merge the PR
(`gh pr merge --merge`), pull `main`, and verify HEAD carries the bump
(`package.json` version is X.Y.Z).
- **Gate passed above covers the tag too:** `git tag vX.Y.Z && git push origin vX.Y.Z`.
- The tag triggers `.github/workflows/release.yml`. Watch it
(`gh run watch --exit-status`); on success verify the Release exists with the four
`*.tar.gz` archives + `checksums.txt`: `gh release view vX.Y.Z`.

### 4. npm publish

- Publish from a clean checkout of the tag, never the working tree. Use a scratch
clone/worktree checked out at `vX.Y.Z` (the script itself refuses dirty trees and
untagged HEADs).
- Dry-run first: `bun scripts/publish-packages.ts --dry-run` — review the five
package names and the version.
- **Gate:** confirm, then `bun scripts/publish-packages.ts`. If a mid-sequence
publish fails, report which packages went live and which didn't; already-published
versions cannot be re-pushed, so the fix is forward (patch release), not retry.

### 5. Homepage republish

- **Gate:** confirm, then `scratchwork publish scratchwork.dev/www --project www --public`
with production credentials, so `https://scratchwork.dev/install.sh` serves the current content.
This needs an interactive `scratchwork login` against production — if not logged
in, hand this step to the user rather than skipping it silently.

### 6. Smoke test

Run the real install path in a clean container (no Scratchwork, no repo):

```sh
docker run --rm debian:stable-slim sh -c '
apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null &&
curl -fsSL https://scratchwork.dev/install.sh | bash &&
"$HOME/.local/bin/scratchwork" --version'
```

Pass = it prints `X.Y.Z`. If the homepage step was deferred, smoke against the
GitHub Release directly instead by setting `SCRATCHWORK_DOWNLOAD_BASE` to
`https://github.com/scratch/scratchwork/releases` inside the container.

## Report

End with: version shipped, PR and Release URLs, the five npm package versions,
whether the homepage was republished, and the smoke-test output. List any step that
was skipped or handed to the user — never imply an unfinished release is done.
48 changes: 48 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Release: triggered by pushing a tag vX.Y.Z (see RELEASING.md). Runs the same
# one gate as ci.yml — a release never ships what `bun run ci` wouldn't pass —
# then cross-compiles the CLI target matrix, packages the archives, and
# publishes a GitHub Release with the matching CHANGELOG.md section as notes.
# npm publishing stays a manual local step for now (scripts/publish-packages.ts).
name: release

on:
push:
tags: ["v*"]

# Creates the GitHub Release and uploads its assets.
permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- run: bun install --frozen-lockfile
- run: bun run ci
- name: Assert the tag matches the lockstep version
run: |
version="$(bun -e 'console.log(JSON.parse(await Bun.file("package.json").text()).version)')"
if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
echo "tag ${GITHUB_REF_NAME} does not match package.json version v${version}" >&2
echo "bump with \`bun scripts/set-version.ts\` and re-tag" >&2
exit 1
fi
- name: Cross-compile the CLI target matrix
run: cd cli && bun build.js --all-targets
- name: Package archives and checksums
run: bun scripts/package-release.ts
- name: Extract release notes from CHANGELOG.md
run: bun scripts/release-notes.ts "${GITHUB_REF_NAME#v}" > release/notes.md
- name: Create the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
release/*.tar.gz release/checksums.txt \
--title "${GITHUB_REF_NAME}" \
--notes-file release/notes.md
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
node_modules/
dist/

# Release staging written by scripts/package-release.ts and
# scripts/build-packages.ts; assets upload to GitHub Releases / npm, never git.
# Anchored to the repo root so directories like .claude/skills/release/ stay
# trackable.
/release/

# Local environment files. Keep .env.example files committed.
**/.env
**/.env.*
Expand Down
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,27 @@ Package names are `@scratchwork/<dir>` for everything under `server/` and `deplo
importable by both cli and server.
- `cli/` — the `scratchwork` CLI, built with Effect: dev server with hot reload,
publishing, login.
- `server/` — deploy tooling scripts only; the server itself lives in its sub-workspaces:
- `server/` — the server workspaces (the directory itself is not a workspace):
- `server/core/` — platform-neutral publishing server core: auth, routing, storage
contracts.
contracts, and the deploy-script tooling (`src/deploy/`).
- `server/deploy-aws/` — AWS Lambda + S3/DynamoDB adapters.
- `server/deploy-cloudflare/` — Cloudflare Worker + R2/D1 adapters.
- `server/deploy-local/` — local Bun deploy target.
- `deploy/*` — one deployment project per domain (generic-aws, cloudflare-vanilla,
cloudflare-access, local-dev), each deployable with one command.
- `scratchwork.dev/` — the scratchwork.dev site: `server/` is its Cloudflare
deployment workspace (same shape as `deploy/*`), `www/` the homepage project
published to it (content, not a workspace).
- `e2e/` — full-loop publish e2e: real server (local-dev, miniflare, LocalStack) driven
by the real CLI, hermetic OAuth provider standing in for Google.

`examples/` and `notes/` are deliberately outside the gate: examples are user-facing
sample content, notes are working documents. Nothing in them is imported by shipped code.
`examples/`, `notes/`, and `scratchwork.dev/www/` are deliberately outside the gate:
examples are user-facing sample content, notes are working documents, and
`scratchwork.dev/www/` is the published homepage project (landing page plus the
`install.sh` / `install.md` entry points — the install script alone is exercised by
`scripts/check-install-sh.ts` in the gate). Nothing in them is imported by shipped code.
`scratchwork.dev/server/` is different: it is the deployment workspace for the
scratchwork.dev server, inside the gate like the `deploy/*` projects.

## Build and test

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Changelog

Newest first. Maintained by hand per release; the release workflow copies the
top released section into the GitHub Release notes (scripts/release-notes.ts).

## Unreleased

- Distribution: cross-platform CLI binaries on GitHub Releases, `install.sh` /
`install.md` served from scratchwork.dev, and the server packages
(`@scratchwork/shared`, `@scratchwork/server-core`,
`@scratchwork/server-deploy-{aws,cloudflare,local}`) published to npm as
built JS + type declarations.

## v0.1.0

- Pre-distribution baseline: the `scratchwork` CLI (dev server, publish,
share), the single-file Markdown renderer, and the publishing server for
local, AWS, and Cloudflare deploy targets.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Peter Koomen

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/scratchwork-logo.svg" alt="Scratchwork" width="480" />
<img src="scratchwork.dev/www/scratchwork-logo.svg" alt="Scratchwork" width="480" />
</p>

Scratchwork is a local development tool for static HTML and Markdown artifacts created by your coding agent.
Expand Down Expand Up @@ -58,7 +58,7 @@ changing to another directory.

## Working with Markdown

Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`docs/index.md`](docs/index.md) for live examples.
Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`scratchwork.dev/www/index.md`](scratchwork.dev/www/index.md) for live examples.

To use the docs page as a starting point for your own project, run:

Expand Down
30 changes: 30 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Releasing Scratchwork

One version for the whole repo (CLI binaries + npm packages), one tag per
release. Short enough to actually follow — or ask an agent to run the
`/release` skill (`.claude/skills/release/SKILL.md`), which drives this loop
with confirmation gates at the irreversible steps:

1. **Bump:** `bun scripts/set-version.ts X.Y.Z` (stamps root + every
workspace; `bun run ci` enforces lockstep).
2. **Changelog:** retitle the `## Unreleased` section of `CHANGELOG.md` to
`## vX.Y.Z` and start a fresh `## Unreleased` above it.
3. **PR + merge:** open a PR with the bump, let ci pass, merge to main.
4. **Tag:** `git tag vX.Y.Z && git push origin vX.Y.Z` on the merge commit.
`.github/workflows/release.yml` runs the full gate, asserts the tag matches
the lockstep version, cross-compiles the four CLI targets, and publishes
the GitHub Release with archives + `checksums.txt` and the changelog
section as notes.
5. **npm:** from a clean checkout of the tag, with an authenticated npm CLI:
`bun scripts/publish-packages.ts` (add `--dry-run` first if in doubt).
Publishes `@scratchwork/shared`, `@scratchwork/server-core`, and the three
`@scratchwork/server-deploy-*` packages in dependency order.
6. **Homepage:** republish the install entry points so
`https://scratchwork.dev/install.sh` serves the current content:
`scratchwork publish scratchwork.dev/www --project www --public` (with the
production server credentials). Automating this in release.yml is a follow-up.
7. **Smoke:** on a machine (or clean container) without Scratchwork:
`curl -fsSL https://scratchwork.dev/install.sh | bash` and check
`scratchwork --version` prints X.Y.Z.

Fix this document in the same PR whenever reality disagrees with it.
14 changes: 10 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 23 additions & 10 deletions cli/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,41 @@
* compiler embeds into the binary, so it runs anywhere with no renderer
* source or dist on disk.
*
* With --all-targets, cross-compiles the release target matrix instead
* (decision 4 in notes/distribution-plan.md) into cli/dist/scratchwork-<os>-<arch>.
* The default (no flag) build is unchanged so `bun run ci` cost stays the same.
*
* Requires the renderer's deps to be installed (cd ../renderer && bun install).
*/
import { buildDist } from "../renderer/build.js";
import { RELEASE_TARGETS } from "../scripts/release-targets";
import { mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(here, "dist");
const OUT_BIN = join(OUT_DIR, "scratchwork");

// 1. Build the renderer shell the binary will embed through shared/src/site.
await buildDist();

// 2. Compile src/index.ts into a binary.
// 2. Compile src/index.ts — host binary by default, the matrix with --all-targets.
mkdirSync(OUT_DIR, { recursive: true });
const proc = Bun.spawnSync(
["bun", "build", join(here, "src", "index.ts"), "--compile", "--outfile", OUT_BIN],
{ cwd: here, stdout: "inherit", stderr: "inherit" },
);
if (!proc.success) {
console.error("cli build: `bun build --compile` failed");
process.exit(1);
const builds = process.argv.includes("--all-targets")
? RELEASE_TARGETS.map((target) => ({
outfile: join(OUT_DIR, `scratchwork-${target}`),
extraArgs: [`--target=bun-${target}`],
}))
: [{ outfile: join(OUT_DIR, "scratchwork"), extraArgs: [] }];

for (const { outfile, extraArgs } of builds) {
const proc = Bun.spawnSync(
["bun", "build", join(here, "src", "index.ts"), "--compile", ...extraArgs, "--outfile", outfile],
{ cwd: here, stdout: "inherit", stderr: "inherit" },
);
if (!proc.success) {
console.error(`cli build: \`bun build --compile ${extraArgs.join(" ")}\` failed`);
process.exit(1);
}
console.log(`Built ${outfile.slice(dirname(here).length + 1)} (standalone binary, renderer shell embedded)`);
}
console.log(`Built cli/dist/scratchwork (standalone binary, renderer shell embedded)`);
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@effect/printer": "0.49.0",
"@effect/printer-ansi": "0.49.0",
"@effect/typeclass": "0.40.0",
"@scratchwork/shared": "workspace:*",
"effect": "3.21.4"
},
"files": [
Expand Down
2 changes: 1 addition & 1 deletion cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import * as Either from "effect/Either";
import * as Option from "effect/Option";
import type * as ParseResult from "effect/ParseResult";
import * as Schema from "effect/Schema";
import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ScratchworkApi } from "../../shared/src/publish/api";
import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ScratchworkApi } from "@scratchwork/shared/publish/api";
import { readAuthToken, readCfToken } from "./auth";
import { CliError, errorMessage } from "./errors";

Expand Down
Loading
Loading