diff --git a/.github/workflows/release-website.yml b/.github/workflows/release-website.yml new file mode 100644 index 0000000..d2b89be --- /dev/null +++ b/.github/workflows/release-website.yml @@ -0,0 +1,78 @@ +name: Release website + +# The website's own release line, independent of the binary's (see +# release.yml). A `website-v*` tag deploys the landing page — copy, layout, a +# newly advertised release — without publishing a vump version or implying one +# to anybody pinning it. The two tag shapes never collide: this one does not +# start with `v`. +on: + push: + tags: + - 'website-v[0-9]*' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + # A deploy in flight is left to finish. Cancelling one does not undo it; it + # just leaves Pages serving a half-known state. + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + + # Unlike release.yml, this uses the composite action rather than the + # build in this checkout. What is being verified here is the website's + # own version against the pushed tag, not the binary being released, so + # there is no regression a freshly built vump would catch that a + # published one would not — and a Rust toolchain and a two-minute build + # would otherwise be dragged into a job that deploys static files. + - name: Check tag matches the website version + uses: okcodes/vump/.github/actions/check@main + with: + version: ${{ github.ref_name }} + + - uses: pnpm/setup@v2 + with: + # The version comes from website/package.json's packageManager field, + # so local development and this job cannot drift apart. There is no + # package.json at the repository root to fall back on — this is a Rust + # project with a site in a subdirectory. + package-json-file: website/package.json + install: false + + - uses: actions/setup-node@v7 + with: + node-version: '26' + + # The committed lockfile is the deploy's declared input. Nothing is + # resolved, pinned or discovered at deploy time — a build of this commit + # produces the same bytes today and next month. + - name: Install + working-directory: website + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: website + run: pnpm run build + env: + VITE_WEBSITE_BUILD_SHA: ${{ github.sha }} + + - uses: actions/configure-pages@v6 + + - uses: actions/upload-pages-artifact@v5 + with: + path: website/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eea429..992362c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,10 @@ name: Release on: push: tags: - - 'v*' + # Digit-anchored so a tag that merely starts with v — `voldemort`, a + # branch-shaped name — cannot start a release. `website-v*` does not + # match either, which is what keeps the two release lines apart. + - 'v[0-9]*' permissions: contents: write # needed to create GitHub releases and upload assets @@ -16,7 +19,7 @@ jobs: name: Verify tag and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: @@ -81,7 +84,7 @@ jobs: asset: vump-windows-arm64.exe steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: @@ -124,7 +127,7 @@ jobs: KEYCHAIN_NAME: ci-signing-${{ github.run_id }}.keychain steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download built binaries uses: actions/download-artifact@v8 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ca60b1..dc762fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: name: Format & lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: @@ -38,7 +38,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 0000000..7ba5479 --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,44 @@ +name: Website + +# Runs on changes to the site itself. The Rust suite in test.yml is untouched +# by a copy edit, and a broken website should be caught in review rather than +# by a failed deploy. +on: + push: + branches: ['**'] + tags-ignore: ['**'] + paths: + - 'website/**' + - '.github/workflows/website.yml' + pull_request: + paths: + - 'website/**' + - '.github/workflows/website.yml' + +jobs: + check: + name: Typecheck, lint, format, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/setup@v2 + with: + # Reads the pnpm version from the site's own package.json; the + # repository root has none. + package-json-file: website/package.json + install: false + + - uses: actions/setup-node@v7 + with: + node-version: '26' + + - name: Install + working-directory: website + run: pnpm install --frozen-lockfile + + # Everything the deploy will do, plus the checks a deploy is a bad time + # to discover. + - name: Check + working-directory: website + run: pnpm run check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b8c380..213bf17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,12 +41,29 @@ platform differences, not ordinary mistakes. ## Releases -vump versions itself with vump. +vump versions itself with vump, as two projects that ship independently. ```bash -vump patch --through push # or: alpha, beta, rc, release +vump patch --project main --through push # or: alpha, beta, rc, release ``` +| Project | Tracks | Tagged | Ships | +| --- | --- | --- | --- | +| `main` | `Cargo.toml`, `Cargo.lock` | `v1.2.3` | The binary: build matrix, signing, checksums, attestation, release | +| `website` | `website/package.json` | `website-v1.2.3` | [`website/`](website) to GitHub Pages | + +The tag shape decides which workflow runs, and `vump check` infers the project +from it, so neither has to be told `--project`. A copy fix on the landing page +therefore ships without a vump version — cutting one to deploy the site would +tell everyone pinning the binary that something changed when nothing did. The +same separation costs the reverse: a binary release does not redeploy the site, +so the version the page states lags until the site is released too. See +[`website/README.md`](website/README.md). + +The rest of this section is about the `main` project. The website's number +answers a narrower question — which build of the page is deployed — and needs +none of the reasoning below, since nothing resolves it. + ### Choosing the number, while the major is 0 **Until 1.0, the minor is the breaking slot.** This is not a formality: Cargo diff --git a/vump.toml b/vump.toml index 7757e58..e9098b3 100644 --- a/vump.toml +++ b/vump.toml @@ -1,20 +1,17 @@ -# vump's own configuration. +# Two independently-versioned projects, told apart by the shape of their tag. # -# Cargo.toml is the single source of truth for this project's version: the -# binary reads it at compile time via CARGO_PKG_VERSION, and CI verifies that -# every released tag matches it before building or publishing anything. -# -# Cargo.lock records that version too, and release builds run --locked, so it -# moves in the same commit rather than being left for a follow-up. - -files = ["Cargo.toml", "Cargo.lock"] +# The binary and the website ship on their own schedules: a copy fix on the +# landing page has nothing in it for someone running vump, and cutting a +# release to deploy one would tell every consumer a new version exists when +# none does. Each tag shape triggers its own workflow, and a single +# `vump check ${{ github.ref_name }}` in CI infers which project a pushed tag +# describes without being told --project. [git] # Stops short of pushing: the commit and tag are local and undoable, whereas a -# pushed tag starts the release workflow. `--through push` when that is wanted. +# pushed tag starts a release. `--through push` when that is wanted. through = "tag" -commit_message = "chore: bump version to v{new_version}" -tag_pattern = "v{new_version}" +commit_message = "chore: bump {project} to v{new_version}" # Stable releases come from main, which is where every one of them has been cut. # The accident this prevents: merging a pull request, staying on its now-deleted @@ -24,3 +21,20 @@ release_branches = ["main"] # prerelease_branches is deliberately unset. Alphas here are cut from the branch # doing the work — that is how every one so far has been made — and constraining # them would mean merging before they could be shared. + +# Cargo.toml is the single source of truth for the binary's version: it is read +# at compile time via CARGO_PKG_VERSION, and CI verifies that every released tag +# matches it before building or publishing anything. Cargo.lock records that +# version too, and release builds run --locked, so it moves in the same commit +# rather than being left for a follow-up. +[[project]] +name = "main" +files = ["Cargo.toml", "Cargo.lock"] +tag_pattern = "v{new_version}" + +# The website's version is what its deployed build reports in the corner, and +# what `vump check` verifies a website-v* tag against before Pages is touched. +[[project]] +name = "website" +files = ["website/package.json"] +tag_pattern = "website-v{new_version}" diff --git a/website/.env b/website/.env deleted file mode 100644 index dca6dee..0000000 --- a/website/.env +++ /dev/null @@ -1,7 +0,0 @@ -# Where the site will be served from. Both values are read at build time. -# -# VITE_BASE_PATH is the path every asset URL is prefixed with: "/" for a custom -# domain at the root, "/vump/" for a GitHub project site at okcodes.github.io. -# VITE_SITE_URL is the absolute origin used for canonical and social-card URLs. -VITE_BASE_PATH=/ -VITE_SITE_URL=https://okcodes.github.io/vump diff --git a/website/README.md b/website/README.md index 84a1431..33c3c4d 100644 --- a/website/README.md +++ b/website/README.md @@ -6,9 +6,14 @@ to static files and destined for GitHub Pages. ```bash pnpm install pnpm dev # http://localhost:5173 -pnpm check # typecheck, lint, format, build — what CI will run +pnpm build && pnpm preview # the real static output, prerender included +pnpm check # typecheck, lint, format, build — what CI runs ``` +`pnpm dev` never runs the prerender step: `index.html` ships an empty `#root` +there and the app client-renders into it. To see what a crawler or a scripting- +disabled browser actually receives, build and preview. + | Script | Does | | ------------------------- | ------------------------------------------------------------- | | `dev` | Vite dev server with hot reload | @@ -81,74 +86,87 @@ codebase rather than because it was inconvenient: Everything else — correctness, suspicious, perf and pedantic — is on, and a warning fails the run. -## Deploying to GitHub Pages - -Not wired up yet, deliberately: the domain is not settled, and the base path is -baked into every asset URL at build time. - -Two values decide it, both in `.env`: - -| Variable | For a custom domain | For `okcodes.github.io/vump` | -| ---------------- | --------------------- | -------------------------------- | -| `VITE_BASE_PATH` | `/` | `/vump/` | -| `VITE_SITE_URL` | `https://your.domain` | `https://okcodes.github.io/vump` | - -A custom domain also needs `public/CNAME` holding the bare hostname, which ships -to `dist/` untouched. `public/.nojekyll` is already there, so Pages serves the -build as-is instead of running it through Jekyll. - -When the domain is decided, add `.github/workflows/website.yml`: - -```yaml -name: website - -on: - push: - branches: [main] - paths: ['website/**', '.github/workflows/website.yml'] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: website - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: website/pnpm-lock.yaml - - run: pnpm install --frozen-lockfile - - run: pnpm check - - uses: actions/upload-pages-artifact@v3 - with: - path: website/dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - id: deploy - uses: actions/deploy-pages@v4 +## The version the page states + +The page says which vump release it describes — in the masthead, and on the +download button. That value is read at build time from +[`src/content/release.json`](src/content/release.json), a declared input like +any other. + +It is not fetched from the GitHub API in the visitor's browser. That call is +rate-limited to sixty an hour per address, so the version would simply vanish +for anyone behind a busy NAT, and the page would say different things at +different moments to different people. + +It is not read from `Cargo.toml` either, tempting as that is. `Cargo.toml` +holds the version most recently bumped, which is not the same as the version +most recently _published_ — a bump that has been tagged locally but not pushed +leaves it describing a release that does not exist, with no binaries behind the +download button. + +Pointing the site at a newer release is deliberate and offline — the script +reads the repository's own tags and skips pre-releases, since the button beside +the number hands over the newest stable: + +```bash +pnpm bump:vump # newest stable tag +pnpm bump:vump 0.6.2 # a specific one +``` + +Commit the change like any other, then cut a website release to deploy it. + +## Two release lines + +The binary and the website ship independently, as two vump projects declared in +the repository's [`vump.toml`](../vump.toml): + +| Project | Tracks | Tagged | Workflow | +| --------- | -------------------------- | ---------------- | ------------------------------------------------------- | +| `main` | `Cargo.toml`, `Cargo.lock` | `v1.2.3` | `release.yml` — builds, signs and publishes the binary | +| `website` | `website/package.json` | `website-v1.2.3` | `release-website.yml` — deploys this directory to Pages | + +Each tag shape triggers exactly one of them, and `vump check` infers which +project a pushed tag describes from its shape, so neither workflow has to name +`--project`. Shipping a site-only change is: + +```bash +vump patch --project website --through push ``` -Then set Pages → Source → GitHub Actions in the repository settings. +No vump version, no release notes, nothing said to anyone pinning the binary. + +The reverse holds too: releasing the binary does not redeploy the site. So the +version the page states can lag a release until the site is deployed again, +which is the deliberate cost of not having one tag do two unrelated things. + +## Deployment + +`release-website.yml` builds this directory and publishes `dist/` to GitHub +Pages, gated on `vump check` agreeing that the tag matches +`website/package.json`. The build receives `VITE_WEBSITE_BUILD_SHA`, which is +what the badge in the corner of the page reports alongside the site's version — +a bug report is a screenshot, and that is the pair needed to know which build +it came from. + +The install step is `pnpm install --frozen-lockfile`: the committed +`pnpm-lock.yaml` is the deploy's declared input, so nothing is resolved or +discovered at deploy time and a rebuild of the same commit produces the same +bytes. + +Served from **vump.codehacks.io**, configured in the repository's Pages +settings rather than by a `CNAME` file — a workflow-based deploy does not need +one. The site is therefore always at the root, which is why there is no Vite +`base` path here; a bare `github.io//` project page would need one. + +## Prerendering + +`dist/index.html` ships with the page already rendered into it. `vite build` +produces the client bundle, a second pass builds `src/entry-server.tsx` to +`dist-ssr/`, and `scripts/prerender.mjs` inlines that HTML into `#root` and +deletes the intermediate. `src/main.tsx` hydrates when `#root` already has +children and renders normally when it does not, which is what `pnpm dev` +serves. -The site is not versioned and vump does not track its `package.json`, which -declares no version at all. It ships when `main` moves, not when a tag does. +The entrance animations are gated on a `js` class the pre-paint script adds, so +a client that never runs the script sees the prerendered page as it stands +rather than a blank one waiting for a reveal that will never come. diff --git a/website/index.html b/website/index.html index 072f3c9..c2604e2 100644 --- a/website/index.html +++ b/website/index.html @@ -9,10 +9,10 @@ name="description" content="vump writes your new version into package.json, Cargo.toml, pyproject.toml and their lock files at once — then commits, tags, and verifies the tag in CI." /> - + - - + + @@ -21,34 +21,41 @@ - - + + + + + - + @@ -61,7 +68,7 @@ "applicationCategory": "DeveloperApplication", "operatingSystem": "macOS, Linux, Windows", "description": "Keep semver version numbers in sync across the files of a repository, and verify in CI that a released tag matches what is recorded in source.", - "url": "https://github.com/okcodes/vump", + "url": "https://vump.codehacks.io/", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" } } diff --git a/website/package.json b/website/package.json index ee0806e..f275fe2 100644 --- a/website/package.json +++ b/website/package.json @@ -1,10 +1,12 @@ { "name": "vump-website", "private": true, + "version": "0.1.1-alpha.0", + "packageManager": "pnpm@11.25.0", "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && vite build --config vite.ssr.config.ts --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs", "preview": "vite preview", "typecheck": "tsc -b", "lint": "oxlint --max-warnings=0", @@ -12,7 +14,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check": "run-s typecheck lint format:check build", - "og": "node scripts/og.mjs" + "og": "node scripts/og.mjs", + "bump:vump": "node scripts/vump-version.mjs", + "clean": "rm -rf dist dist-ssr node_modules/.tmp" }, "dependencies": { "@fontsource-variable/instrument-sans": "^5.3.0", @@ -37,5 +41,5 @@ "vite": "^8.2.2", "wawoff2": "^2.0.1" }, - "description": "Marketing site for vump" + "description": "Landing page for vump" } diff --git a/website/public/og.png b/website/public/og.png index be162b7..20f5675 100644 Binary files a/website/public/og.png and b/website/public/og.png differ diff --git a/website/scripts/og.mjs b/website/scripts/og.mjs index 999da01..0e0d71f 100644 --- a/website/scripts/og.mjs +++ b/website/scripts/og.mjs @@ -99,7 +99,7 @@ const card = ` GITHUB.COM/OKCODES/VUMP + letter-spacing="1.4">VUMP.CODEHACKS.IO diff --git a/website/scripts/prerender.mjs b/website/scripts/prerender.mjs new file mode 100644 index 0000000..7963c34 --- /dev/null +++ b/website/scripts/prerender.mjs @@ -0,0 +1,34 @@ +/** + * Inlines the app's rendered HTML into dist/index.html. + * + * Runs after the client and SSR builds. Without it the deployed page ships an + * empty #root: correct in a browser, nothing at all to a crawler, a link + * preview, or an agent fetching the page — and this site's whole job is being + * read by someone deciding whether to try the tool. + * + * dist-ssr/ is a build-time intermediate and is deleted here rather than + * deployed. + */ + +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const websiteDir = dirname(dirname(fileURLToPath(import.meta.url))); +const distDir = join(websiteDir, 'dist'); +const ssrDir = join(websiteDir, 'dist-ssr'); + +const { render } = await import(join(ssrDir, 'entry-server.js')); + +const indexPath = join(distDir, 'index.html'); +const template = readFileSync(indexPath, 'utf8'); +const rendered = template.replace('
', `
${render()}
`); + +if (rendered === template) { + throw new Error(`prerender: no '
' placeholder in ${indexPath}`); +} + +writeFileSync(indexPath, rendered); +rmSync(ssrDir, { recursive: true, force: true }); + +process.stdout.write('prerendered dist/index.html\n'); diff --git a/website/scripts/vump-version.mjs b/website/scripts/vump-version.mjs new file mode 100644 index 0000000..794763c --- /dev/null +++ b/website/scripts/vump-version.mjs @@ -0,0 +1,62 @@ +/** + * Points the site at a different published vump release. + * + * The version the page states is a declared input, not something the build + * discovers: it is read from src/content/release.json at build time, so every + * deploy of the same commit says the same thing and no visitor's browser talks + * to an API to find out. This script only makes updating it one command instead + * of a lookup — it reads the repository's own tags, so it works offline and + * cannot be rate-limited. + * + * pnpm bump:vump # newest stable tag + * pnpm bump:vump 0.6.2 # a specific one + * + * Pre-releases are skipped deliberately. The page's audience is someone + * deciding whether to try vump, and the download button hands them the newest + * stable; stating an alpha beside it would be two answers to one question. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const target = fileURLToPath(new URL('../src/content/release.json', import.meta.url)); + +/** Parses a stable `vX.Y.Z` tag. Anything with a pre-release part is not one. */ +function stable(tag) { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim()); + return match ? { tag, parts: [+match[1], +match[2], +match[3]] } : null; +} + +function newestStable() { + const tags = execFileSync('git', ['tag', '--list', 'v*'], { encoding: 'utf8' }).split('\n'); + const releases = tags.map(stable).filter(Boolean); + + if (releases.length === 0) { + throw new Error('no stable v* tags in this repository; pass a version explicitly'); + } + + releases.sort((a, b) => { + for (let i = 0; i < 3; i++) { + if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i]; + } + return 0; + }); + + return releases.at(-1).tag.slice(1); +} + +const requested = process.argv[2]?.replace(/^v/, ''); +if (requested && !/^\d+\.\d+\.\d+$/.test(requested)) { + throw new Error(`not a stable version: ${requested}`); +} + +const version = requested ?? newestStable(); +const current = JSON.parse(readFileSync(target, 'utf8')).version; + +if (current === version) { + process.stdout.write(`already at ${version}\n`); +} else { + writeFileSync(target, `${JSON.stringify({ version }, null, 2)}\n`); + process.stdout.write(`${current} -> ${version}\n`); +} diff --git a/website/src/App.tsx b/website/src/App.tsx index 8a833bd..e22df6e 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -5,6 +5,7 @@ import { Features } from './components/sections/Features.tsx'; import { Files } from './components/sections/Files.tsx'; import { Hero } from './components/sections/Hero.tsx'; import { Start } from './components/sections/Start.tsx'; +import { VersionBadge } from './components/ui/VersionBadge.tsx'; export function App() { return ( @@ -24,6 +25,7 @@ export function App() {