diff --git a/.changeset/changelog-page.md b/.changeset/changelog-page.md new file mode 100644 index 0000000000..3add79426b --- /dev/null +++ b/.changeset/changelog-page.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +The update card now links to the changelog at executor.sh/changelog, and the changelog is published there for every release. diff --git a/apps/cli/package.json b/apps/cli/package.json index a21ce368d8..745514811b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -7,7 +7,8 @@ }, "type": "module", "exports": { - ".": "./src/main.ts" + ".": "./src/main.ts", + "./CHANGELOG.md": "./CHANGELOG.md" }, "scripts": { "build": "bun run src/build.ts binary --single", diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index b51ef0b459..40cea3d7ab 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -18,6 +18,8 @@ const MARKETING_PATHS = [ "/google-oauth", "/google-workspace", "/blog", + "/changelog", + "/changelog.json", "/llms.txt", "/api/detect", "/_astro", diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 6b3592652e..5a73261a22 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -8,6 +8,7 @@ "dev:vite": "portless --name executor-marketing astro dev", "build": "astro build", "preview": "astro preview", + "test": "vitest run", "deploy": "astro build && npx wrangler deploy --config dist/server/wrangler.json", "astro": "astro", "typecheck": "tsgo --noEmit", @@ -20,11 +21,13 @@ "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.3", "clsx": "^2.1.1", + "marked": "^17.0.1", "motion": "^12.38.0", "posthog-js": "^1.372.5", "react": "^19.2.5", "react-dom": "^19.2.5", "react-tweet": "^3.3.0", + "sanitize-html": "^2.17.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2" }, @@ -32,6 +35,8 @@ "@rhyssul/portless": "^0.13.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/sanitize-html": "^2.16.1", + "executor": "workspace:*", "wrangler": "^4.0.0" }, "engines": { diff --git a/apps/marketing/src/layouts/BlogPostLayout.astro b/apps/marketing/src/layouts/BlogPostLayout.astro index 6a244d6fff..9514ecdf8b 100644 --- a/apps/marketing/src/layouts/BlogPostLayout.astro +++ b/apps/marketing/src/layouts/BlogPostLayout.astro @@ -67,6 +67,11 @@ const isoDate = date.toISOString(); class="text-[14px] font-medium text-ink-2 transition-colors hover:text-ink" >Blog + Changelog
Blog + Changelog Docs { + const entry = parseChangelog(markdown)[0]?.entries[0]; + if (!entry) throw new Error("expected one parsed entry"); + return entry.body; +}; + +const release = (item: string) => `# executor\n\n## 9.9.9\n\n### Patch Changes\n\n- ${item}\n`; + +describe("renderChangelogHtml", () => { + it("neutralizes raw HTML embedded in a changeset", () => { + const html = renderChangelogHtml( + bodyOf( + release( + 'Fix the thing. Done.', + ), + ), + ); + + expect(html).not.toContain("Fix the thing. Done.

"); + }); + + it("strips event handlers from tags it otherwise allows", () => { + expect(renderChangelogHtml('

Text

').trim()).toBe("

Text

"); + }); + + it("drops non-https link targets while keeping the link text", () => { + const html = renderChangelogHtml( + bodyOf( + release( + "See [docs](javascript:alert(1)) and [more](https://executor.sh/docs) and [http](http://example.com).", + ), + ), + ); + + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("http://example.com"); + expect(html).toContain('docs"); + }); + + it("keeps ordinary changelog prose intact", () => { + const html = renderChangelogHtml( + "**Highlights**\n\n- One thing\n- Another\n\n### Details\n\nUse `/mcp` for the path.", + ); + + expect(html).toContain("Highlights"); + expect(html).toContain("
  • One thing
  • "); + expect(html).toContain("

    Details

    "); + expect(html).toContain("<origin>/mcp"); + }); +}); + +describe("changelogBodyToText", () => { + it("publishes no markup for a hostile changeset", () => { + const text = changelogBodyToText( + bodyOf(release('Fix it. Done.')), + ); + + expect(text).not.toContain(" { + // A code span is escaped by the renderer, so decoding `<` here would put + // a working `` back into a payload that is meant to carry no markup. + const text = changelogBodyToText("Never write `` in a changeset."); + + expect(text).not.toContain(" { + expect(changelogBodyToText("**Integrations & auth** for [`login`](https://executor.sh).")).toBe( + "Integrations & auth for login.", + ); + }); +}); diff --git a/apps/marketing/src/lib/changelog-html.ts b/apps/marketing/src/lib/changelog-html.ts new file mode 100644 index 0000000000..1da8f677af --- /dev/null +++ b/apps/marketing/src/lib/changelog-html.ts @@ -0,0 +1,78 @@ +// Rendering for changelog bodies. +// +// CHANGELOG.md is generated from changesets, and a changeset is written by +// whoever opened the pull request. Its text is therefore untrusted input that +// happens to live in the repository, and markdown allows both raw HTML and +// `javascript:` links. Every path that turns a body into HTML goes through +// `renderChangelogHtml`, which parses the markdown and then re-serializes it +// through a fixed allowlist, so anything outside that list cannot survive — +// no `