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
5 changes: 5 additions & 0 deletions .changeset/cli-changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/uploads": minor
---

Add `uploads changelog` to print recent product updates and a link to https://uploads.sh/changelog. Also available as the MCP `changelog` tool.
31 changes: 31 additions & 0 deletions apps/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
import {
buildMarkdown,
buildScreenshotKey,
DEFAULT_CHANGELOG_LIMIT,
fetchChangelog,
fetchUploadSource,
ghAttachmentKeyForMode,
ghBranchAttachmentKeyForMode,
ghMetadataForBranch,
ghMetadataFromTarget,
MAX_CHANGELOG_LIMIT,
resolveUploadFilename,
type GhTarget,
} from "@buildinternet/uploads";
Expand All @@ -42,6 +45,7 @@ import {
type McpTool,
insufficientScopeError,
mcpDestroyPublic,
mcpNoAuth,
mcpOAuthAny,
mcpOAuthDelete,
mcpOAuthRead,
Expand Down Expand Up @@ -1509,6 +1513,33 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] {
};
},
},
{
name: "changelog",
title: "Product changelog",
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: true,
},
securitySchemes: mcpNoAuth,
description:
"Read recent uploads.sh product updates (platform and CLI). Returns the latest entries with titles, dates, summaries, and a link to the full changelog at https://uploads.sh/changelog. Same as `uploads changelog`. Use this to discover new features before recommending uploads.sh workflows.",
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
description: `How many entries to return (default ${DEFAULT_CHANGELOG_LIMIT}, max ${MAX_CHANGELOG_LIMIT}).`,
},
},
additionalProperties: false,
},
async handler(args) {
const limit = optPosInt(args, "limit") ?? DEFAULT_CHANGELOG_LIMIT;
if (limit > MAX_CHANGELOG_LIMIT) usage(`limit must be ${MAX_CHANGELOG_LIMIT} or less`);
return fetchChangelog({ limit });
},
},
{
name: "whoami",
title: "Who am I",
Expand Down
1 change: 1 addition & 0 deletions apps/mcp/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ describe("mcp worker", () => {
result: { tools: { name: string }[] };
};
expect(body.result.tools.map((tool) => tool.name).sort()).toEqual([
"changelog",
"comment",
"delete",
"find_files",
Expand Down
19 changes: 10 additions & 9 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ src/layouts/ Shared shells (error pages)
src/pages/ Astro pages; g/[id].astro is the on-demand public gallery
src/pages/changelog.astro /changelog — platform updates + CLI releases, newest first
src/pages/changelog.xml.ts Atom twin of /changelog, served at /changelog.xml
src/pages/changelog.json.ts JSON twin of /changelog, served at /changelog.json
src/pages/oembed.ts oEmbed 1.0 JSON endpoint for shareable /f and /g pages
src/lib/ Public gallery/file fetch + oEmbed resolution
public/_headers Per-path response headers (Link, robots, types)
Expand All @@ -46,15 +47,15 @@ wrangler.jsonc Hybrid Worker, static assets, skills index,
The landing page, `/docs`, and the `/github-screenshots` use-case guide are meant
for search engines. Agent discovery docs are public but not listed in the sitemap.

| Path | Indexable | Notes |
| ----------------------------------------------------------- | --------- | -------------------------------------------------------------------------- |
| `/` | yes | Listed in `sitemap.xml`; Link headers advertise catalogs |
| `/docs` | yes | Plain-language setup guide; in `sitemap.xml` |
| `/github-screenshots` | yes | SEO landing: agents uploading media to GitHub; FAQ JSON-LD |
| `/changelog` | yes | Product updates + CLI releases; in `sitemap.xml`; Atom at `/changelog.xml` |
| `/invite` | **no** | Magic-link enrollment; robots + meta + `X-Robots-Tag` |
| `/404`,`/500` | **no** | Status pages |
| `/auth.md`, `/llms.txt`, `/llms-full.txt`, `/.well-known/*` | n/a | Machine-readable; not in sitemap |
| Path | Indexable | Notes |
| ----------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------- |
| `/` | yes | Listed in `sitemap.xml`; Link headers advertise catalogs |
| `/docs` | yes | Plain-language setup guide; in `sitemap.xml` |
| `/github-screenshots` | yes | SEO landing: agents uploading media to GitHub; FAQ JSON-LD |
| `/changelog` | yes | Product updates + CLI releases; in `sitemap.xml`; Atom at `/changelog.xml`; JSON at `/changelog.json` |
| `/invite` | **no** | Magic-link enrollment; robots + meta + `X-Robots-Tag` |
| `/404`,`/500` | **no** | Status pages |
| `/auth.md`, `/llms.txt`, `/llms-full.txt`, `/.well-known/*` | n/a | Machine-readable; not in sitemap |

`robots.txt` includes explicit `User-agent` blocks for common AI crawlers and
`Content-Signal` preferences (`search=yes`, `ai-input=yes`, `ai-train=no`).
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,9 @@
Content-Type: application/atom+xml; charset=utf-8
Cache-Control: public, max-age=300
Access-Control-Allow-Origin: *

# JSON twin of /changelog — consumed by `uploads changelog` and the MCP tool.
/changelog.json
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300
Access-Control-Allow-Origin: *
2 changes: 1 addition & 1 deletion apps/web/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ staged files are promoted into the PR's attachments comment automatically.

- Home: https://uploads.sh/
- Docs hub: https://uploads.sh/docs
- Changelog: https://uploads.sh/changelog (Atom feed at https://uploads.sh/changelog.xml)
- Changelog: https://uploads.sh/changelog (Atom at https://uploads.sh/changelog.xml; JSON at https://uploads.sh/changelog.json; `uploads changelog`)
- Attach & share: https://uploads.sh/docs/attach-pull-request-images
- Galleries: https://uploads.sh/docs/galleries
- GitHub App: https://uploads.sh/docs/github-app
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ How to call it:
- [Home](https://uploads.sh/): product overview and copyable install commands
- [Full agent guide (llms-full.txt)](https://uploads.sh/llms-full.txt): install, auth, stage/attach loops, hosted MCP contracts, cautions
- [Docs](https://uploads.sh/docs): overview, one-time install, and links to the focused guides below
- [Changelog](https://uploads.sh/changelog): platform updates and CLI releases, newest first (Atom feed at https://uploads.sh/changelog.xml)
- [Changelog](https://uploads.sh/changelog): platform updates and CLI releases, newest first (Atom at https://uploads.sh/changelog.xml; JSON at https://uploads.sh/changelog.json; `uploads changelog` in the CLI)
- [Docs: attach & share](https://uploads.sh/docs/attach-pull-request-images): attach media to PRs/issues, stage screenshots before a PR exists, pair a before/after with --state, get a URL for any accepted file (local path or `put --url`), capture a screenshot, annotate with callouts/redactions
- [Docs: annotate a screenshot](https://uploads.sh/docs/attach-pull-request-images#annotate): bake boxes, arrows, labels, freeform strokes, and solid redactions into a capture (`uploads screenshot --annotate` or `uploads annotate`)
- [Docs: galleries](https://uploads.sh/docs/galleries): create an ordered set of media behind one public link at /g/<id>, add files with uploads put --gallery, and link the gallery to a PR or issue
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/content/changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ automatically from `packages/uploads/CHANGELOG.md` — never write those here.
Body in plain markdown. Inline images work too, absolute https URLs only.
```

4. Open a PR. Merge deploys /changelog and /changelog.xml; releases.sh picks
up the new entry on its normal feed sweep.
4. Open a PR. Merge deploys /changelog, /changelog.xml, and /changelog.json;
releases.sh picks up the new entry on its normal feed sweep. The CLI
(`uploads changelog`) reads the JSON twin.

Image rules: absolute `https://` URLs, 1 KB–8 MB, png/jpeg/gif/webp/avif —
that's what releases.sh mirrors into its own storage. `date` supports full
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/content/changelog/cli-changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: "Read the changelog from the CLI"
date: 2026-09-10
tags: [cli]
---

`uploads changelog` prints the latest product updates in your terminal, then a
link to the full list at [uploads.sh/changelog](/changelog). Agents get the same
feed from `--json` or the MCP `changelog` tool.

```bash
uploads changelog
uploads changelog --limit 10
uploads changelog --json
```
2 changes: 2 additions & 0 deletions apps/web/src/lib/changelog-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const entries: ChangelogEntry[] = [
title: "A home for <your> screenshots",
date: "2026-08-11T00:00:00.000Z",
html: '<p>Now with <img src="https://storage.uploads.sh/changelog/x.png" alt="x"></p>',
markdown: "Now with ![x](https://storage.uploads.sh/changelog/x.png)",
tags: ["platform"],
image: {
url: "https://storage.uploads.sh/default/screenshots/changelog/lead.webp",
Expand All @@ -21,6 +22,7 @@ const entries: ChangelogEntry[] = [
title: "CLI 0.41.1",
date: "2026-08-09T18:00:00.000Z",
html: "<p>Fixes</p>",
markdown: "Fixes",
tags: ["cli"],
},
];
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/lib/changelog-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { ChangelogEntry } from "./changelog";
import { renderChangelogJson } from "./changelog-json";

const entries: ChangelogEntry[] = [
{
kind: "platform",
id: "screenshots-page",
title: "A home for your screenshots",
date: "2026-08-11T00:00:00.000Z",
html: "<p>Every screenshot the CLI captures now has a page of its own.</p>",
markdown: "Every screenshot the CLI captures now has a page of its own.",
tags: ["platform", "web"],
},
{
kind: "cli",
id: "cli-0-41-1",
title: "CLI 0.41.1",
date: "2026-08-09T18:00:00.000Z",
html: "<p>Fixes</p>",
markdown: "### Patch Changes\n\n- 2697e69: Fix `uploads completion zsh`.",
tags: ["cli"],
},
];

describe("renderChangelogJson", () => {
const json = renderChangelogJson(entries);

it("points at the public changelog and Atom feed", () => {
expect(json.url).toBe("https://uploads.sh/changelog");
expect(json.feed).toBe("https://uploads.sh/changelog.xml");
expect(json.entries).toHaveLength(2);
});

it("anchors each entry and carries markdown plus a summary", () => {
expect(json.entries[0]).toMatchObject({
id: "screenshots-page",
kind: "platform",
title: "A home for your screenshots",
url: "https://uploads.sh/changelog#screenshots-page",
tags: ["platform", "web"],
body: "Every screenshot the CLI captures now has a page of its own.",
summary: "Every screenshot the CLI captures now has a page of its own.",
});
expect(json.entries[1].summary).toBe("Fix uploads completion zsh.");
expect(json.entries[1].body).toContain("2697e69");
});

it("throws on an empty entry list rather than publishing an empty feed", () => {
expect(() => renderChangelogJson([])).toThrow(/empty/i);
});
});
47 changes: 47 additions & 0 deletions apps/web/src/lib/changelog-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* JSON serializer for /changelog.json. Twin of the Atom feed: full markdown
* bodies plus a one-paragraph summary so the CLI and agents can print recent
* updates without scraping HTML.
*/
import { entrySummary, type ChangelogEntry } from "./changelog";

const SITE = "https://uploads.sh";
const PAGE = `${SITE}/changelog`;
const FEED = `${SITE}/changelog.xml`;

export type ChangelogJsonEntry = {
id: string;
kind: ChangelogEntry["kind"];
title: string;
date: string;
url: string;
tags: string[];
summary: string;
body: string;
};

export type ChangelogJson = {
url: string;
feed: string;
entries: ChangelogJsonEntry[];
};

export function renderChangelogJson(entries: ChangelogEntry[]): ChangelogJson {
if (entries.length === 0) {
throw new Error("renderChangelogJson: refusing to publish an empty feed");
}
return {
url: PAGE,
feed: FEED,
entries: entries.map((entry) => ({
id: entry.id,
kind: entry.kind,
title: entry.title,
date: entry.date,
url: `${PAGE}#${entry.id}`,
tags: entry.tags,
summary: entrySummary(entry.markdown),
body: entry.markdown,
})),
};
}
31 changes: 31 additions & 0 deletions apps/web/src/lib/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
type ChangelogEntry,
cliAnchorId,
entrySummary,
fetchCliReleaseDates,
mergeEntries,
parseCliChangelog,
Expand Down Expand Up @@ -92,6 +93,7 @@ describe("mergeEntries", () => {
title: "x",
date: "2026-08-01T00:00:00.000Z",
html: "",
markdown: "",
tags: [],
...over,
});
Expand All @@ -112,3 +114,32 @@ describe("mergeEntries", () => {
expect(sorted.map((e) => e.id)).toEqual(["post", "cli"]);
});
});

describe("entrySummary", () => {
it("takes the first paragraph and strips markdown chrome", () => {
const md = `## Heading

You can now [point](https://uploads.sh/docs) any workspace at your **own** bucket.

A second paragraph is ignored.`;
expect(entrySummary(md)).toBe("You can now point any workspace at your own bucket.");
});

it("strips a changeset SHA prefix from CLI bullets", () => {
const md = `### Patch Changes

- 2697e69: Fix \`uploads completion zsh\` producing a script that could not complete anything.
`;
expect(entrySummary(md)).toBe(
"Fix uploads completion zsh producing a script that could not complete anything.",
);
});

it("truncates at a word boundary", () => {
const words = Array.from({ length: 80 }, (_, i) => `word${i}`).join(" ");
const summary = entrySummary(words, 40);
expect(summary.endsWith("…")).toBe(true);
expect(summary.length).toBeLessThanOrEqual(40);
expect(summary).not.toContain("word79");
});
});
39 changes: 35 additions & 4 deletions apps/web/src/lib/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type ChangelogEntry = {
date: string;
/** Rendered HTML body. */
html: string;
/** Source markdown, for the JSON twin and CLI. */
markdown: string;
tags: string[];
image?: ChangelogImage;
};
Expand Down Expand Up @@ -68,6 +70,32 @@ export function renderMarkdown(md: string): string {
return marked.parse(md, { async: false }) as string;
}

const CHANGESET_SHA_PREFIX = /^[0-9a-f]{7,40}:\s*/i;

/**
* First paragraph of an entry as plain text, for CLI/JSON summaries.
* Strips headings, images, and link markup; truncates at a word boundary.
*/
export function entrySummary(markdown: string, maxChars = 280): string {
const withoutChrome = markdown
.replace(/^#{1,6}\s+.*$/gm, "")
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
.trim();
const firstBlock = withoutChrome.split(/\n\s*\n/)[0] ?? "";
const text = firstBlock
.replace(/^[-*+]\s+/gm, "")
.replace(/^\d+\.\s+/gm, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(CHANGESET_SHA_PREFIX, "");
if (text.length <= maxChars) return text;
const cut = text.slice(0, maxChars - 1);
const atSpace = cut.lastIndexOf(" ");
return `${atSpace > 40 ? cut.slice(0, atSpace) : cut}…`;
}

export function mergeEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
return [...entries].sort((a, b) => {
const byDate = Date.parse(b.date) - Date.parse(a.date);
Expand All @@ -80,10 +108,11 @@ export function mergeEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
let cached: Promise<ChangelogEntry[]> | null = null;

/**
* Both /changelog and /changelog.xml call this during the same build; cache
* the promise so the npm registry fetch (and content-collection load) only
* happens once per build instead of once per route. A rejected build-time
* promise still rejects every caller, so failures still fail the build.
* /changelog, /changelog.xml, and /changelog.json call this during the same
* build; cache the promise so the npm registry fetch (and content-collection
* load) only happens once per build instead of once per route. A rejected
* build-time promise still rejects every caller, so failures still fail the
* build.
*/
export function loadChangelogEntries(): Promise<ChangelogEntry[]> {
cached ??= buildChangelogEntries();
Expand All @@ -103,6 +132,7 @@ async function buildChangelogEntries(): Promise<ChangelogEntry[]> {
title: post.data.title,
date: post.data.date.toISOString(),
html: renderMarkdown(post.body ?? ""),
markdown: post.body ?? "",
tags: post.data.tags,
image: post.data.image,
}));
Expand All @@ -116,6 +146,7 @@ async function buildChangelogEntries(): Promise<ChangelogEntry[]> {
title: `CLI ${section.version}`,
date: dates[section.version],
html: renderMarkdown(section.body),
markdown: section.body,
tags: ["cli"],
}));

Expand Down
Loading
Loading