diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b77c131
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ # No private planning document and no private identifier, as in uptide-dev/uptide.
+ # The denylist is the UPTIDE_PRIVATE_DENYLIST secret; scripts/private-material.mjs
+ # decides which scan runs on which event.
+ - name: No private material
+ env:
+ UPTIDE_PRIVATE_DENYLIST: ${{ secrets.UPTIDE_PRIVATE_DENYLIST }}
+ EVENT: ${{ github.event_name }}
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ REPO: ${{ github.repository }}
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
+ run: node scripts/private-material.mjs
+ - name: The page's own checks
+ run: node --test 'test/*.test.mjs'
diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml
new file mode 100644
index 0000000..e7b747e
--- /dev/null
+++ b/.github/workflows/dco.yml
@@ -0,0 +1,47 @@
+name: DCO
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ signed-off:
+ runs-on: ubuntu-latest
+ steps:
+ # The checker runs from the pull request's own checkout, as the rest of CI does.
+ # Taking it from the base branch instead would buy nothing: for a `pull_request`
+ # event GitHub already reads the workflow definition from the pull request, so the
+ # step invoking the checker is the pull request's either way -- and the base branch
+ # does not have the checker at all until this lands. What does the work is the
+ # event: `pull_request`, never `pull_request_target`, so the job holds a read-only
+ # token and no secret, whoever opened it. The range below comes from this
+ # repository's own refs rather than from anything the branch says about itself.
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: Every commit is signed off
+ # Values from the event go through the environment, never into the script source.
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ # Who opened the pull request, as GitHub records it: a bot's own commits in its
+ # own pull request (Dependabot) are exempt; a commit merely claiming a bot's
+ # address in someone else's pull request is not. See BOTS in scripts/dco.mjs.
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
+ run: |
+ # refs/pull/N/head is the complete pull request head as this repository sees it,
+ # for a branch and for a fork alike, and refs/heads/ is the base branch as
+ # it stands now. Both are fetched here rather than assumed, with full history, so
+ # the merge base -- and so the whole range the pull request adds -- resolves.
+ git fetch --no-tags origin \
+ "+refs/pull/${PR_NUMBER}/head:refs/uptide/pr-head" \
+ "+refs/heads/${BASE_REF}:refs/uptide/base"
+ # The refs above are CI's own bookkeeping: --base-name is the branch a
+ # contributor would actually rebase onto, for the failure message.
+ node scripts/dco.mjs refs/uptide/base refs/uptide/pr-head \
+ --base-name="origin/${BASE_REF}" --pr-author="${PR_AUTHOR}"
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..8778313
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,47 @@
+name: Pages
+
+# Publishes site/ to GitHub Pages as it is, with no build step: on a push to main that
+# changes it, or by hand. See site/README.md.
+on:
+ push:
+ branches: [main]
+ paths: ['site/**', '.github/workflows/pages.yml']
+ workflow_dispatch:
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+permissions: {}
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ permissions:
+ # checkout reads the repository; deploy-pages publishes with the OIDC identity.
+ contents: read
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ env:
+ # The page's own address, for its canonical and social-preview URLs. The one value to
+ # change when the page moves to a domain.
+ SITE_URL: https://uptide-dev.github.io/
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Write the site URL into the page
+ run: |
+ sed -i "s#%SITE_URL%#${SITE_URL}#g" site/index.html
+ if grep -q '%SITE_URL%' site/index.html; then
+ echo "::error::site/index.html still has a %SITE_URL% placeholder."
+ exit 1
+ fi
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 652b6aa..5f18d1d 100644
--- a/README.md
+++ b/README.md
@@ -2,3 +2,43 @@
The landing page of [Uptide](https://github.com/uptide-dev/uptide), published at
https://uptide-dev.github.io.
+
+The page is `site/`: static HTML with inline CSS, self-hosted fonts and no build step. What
+each file is, and where every number on the page comes from: [site/README.md](site/README.md).
+
+## See it locally
+
+```sh
+python3 -m http.server 8000 --directory site # then open http://localhost:8000
+```
+
+`%SITE_URL%` in the page's `
` is expected: the deploy writes the real address there
+(canonical and social-preview URLs only; nothing visible).
+
+## Publishing
+
+`.github/workflows/pages.yml` deploys `site/` to GitHub Pages on every push to `main` that
+changes it. Its `SITE_URL` is the one value to change if the page moves to a domain.
+
+One-time setup:
+
+1. The repository is **public** (Pages on a private repository needs a paid plan, and the page
+ is public anyway).
+2. Settings → Pages → Source: **GitHub Actions**.
+3. Settings → Secrets and variables → Actions → secret **`UPTIDE_PRIVATE_DENYLIST`**, the same
+ value as in uptide-dev/uptide.
+
+## Checks
+
+- **DCO**: every commit is signed off (`git commit -s`), as in uptide-dev/uptide.
+- **No private material**: no private planning document and no identifier from the denylist.
+- **The page's own tests** (`node --test 'test/*.test.mjs'`): every font next to its license,
+ the URLs from `SITE_URL`, nothing loaded from another origin, the deploy's trigger and
+ permissions.
+
+`scripts/` (`dco.mjs`, `bots.mjs`, `public-tree.mjs`, `private-material.mjs`) and
+`.github/workflows/dco.yml` are copies from uptide-dev/uptide, where they are tested. Change
+them there first, then copy them here.
+
+The page is licensed under Apache-2.0 ([LICENSE](LICENSE)); its fonts under the SIL Open Font
+License 1.1, with each license next to its font in `site/fonts/`.
diff --git a/docs/screenshots/site-landing/desktop-dark.png b/docs/screenshots/site-landing/desktop-dark.png
new file mode 100644
index 0000000..a9602f4
Binary files /dev/null and b/docs/screenshots/site-landing/desktop-dark.png differ
diff --git a/docs/screenshots/site-landing/desktop-light.png b/docs/screenshots/site-landing/desktop-light.png
new file mode 100644
index 0000000..fe1881f
Binary files /dev/null and b/docs/screenshots/site-landing/desktop-light.png differ
diff --git a/docs/screenshots/site-landing/mobile-dark.png b/docs/screenshots/site-landing/mobile-dark.png
new file mode 100644
index 0000000..f7e36e6
Binary files /dev/null and b/docs/screenshots/site-landing/mobile-dark.png differ
diff --git a/docs/screenshots/site-landing/mobile-light.png b/docs/screenshots/site-landing/mobile-light.png
new file mode 100644
index 0000000..2856c31
Binary files /dev/null and b/docs/screenshots/site-landing/mobile-light.png differ
diff --git a/scripts/bots.mjs b/scripts/bots.mjs
new file mode 100644
index 0000000..38544b7
--- /dev/null
+++ b/scripts/bots.mjs
@@ -0,0 +1,18 @@
+// The bots that open pull requests here, by the login GitHub records as a pull request's
+// opener: a contributor cannot set it, so it is what every exemption keys on.
+
+/** The GitHub App that opens the Version Packages pull request (docs/releasing.md). */
+export const RELEASE_APP = 'uptide-release';
+export const RELEASE_BOT = `${RELEASE_APP}[bot]`;
+export const DEPENDABOT = 'dependabot[bot]';
+
+/**
+ * Each bot's commit address. A bot's own commits in its own pull request need no sign-off
+ * (scripts/dco.mjs). An app's user id is assigned when it is created, so its address is
+ * matched whatever the id: the opener, which only the app can be, is what is checked.
+ */
+export const BOTS = {
+ [DEPENDABOT]: '49699333+dependabot[bot]@users.noreply.github.com',
+ 'github-actions[bot]': '41898282+github-actions[bot]@users.noreply.github.com',
+ [RELEASE_BOT]: /^\d+\+uptide-release\[bot\]@users\.noreply\.github\.com$/,
+};
diff --git a/scripts/dco.mjs b/scripts/dco.mjs
new file mode 100644
index 0000000..a868575
--- /dev/null
+++ b/scripts/dco.mjs
@@ -0,0 +1,125 @@
+// Whether every commit a pull request adds carries a Developer Certificate of Origin
+// sign-off (https://developercertificate.org/), which is how a contributor states they
+// have the right to send the patch under the project's license.
+//
+// node scripts/dco.mjs [--base-name=] [--pr-author=] [--json]
+//
+// The range is everything on `head` since it left `base`, so a long-running branch is not
+// asked to account for commits it merely inherited. CI passes the refs it fetched for
+// itself, which are not names a contributor could rebase onto, so `--base-name` sets what
+// the failure message tells them to use.
+import { execFileSync } from 'node:child_process';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { BOTS } from './bots.mjs';
+
+/**
+ * Bots that cannot sign off, by login, with the noreply address GitHub gives each
+ * (scripts/bots.mjs). A commit is exempt only when the pull request was opened by that bot
+ * (`--pr-author`, which GitHub sets and a contributor cannot) and the commit is authored by
+ * that same bot: an author address is just text in a commit, so on its own it would let
+ * anyone skip the sign-off.
+ */
+export { BOTS };
+
+/** The commits a bot's own pull request carries in its own name. */
+export function exempt(list, prAuthor) {
+ const bot = Object.hasOwn(BOTS, prAuthor ?? '') ? BOTS[prAuthor] : undefined;
+ if (!bot) return [];
+ const own = (email) => (bot instanceof RegExp ? bot.test(email) : email === bot);
+ return list.filter((c) => own(c.email.toLowerCase()));
+}
+
+/** A sign-off line: a name, then an address in angle brackets, as `git commit -s` writes. */
+const SIGN_OFF = /^[ \t]*Signed-off-by:[ \t]*(\S.*?)[ \t]*<([^<>\s]+@[^<>\s]+)>[ \t]*$/gim;
+
+const git = (args, cwd) =>
+ execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+
+/** The commit `head` adds on top of `base`: oldest first, merge commits included. */
+export function commits(base, head, cwd = process.cwd()) {
+ // A pull request's base branch moves on. What the branch added is measured from where it
+ // forked off, which is the merge base, not wherever the base branch has since got to.
+ const from = git(['merge-base', base, head], cwd).trim();
+ // One record per commit, with field and record separators no commit message can contain.
+ const log = git(
+ ['log', '--reverse', '--format=%H%x1f%ae%x1f%s%x1f%B%x1e', `${from}..${head}`],
+ cwd,
+ );
+ return log
+ .split('\x1e')
+ .map((record) => record.replace(/^\n/, ''))
+ .filter(Boolean)
+ .map((record) => {
+ const [sha, email, subject, message] = record.split('\x1f');
+ return { sha, email, subject, message };
+ });
+}
+
+/**
+ * Everything missing, as sentences. A sign-off has to be the commit author's own: a line
+ * naming somebody else certifies nothing about the person who wrote the patch.
+ */
+export function problems(list, prAuthor) {
+ const found = [];
+ const skip = new Set(exempt(list, prAuthor));
+ for (const commit of list) {
+ if (skip.has(commit)) continue;
+ const { sha, email, subject, message } = commit;
+ const short = sha.slice(0, 8);
+ const signers = [...message.matchAll(SIGN_OFF)].map((match) => match[2].toLowerCase());
+ if (!signers.length) found.push(`${short} has no Signed-off-by line: ${subject}`);
+ else if (!signers.includes(email.toLowerCase()))
+ found.push(
+ `${short} is signed off by ${signers.join(', ')} but authored by ${email}: ${subject}`,
+ );
+ }
+ return found;
+}
+
+/** What to run to fix it, naming the branch this pull request is against. */
+export function howToFix(base) {
+ return [
+ 'Every commit in a pull request needs a Developer Certificate of Origin sign-off.',
+ 'Add it to the commits you already pushed, then force-push the branch:',
+ '',
+ ` git rebase --signoff ${base}`,
+ ' git push --force-with-lease',
+ '',
+ 'A merge commit needs one too (`git merge --signoff`); rebasing avoids them entirely.',
+ 'New commits get the line from `git commit -s`. See CONTRIBUTING.md.',
+ ].join('\n');
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const args = process.argv.slice(2);
+ const [base, head] = args.filter((arg) => !arg.startsWith('--'));
+ if (!base || !head) {
+ console.error(
+ 'usage: node scripts/dco.mjs [--base-name=] [--pr-author=] [--json]',
+ );
+ process.exit(2);
+ }
+ const list = commits(base, head);
+ const prAuthor = args.find((arg) => arg.startsWith('--pr-author='))?.slice(12);
+ const bots = exempt(list, prAuthor);
+ const found = problems(list, prAuthor);
+ // The ref CI fetched is not a name anyone can rebase onto; this says what to print.
+ const named = args.find((arg) => arg.startsWith('--base-name='))?.slice(12) || base;
+ if (args.includes('--json')) {
+ console.log(
+ JSON.stringify(
+ { commits: list.map((c) => c.sha), exempt: bots.map((c) => c.sha), problems: found },
+ null,
+ 2,
+ ),
+ );
+ } else {
+ console.log(`${list.length} commits on ${head} since ${named}`);
+ for (const c of bots)
+ console.log(` · ${c.sha.slice(0, 8)} authored by ${prAuthor}, exempt: ${c.subject}`);
+ for (const line of found) console.log(` ✗ ${line}`);
+ console.log(found.length ? `\n${howToFix(named)}` : 'every commit is signed off');
+ }
+ process.exit(found.length ? 1 : 0);
+}
diff --git a/scripts/private-material.mjs b/scripts/private-material.mjs
new file mode 100644
index 0000000..8df3ec8
--- /dev/null
+++ b/scripts/private-material.mjs
@@ -0,0 +1,45 @@
+// How CI scans for private material (scripts/public-tree.mjs) on this event. The denylist
+// is a secret: required on pushes and on pull requests from this repository. A fork has no
+// secrets, so only document names are checked there. Dependabot's own pull requests get no
+// repository secrets either, and are never given this one (CONTRIBUTING.md): with the
+// opener GitHub records as dependabot[bot] and no denylist, the identifier scan is skipped
+// with a notice, and whatever it brings is scanned with the secret on its push to main.
+// Every other opener, the release app included (its pull requests get secrets), is scanned
+// in full, and a missing denylist fails.
+//
+// EVENT, HEAD_REPO, REPO, PR_AUTHOR, UPTIDE_PRIVATE_DENYLIST node scripts/private-material.mjs
+import { spawnSync } from 'node:child_process';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { DEPENDABOT } from './bots.mjs';
+import { DENYLIST_ENV } from './public-tree.mjs';
+
+/** The public-tree arguments for this event, and the notice to print when it skips a part. */
+export function scanFor({ event, headRepo, repo, prAuthor, denylist }) {
+ if (event === 'pull_request' && headRepo !== repo) return { args: ['.'] };
+ if (event === 'pull_request' && prAuthor === DEPENDABOT && !denylist)
+ return {
+ args: ['.'],
+ notice:
+ 'Private identifier scan skipped: Dependabot pull requests get no repository secrets, and this one is never given to them. Document names are still checked; the merge to main is scanned with the denylist.',
+ };
+ return { args: ['.', '--require-denylist'] };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const env = process.env;
+ const { args, notice } = scanFor({
+ event: env.EVENT,
+ headRepo: env.HEAD_REPO,
+ repo: env.REPO,
+ prAuthor: env.PR_AUTHOR,
+ denylist: env[DENYLIST_ENV],
+ });
+ if (notice) console.log(`::notice title=No private material::${notice}`);
+ const scan = spawnSync(
+ process.execPath,
+ [fileURLToPath(new URL('./public-tree.mjs', import.meta.url)), ...args],
+ { stdio: 'inherit' },
+ );
+ process.exit(scan.status ?? 1);
+}
diff --git a/scripts/public-tree.mjs b/scripts/public-tree.mjs
new file mode 100644
index 0000000..ae616cb
--- /dev/null
+++ b/scripts/public-tree.mjs
@@ -0,0 +1,91 @@
+// Whether this repository is clean of private material: no planning or audit document, and
+// no identifier from the private denylist. The denylist is not in the repository: it comes
+// from the UPTIDE_PRIVATE_DENYLIST environment variable (a secret in CI).
+//
+// node scripts/public-tree.mjs [dir] [--json] [--require-denylist] [--every-file]
+//
+// `--every-file` scans every file under `dir` instead of git's tracked files: an unpacked
+// npm tarball (scripts/check-pack.mjs) is not a repository.
+import { execFileSync } from 'node:child_process';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const DENYLIST_ENV = 'UPTIDE_PRIVATE_DENYLIST';
+
+export function trackedFiles(root) {
+ return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
+ .split('\0')
+ .filter(Boolean);
+}
+
+export function everyFile(root) {
+ return readdirSync(root, { recursive: true, withFileTypes: true })
+ .filter((entry) => entry.isFile())
+ .map((entry) => relative(root, join(entry.parentPath, entry.name)));
+}
+
+/** The private identifiers, comma or newline separated; undefined when none were given. */
+export function denylist(env = process.env) {
+ const terms = (env[DENYLIST_ENV] ?? '')
+ .split(/[\n,]/)
+ .map((term) => term.trim())
+ .filter(Boolean);
+ return terms.length ? terms : undefined;
+}
+
+/** Private planning and audit documents, whatever they are called next time. */
+const PRIVATE_DOC = /(^|\/)(plan|roadmap|privacy-audit|audit)[^/]*\.md$/i;
+const TEXT =
+ /\.(md|ts|tsx|mts|mjs|js|json|ya?ml|txt|snap|html|css|sh)$|(^|\/)\.[\w.-]+$|(^|\/)(LICENSE|NOTICE)$/;
+
+/** Lowercase entries match in any case; an entry with an uppercase letter matches as written. */
+const matcher = (term) =>
+ term === term.toLowerCase()
+ ? (text) => text.toLowerCase().includes(term)
+ : (text) => text.includes(term);
+
+/**
+ * Everything wrong with the tracked files of `root`, as sentences. A denylist hit names the
+ * file and the identifier, never the line: this output ends up in CI logs.
+ */
+export function problems(root, terms = denylist(), files = trackedFiles(root)) {
+ const found = [];
+ const matchers = (terms ?? []).map((term) => [term, matcher(term)]);
+ for (const path of files) {
+ if (PRIVATE_DOC.test(path))
+ found.push(`${path}: a private planning or audit document does not belong here`);
+ for (const [term, matches] of matchers)
+ if (matches(path)) found.push(`${path}: the path contains the private identifier "${term}"`);
+ const file = join(root, path);
+ if (!TEXT.test(path) || !existsSync(file)) continue;
+ const text = readFileSync(file, 'utf8');
+ for (const [term, matches] of matchers)
+ if (matches(text)) found.push(`${path}: contains the private identifier "${term}"`);
+ }
+ return found;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const args = process.argv.slice(2);
+ const root = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
+ const terms = denylist();
+ if (!terms && args.includes('--require-denylist')) {
+ console.error(`no denylist: set ${DENYLIST_ENV} (comma or newline separated)`);
+ process.exit(2);
+ }
+ const list = args.includes('--every-file') ? everyFile(root) : trackedFiles(root);
+ const found = problems(root, terms, list);
+ const files = list.length;
+ if (args.includes('--json')) {
+ console.log(JSON.stringify({ files, denylist: terms?.length, problems: found }));
+ } else {
+ const scope = terms ? `${terms.length} private identifiers` : 'no denylist given';
+ console.log(
+ `${root}: ${files} ${args.includes('--every-file') ? '' : 'tracked '}files, ${scope}`,
+ );
+ for (const line of found) console.log(` ✗ ${line}`);
+ console.log(found.length ? `${found.length} problems` : 'clean');
+ }
+ process.exit(found.length ? 1 : 0);
+}
diff --git a/site/README.md b/site/README.md
new file mode 100644
index 0000000..807ec18
--- /dev/null
+++ b/site/README.md
@@ -0,0 +1,42 @@
+# Landing page
+
+`index.html` is the whole site: one static page with inline CSS, a few lines of inline
+JavaScript for the copy buttons, and self-hosted fonts. No build step and no external
+requests.
+
+```sh
+npx serve site # or any static file server; open http://localhost:3000
+```
+
+## Files
+
+| File | What it is |
+| --- | --- |
+| `index.html` | The page. Design tokens are the same as the list/check HTML report ([`packages/cli/src/html/assets.ts`](https://github.com/uptide-dev/uptide/blob/main/packages/cli/src/html/assets.ts) in uptide-dev/uptide); light and dark follow the system. |
+| `favicon.svg` | The logo mark, blue in light mode and light blue in dark mode. |
+| `og.png` | Social preview, 1280×640. Used for `og:image` and `twitter:image`, and meant for the repository's social preview setting. |
+| `og-image.html` | Source of `og.png`. Render it at 1280×640 with a headless browser and save a PNG screenshot. |
+| `fonts/` | Space Grotesk, Instrument Sans and IBM Plex Mono, Latin subsets (from Fontsource 5.3.0), each under the SIL Open Font License 1.1; the license texts are next to them. |
+
+## Deploying
+
+`.github/workflows/pages.yml` publishes this directory to GitHub Pages on every push to `main`
+that changes `site/`, as it is: no build step. The canonical URL, `og:url`, `og:image` and
+`twitter:image` are written `%SITE_URL%` in `index.html`, and the workflow replaces them with
+its `SITE_URL` (now `https://uptide-dev.github.io/`) just before upload. To move the
+page to a domain, change that one value.
+
+## Where the numbers come from
+
+Every number on the page is printed output, never edited:
+
+- `list` on [supabase/supabase](https://github.com/supabase/supabase) at commit
+ `9d1661dec1548ec5bf3ffb4f551376a7650c213e`, run with uptide 0.4.0 from npm (`npx uptide@latest list`) on 2026-10-06 from the
+ repository root (`npx uptide list`). Rows are trimmed. Latest versions and advisories come
+ from the registry at run time, so a rerun prints different numbers: when you refresh the
+ page, rerun and replace the block and the four tiles together, and update the footnote.
+- `check` and `fix` on [`fixtures/repos/storefront`](https://github.com/uptide-dev/uptide/tree/main/fixtures/repos/storefront):
+ the same blocks as the uptide README's "Before and after". If those numbers change, change
+ them in both repositories.
+- Timings in the hero note: `list` on the storefront fixture (about a second) and on the
+ supabase commit above (20 seconds).
diff --git a/site/favicon.svg b/site/favicon.svg
new file mode 100644
index 0000000..fa9ba56
--- /dev/null
+++ b/site/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/site/fonts/LICENSE-ibm-plex-mono.txt b/site/fonts/LICENSE-ibm-plex-mono.txt
new file mode 100644
index 0000000..83eac82
--- /dev/null
+++ b/site/fonts/LICENSE-ibm-plex-mono.txt
@@ -0,0 +1,93 @@
+Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/site/fonts/LICENSE-instrument-sans.txt b/site/fonts/LICENSE-instrument-sans.txt
new file mode 100644
index 0000000..9a39a8e
--- /dev/null
+++ b/site/fonts/LICENSE-instrument-sans.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/site/fonts/LICENSE-space-grotesk.txt b/site/fonts/LICENSE-space-grotesk.txt
new file mode 100644
index 0000000..d5666d7
--- /dev/null
+++ b/site/fonts/LICENSE-space-grotesk.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/site/fonts/ibm-plex-mono-latin-400-normal.woff2 b/site/fonts/ibm-plex-mono-latin-400-normal.woff2
new file mode 100644
index 0000000..0804aaf
Binary files /dev/null and b/site/fonts/ibm-plex-mono-latin-400-normal.woff2 differ
diff --git a/site/fonts/ibm-plex-mono-latin-500-normal.woff2 b/site/fonts/ibm-plex-mono-latin-500-normal.woff2
new file mode 100644
index 0000000..090f82f
Binary files /dev/null and b/site/fonts/ibm-plex-mono-latin-500-normal.woff2 differ
diff --git a/site/fonts/ibm-plex-mono-latin-600-normal.woff2 b/site/fonts/ibm-plex-mono-latin-600-normal.woff2
new file mode 100644
index 0000000..67aeeb0
Binary files /dev/null and b/site/fonts/ibm-plex-mono-latin-600-normal.woff2 differ
diff --git a/site/fonts/instrument-sans-latin-400-normal.woff2 b/site/fonts/instrument-sans-latin-400-normal.woff2
new file mode 100644
index 0000000..74572e7
Binary files /dev/null and b/site/fonts/instrument-sans-latin-400-normal.woff2 differ
diff --git a/site/fonts/instrument-sans-latin-500-normal.woff2 b/site/fonts/instrument-sans-latin-500-normal.woff2
new file mode 100644
index 0000000..0fd7a27
Binary files /dev/null and b/site/fonts/instrument-sans-latin-500-normal.woff2 differ
diff --git a/site/fonts/instrument-sans-latin-600-normal.woff2 b/site/fonts/instrument-sans-latin-600-normal.woff2
new file mode 100644
index 0000000..2a094d3
Binary files /dev/null and b/site/fonts/instrument-sans-latin-600-normal.woff2 differ
diff --git a/site/fonts/space-grotesk-latin-500-normal.woff2 b/site/fonts/space-grotesk-latin-500-normal.woff2
new file mode 100644
index 0000000..0db251f
Binary files /dev/null and b/site/fonts/space-grotesk-latin-500-normal.woff2 differ
diff --git a/site/fonts/space-grotesk-latin-600-normal.woff2 b/site/fonts/space-grotesk-latin-600-normal.woff2
new file mode 100644
index 0000000..a1db41a
Binary files /dev/null and b/site/fonts/space-grotesk-latin-600-normal.woff2 differ
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..1901344
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,602 @@
+
+
+
+
+
+Uptide: migrations you can merge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Uptide is an open-source CLI that shows which dependency upgrades matter in your repo, what each one breaks in your code, and opens a verified migration branch or PR. It runs locally on your repo. Nothing to sign up for.
+
npx uptide list --html --open
+
No API key. No account. About a second on a small repo, 20 seconds on the supabase monorepo. Node 20 or newer.
Major upgrades sit for months because nobody knows what they'll break.
+
Version bots open the PR. The version changes, the build goes red, and the code that breaks is still on you. So the PR waits, and the next major lands on top of it.
+
+
+
+
What a version bot does
+
Bumps the version and the lockfile.
Links the changelog.
+
+
+
What is left, and what Uptide does
+
Decide which upgrades matter first.
Find every line the new version breaks.
Rewrite those lines and prove the rest still compiles and passes its tests.
+
+
+
+
+
+
+
+
02 / How it works
+
list, check, fix, pr.
+
+
+
list
+
Every outdated dependency in seconds, grouped into packages that move together and ranked by priority with a reason: known advisories with the smallest fixing version, deprecated versions, unsupported major lines, version drift across workspaces, runtime before dev. In the terminal, or as an HTML report with filters. No LLM, no API key.
+
npx uptide list --html --open
+
+
check <pkg>
+
Compiles your code against the new version and states the verdict, 28 breaking · compiled against 4.6.5: 28 new type errors, with every call site. No LLM, no API key.
+
npx uptide check zod --html --open
+
+
fix <pkg>
+
Migrates on a new branch in an isolated clone of your local HEAD, in two passes:
+
+
Deterministic rules first. The dependency's pack (zod, stripe) rewrites every site it covers, the same way on every run. No LLM, no key.
+
Then AI, only for what rules can't do. It needs your API key in the environment: ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (experimental). Without one, the rules still run and the remaining sites are listed for you; a dependency with no pack has no rules, so it needs the key.
+
+
Then it verifies with your compiler, the related tests, and your linter and formatter. With --pr --yes it pushes the branch and opens a draft PR once verification passes; without them it never pushes.
+
npx uptide fix zod --pr --yes
+
+
pr
+
For a branch fix left local: opens the pull request with the evidence, summary first. It prints the plan and pushes only when you add --yes.
+
npx uptide pr --branch uptide/zod-4.6.5
+
+
+
+
+
+
+
+
03 / Real output
+
Real runs. Numbers as printed.
+
list on a large public monorepo, then check and fix on fixtures/repos/storefront, a small pnpm workspace in the repository written to exercise Uptide: two packages on zod 3 and stripe 14, with a few tests.
+
+
+
+ list on supabase/supabase. No install, no compilation, no LLM.1
+
$ npx uptide list
+uptide list · supabase · pnpm · 20s
+
+273 outdated 116 major 100 minor 57 patch 15 groups 53 tooling
+
+PRIORITIESmost urgent first · advisories checked for 377 packages
+ Urgent 12 · advisories, deprecations
+ @modelcontextprotocol/* 1 advisory (1 high), fixed in 1.31.0 (minor, same major)
+ npx uptide check --group modelcontextprotocol --target @modelcontextprotocol/sdk@1.31.0
+ simple-git dev · 3 advisories (1 critical), needs 4.0.1 (major)
+ npx uptide check simple-git --target simple-git@4.0.1
+ smol-toml 1 advisory (1 moderate), fixed in 1.9.0 (minor, same major)
+ npx uptide check smol-toml --target smol-toml@1.9.0
+ sharp dev · 1 advisory (1 high), fixed in 0.35.5 (patch, same major)
+ npx uptide check sharp --target sharp@0.35.5
+ katex 1 advisory (1 low), fixed in 0.18.2 (minor, same major)
+ npx uptide check katex --target katex@0.18.2
+ + 7 more · --all
+ Worth planning 73 · unsupported, drift, blocking, majors behind · --all
+
+GROUPSupgrade together
+@sentry/* 3 packages → 11.x npx uptide check --group sentry
+ @sentry/nextjs 10.59.0 → 11.4.0 major 59 files 91 calls
+ @sentry/react 10.59.0 → 11.4.0 major 5 files 8 calls
+ @sentry/tanstackstart-react 10.59.0 → 11.4.0 major 3 files 2 calls
+ai + @ai-sdk/* 5 packages → ai 7 · @ai-sdk/amazon-bedrock 5 · @ai-sdk/* 4 · @ai-sdk/mcp 2
+ npx uptide check --group ai
+ @ai-sdk/openai 2.0.2, 4.0.41 → 4.0.86 2 majors behind 2 files 1 call
+ 2 versions in 2 workspaces
+ ai 5.0.52, 7.0.59 → 7.0.130 2 majors behind 53 files 75 calls
+ 2 versions in 2 workspaces
+…
+
+
+
+
+
+ Before: check. What breaks, where, and who can fix it.
+
+
fixtures/repos/storefrontuptide check
+
$ npx uptide check zod stripe
+uptide check · storefront · pnpm · 8.5s
+
+zod 3.25.76 → 4.6.5 major · latest on npm verified✗ 28 breaking in 7 files 24 auto-fixable · 4 need the agent (LLM)
+stripe (packages/api) 14.25.0 → 23.0.0 9 majors behind · latest on npm verified✗ 4 breaking in 3 files 1 auto-fixable · 3 need the agent (LLM)
+
+zod 28 breaking · compiled against 4.6.5: 28 new type errors
+ ✗ New error API (required_error → error) 24 sites auto-fixable
+ ✗ ZodTypeDef removed 1 fix, 3 errors needs the agent (LLM)
+ ✗ .ip() removed monitoring.ts:6 needs the agent (LLM)
+ ! 15 deprecated calls (.uuid, .datetime, .email, ...) 8 auto-fixable
+
+stripe 4 breaking · compiled against 23.0.0: 4 new type errors
+ ✗ Test fixture casts widened renewal.test.ts:7 auto-fixable
+ ✗ Subscription billing period moved to items 2 sites needs the agent (LLM)
+ ✗ apiVersion no longer matches the SDK client.ts:9 needs the agent (LLM)
+ 1 API change since 2023-10-16 affects your code
+
+
+
+
+ After: fix, one dependency at a time, each on its own verified branch.
+
+
fixtures/repos/storefrontuptide fix zod
+
$ npx uptide fix zod
+uptide fix · zod 3.25.76 → 4.6.5 (latest on npm) · verification passed · 36s
+
+ Risk Medium: request validation in webhooks
+ Changes 29 sites in 8 files · 25 auto-fixed · 4 fixed by the agent (LLM)
+ Types ✅ 29 errors after the bump → 1 (1 pre-existing)
+ Behavior ✅ 9 schemas identical · 2 not checked · 21/21 custom-message assertions
+ Tests ✅ 5 tests in 3 files passed
+
+
+
+
+ No compiler sees this one: zod 4 words its default messages differently, and a test that asserted the old wording failed after the migration. Uptide updated it and says so in the pull request, so you can check that nothing else reads that text.
+
+
packages/shared/src/monitoring.test.tsdiff
+
- expect(parsed.error?.issues[0]?.message).toBe('Required');+ expect(parsed.error?.issues[0]?.message).toBe('Invalid input: expected string, received undefined');
+
+
+
+
+
+
fixtures/repos/storefrontuptide fix stripe
+
$ npx uptide fix stripe
+uptide fix · stripe 14.25.0 → 23.0.0 (latest on npm) · verification passed · 41s
+
+ Risk High: behavior changes
+ Changes 6 sites in 3 files · 3 auto-fixed · 3 fixed by the agent (LLM)
+ Types ✅ 5 errors after the bump → 1 (1 pre-existing)
+ Behavior ⚠️ 1 of 570 API changes affects your code · 47 touch resources you use (8 breaking)
+ Tests ✅ 3 tests in 2 files passed · the test script of packages/api
+
+ Stripe moved a subscription's billing period from the subscription to its items. Uptide added a subscriptionPeriod helper and migrated the code that read the old fields.
+
+
+
+
+
packages/api/src/billing/renewal.tsdiff
+
- renewsAt: new Date(subscription.current_period_end * 1000),+ renewsAt: new Date(period.end * 1000),
+
+ The one type error left in both runs is the fixture's own, there on purpose: errors a repository already had are subtracted, never blamed on the upgrade.
+
+
+
1 supabase/supabase at commit 9d1661dec1548ec5bf3ffb4f551376a7650c213e, run with uptide 0.4.0 from npm (npx uptide@latest list) on 2026-10-06. Rows are trimmed, values are as printed. Latest versions and advisories come from the npm registry at run time, so a later run prints different numbers.
+
+
+
+
+
+
+
04 / Honest by design
+
The compiler decides. The diff explains.
+
A change in a package's API is a reason to look, not a verdict. A site is breaking when your own compiler, or the new version loaded at runtime, says so.
+
+
+
+
Verified packs and generic mode
+
zod 3 → 4 and stripe have verified packs: rules written for that dependency, a guide for the agent, behavior checks, and ground truth from a real upgrade that the pack is scored against.
+
Every other dependency runs in generic mode. A finding is breaking only when your compiler or the runtime probe confirms it, or when it is a require() of an ESM-only package or an import of a removed export. Everything else is listed as unconfirmed under --details. A generic fix uses the agent alone, under the same verification, and its pull request says no pack covers the package.
+
+
+
When it can't verify, it says so
+
If the compiler could not run, the verdict says why instead of guessing: N breaking · types not verified: <reason>. A package whose analysis fails is named with its reason and never called clean.
+
A fix that does not verify exits 1 and lists the sites left for you. Unfinished sites stay manual and cannot pass the publish gate. Passing compilation does not prove runtime equivalence, so the report says what was verified, what was not, and what to review.
+
+
+
Risk levels
+
+
LowVerified rule-only edits.
+
MediumAgent edits, sensitive paths, or missing tests.
+
HighUnverified sites, behavior changes, or manual work left.
+
+
+
+
Why stripe is High on purpose
+
Types and tests pass, but a new Stripe API version changes what Stripe sends. Server API changes are never rewritten by rule, and the pull request asks what only you can answer, such as which item's period counts when a subscription has several.
+
+
+
+
+
+
+
+
05 / Safe to run
+
Built to run on code it didn't write.
+
+
+
+
+
list and check execute nothing from your repository. Configs are read as data; check compiles against the target in memory.
+
fix works in an isolated clone of the commit you have checked out, never in your checkout and never from the remote. Uncommitted changes are left out and listed. Your branch, files, git hooks and git config are compared before and after and must be identical.
+
Lifecycle scripts are disabled for every install, and git hooks are disabled for every git command it runs.
+
Lockfile check: a lockfile change outside the upgraded dependency's subtree is rejected.
+
+
+
+
+
Services are opt-in. Tests that need a database, cache or queue run only with --with-services --yes, after it prints what they connect to.
+
No PR from a dirty or diverged tree. With --pr it stops before any work when the working tree has uncommitted changes or the base branch on origin does not contain your commit. uptide pr checks the branch is still at the verified commit.
+
No push without you. The branch stays local until you run uptide pr --yes or pass fix --pr --yes.
+
Secrets stay out of child processes. Install and test commands do not receive your LLM API key or GitHub token.
+
+
+
+
Not a sandbox for your own code.fix runs your repository's tests with your user's permissions, the way you would. Run it on code you would run yourself. The full model is in SECURITY.md.
+
+
+
+
+
+
06 / Your code, your key
+
The agent runs on your key, only when rules run out.
+
+
+
list, check
Never call an LLM.
+
fix
Rules first. Only sites no rule covers go to the agent, one at a time, and a patch is kept only if it removes its compiler error and introduces none.
+
Providers
Anthropic by default (claude-sonnet-5-5), OpenAI (gpt-6.1-sol) or Gemini, experimental (gemini-3.8-flash), with your own key from ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY. Keys are read only from the environment. Pick with --provider and --model.
+
Cost cap
--max-cost defaults to $1 per package. Uptide reserves the worst-case cost before every call, and a call that would exceed the budget is never sent.
+
Fully local
--no-llm turns the agent off. Rules still apply, and the remaining sites are listed for you.
+
+
+
+
+
+
+
07 / Privacy
+
What leaves your machine, exactly.
+
The same statement the CLI prints in uptide --help:
+
+
+
uptide --helpprivacy
+
Privacy:
+ Analysis runs locally. Code snippets go to your chosen LLM provider (Anthropic, OpenAI or Gemini), only
+ for assisted fixes in `uptide fix`, and only with your own API key from ANTHROPIC_API_KEY,
+ OPENAI_API_KEY or GEMINI_API_KEY: for each
+ site the rules cannot migrate, the finding, the enclosing function and the compiler
+ error. `uptide fix --no-llm` turns assisted fixes off. No account.
+ Anonymous telemetry is off by default and asks for consent in an interactive terminal.
+ Set UPTIDE_TELEMETRY=0 to disable it. No IP, code, paths or repo names are collected.
+ Other network use: your npm registry for package metadata and tarballs, npm's advisory
+ endpoint from `list` (public package names and installed versions; `--no-advisories` turns
+ it off), PostHog EU only
+ after telemetry opt-in, and GitHub when you pass `fix --pr` or run `pr` / `pr-body`.
+
+
+
Command
What leaves your machine (telemetry off, the default)
+
+
list
Package names and versions requested from your npm registry, and the names and installed versions of public packages sent to npm's advisory endpoint. Off with --no-advisories.
+
check
Nothing of yours. It downloads package tarballs from your npm registry.
+
fix
Nothing of yours for rules and verification. For each site no rule covers: the finding, the enclosing function or declaration, and the compiler error, to the provider you chose, with your key.
+
pr
The branch and the pull request, to GitHub through your own gh, when you say so.
+
+
+
Telemetry is anonymous, off by default and opt-in: the first interactive run asks once, and Enter means no. CI never sends without UPTIDE_TELEMETRY=1. The exact fields are in docs/telemetry.md.
+
+
+
+
+
+
08 / Supported today
+
What works now.
+
+
+
Package managers
npm, pnpm and Yarn with the node-modules linker. Not Yarn Plug'n'Play.
+
Monorepos
Workspaces and pnpm catalogs. One row per package across workspaces, with version drift called out.
+
Languages
TypeScript, and JavaScript in the project (allowJs). In JavaScript the compiler type-checks (checkJs or // @ts-check) the compiler decides; elsewhere a site is breaking only when the runtime confirms it.
+
Verified packs
zod 3 → 4, and stripe 14 and newer.
+
Generic mode
Every other dependency: list, check, and fix with the agent.
+
GitHub Action
For version-bot pull requests: detects zod and stripe upgrades and keeps one findings comment. With fix enabled, it pushes one verified commit to the bot's branch. Setup.
+
Runtime
Node 20 or newer.
+
+
+
+
+
+
+
09 / Contribute
+
Packs are how Uptide learns a library.
+
A pack holds what Uptide knows about one dependency: deterministic rules applied only at sites check reported, a guide and validator for the agent, findings the compiler cannot see, and review material for the pull request. Every rule ships with a before-and-after test and a case it must leave alone, and every claim is backed by ground truth from a real upgrade.