Skip to content
Closed
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/changelog-page.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/edge/marketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const MARKETING_PATHS = [
"/google-oauth",
"/google-workspace",
"/blog",
"/changelog",
"/changelog.json",
"/llms.txt",
"/api/detect",
"/_astro",
Expand Down
5 changes: 5 additions & 0 deletions apps/marketing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -20,18 +21,22 @@
"@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"
},
"devDependencies": {
"@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": {
Expand Down
8 changes: 8 additions & 0 deletions apps/marketing/src/layouts/BlogPostLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const isoDate = date.toISOString();
class="text-[14px] font-medium text-ink-2 transition-colors hover:text-ink"
>Blog</a
>
<a
href="/changelog"
class="text-[14px] font-medium text-ink-2 transition-colors hover:text-ink"
>Changelog</a
>
<a
href="https://executor.sh/docs"
class="hidden text-[14px] font-medium text-ink-2 transition-colors hover:text-ink sm:inline"
Expand Down Expand Up @@ -167,6 +172,9 @@ const isoDate = date.toISOString();
</div>
<div class="flex items-center gap-6 text-[13px] text-ink-2">
<a href="/blog" class="transition-colors hover:text-ink">Blog</a>
<a href="/changelog" class="transition-colors hover:text-ink"
>Changelog</a
>
<a
href="https://executor.sh/docs"
class="transition-colors hover:text-ink">Docs</a
Expand Down
92 changes: 92 additions & 0 deletions apps/marketing/src/lib/changelog-html.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "@effect/vitest";

import { changelogBodyToText, renderChangelogHtml } from "./changelog-html";
import { parseChangelog } from "./changelog";

// A changeset is written by whoever opened the pull request, so these run the
// whole path a hostile changeset would take: markdown source → parseChangelog →
// the HTML the page injects, and the text the JSON endpoint publishes.
const bodyOf = (markdown: string): string => {
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. <img src=x onerror="alert(1)"> <script>alert(2)</script><iframe src="https://evil.example"></iframe> Done.',
),
),
);

expect(html).not.toContain("<img");
expect(html).not.toContain("<script");
expect(html).not.toContain("<iframe");
expect(html).not.toContain("onerror");
expect(html).not.toContain("alert(2)");
expect(html.trim()).toBe("<p>Fix the thing. Done.</p>");
});

it("strips event handlers from tags it otherwise allows", () => {
expect(renderChangelogHtml('<p onclick="alert(1)">Text</p>').trim()).toBe("<p>Text</p>");
});

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('<a href="https://executor.sh/docs" rel="noopener noreferrer"');
expect(html).toContain(">docs</a>");
});

it("keeps ordinary changelog prose intact", () => {
const html = renderChangelogHtml(
"**Highlights**\n\n- One thing\n- Another\n\n### Details\n\nUse `<origin>/mcp` for the path.",
);

expect(html).toContain("<strong>Highlights</strong>");
expect(html).toContain("<li>One thing</li>");
expect(html).toContain("<h3>Details</h3>");
expect(html).toContain("<code>&lt;origin&gt;/mcp</code>");
});
});

describe("changelogBodyToText", () => {
it("publishes no markup for a hostile changeset", () => {
const text = changelogBodyToText(
bodyOf(release('Fix it. <img src=x onerror="alert(1)"><script>alert(2)</script> Done.')),
);

expect(text).not.toContain("<img");
expect(text).not.toContain("onerror");
expect(text).not.toContain("alert");
expect(text).toBe("Fix it. Done.");
});

it("leaves no tag recoverable from an escaped code span", () => {
// A code span is escaped by the renderer, so decoding `&lt;` here would put
// a working `<img>` back into a payload that is meant to carry no markup.
const text = changelogBodyToText("Never write `<img src=x onerror=alert(1)>` in a changeset.");

expect(text).not.toContain("<img");
expect(text).toContain("&lt;img");
});

it("flattens markdown to text and decodes ampersands", () => {
expect(changelogBodyToText("**Integrations & auth** for [`login`](https://executor.sh).")).toBe(
"Integrations & auth for login.",
);
});
});
78 changes: 78 additions & 0 deletions apps/marketing/src/lib/changelog-html.ts
Original file line number Diff line number Diff line change
@@ -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 `<script>`, no event handlers, no non-https URLs.
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

// Prose only. A changelog entry never needs images, tables, iframes, styles or
// ids, so none of them are allowed. Unlisted tags are dropped but their text is
// kept, and `sanitize-html` discards the contents of `script`/`style` outright.
const RENDER_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: ["p", "br", "strong", "em", "code", "pre", "ul", "ol", "li", "h3", "h4", "a"],
allowedAttributes: { a: ["href", "rel", "target"] },
// https only: this also rules out `javascript:`, `data:` and protocol-relative
// URLs, which `marked` itself does not filter.
allowedSchemes: ["https"],
allowedSchemesAppliedToAttributes: ["href"],
allowProtocolRelative: false,
disallowedTagsMode: "discard",
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: { ...attribs, rel: "noopener noreferrer", target: "_blank" },
}),
},
};

const TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [],
allowedAttributes: {},
disallowedTagsMode: "discard",
};

const toHtml = (body: string): string => marked.parse(body, { async: false });

/** Sanitized HTML for a changelog entry body, safe to inject with `set:html`. */
export const renderChangelogHtml = (body: string): string =>
sanitizeHtml(toHtml(body), RENDER_OPTIONS);

// `sanitize-html` escapes the text it emits, because its output is HTML, and
// `&amp;` in a JSON string is just noise. `&lt;` and `&gt;` are deliberately
// left escaped: a body may legitimately contain `<` (a code span such as
// `` `<origin>/mcp` ``), and decoding it would put a live tag back into a
// payload whose whole point is that it carries none.
const ENTITIES: ReadonlyArray<readonly [RegExp, string]> = [
[/&quot;/g, '"'],
[/&#39;/g, "'"],
// Last, so that a literal `&quot;` in the source (encoded as `&amp;quot;`)
// decodes back to `&quot;` rather than to a quote character.
[/&amp;/g, "&"],
];

/**
* A changelog entry body as plain text, with every tag removed.
*
* `/changelog.json` is public and cross-origin readable, so it must not hand a
* consumer markup it would be unsafe to render. The body is rendered and then
* stripped rather than regex-scrubbed, so markdown constructs (code spans,
* links, emphasis) collapse to their text instead of leaking their syntax.
*/
export const changelogBodyToText = (body: string): string => {
const stripped = sanitizeHtml(toHtml(body), TEXT_OPTIONS);
const decoded = ENTITIES.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
stripped,
);
return decoded
.split("\n")
.map((line) => line.trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
};
144 changes: 144 additions & 0 deletions apps/marketing/src/lib/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from "@effect/vitest";

import { parseChangelog } from "./changelog";

describe("parseChangelog", () => {
it("strips changesets boilerplate and captures PR metadata", () => {
const releases = parseChangelog(`# executor

## 1.5.29

### Patch Changes

- [#1341](https://github.com/UsefulSoftwareCo/executor/pull/1341) [\`5656c3e\`](https://github.com/UsefulSoftwareCo/executor/commit/5656c3e2fbb1982510267a7999f4ae37cdb5a381) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix 1Password desktop-app connections failing with "undefined is not a constructor (evaluating 'new n.DesktopAuth(...)')" in packaged builds. The compiled binary now bundles the 1Password SDK's wasm core correctly and falls back to a copy shipped next to the binary, so vault listing and secret resolution work without the \`op\` CLI installed.

- Updated dependencies []:
- @executor-js/sdk@1.5.29
- @executor-js/runtime-quickjs@1.5.29
- @executor-js/local@1.4.4
- @executor-js/api@1.4.49
`);

expect(releases).toEqual([
{
version: "1.5.29",
entries: [
{
prNumber: 1341,
prUrl: "https://github.com/UsefulSoftwareCo/executor/pull/1341",
body: "Fix 1Password desktop-app connections failing with \"undefined is not a constructor (evaluating 'new n.DesktopAuth(...)')\" in packaged builds. The compiled binary now bundles the 1Password SDK's wasm core correctly and falls back to a copy shipped next to the binary, so vault listing and secret resolution work without the `op` CLI installed.",
},
],
},
]);
});

it("drops dependency-only releases", () => {
const releases = parseChangelog(`# executor

## 1.5.28

### Patch Changes

- Updated dependencies [[\`1c48182\`](https://github.com/UsefulSoftwareCo/executor/commit/1c4818254e71dc4ee27ff95f489e2c5cf330a450)]:
- @executor-js/sdk@1.5.28
- @executor-js/local@1.4.4
- @executor-js/api@1.4.48
- @executor-js/runtime-quickjs@1.5.28
`);

expect(releases).toEqual([]);
});

it("keeps release entries while dropping dependency blocks", () => {
const releases = parseChangelog(`# executor

## 1.5.26

### Patch Changes

- [#1221](https://github.com/RhysSullivan/executor/pull/1221) [\`3606317\`](https://github.com/RhysSullivan/executor/commit/360631733e0d0595094a06b9a9fbe06b2714d16c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send correct \`Cache-Control\` headers for the self-hosted web app. The SPA shell (\`index.html\`) and its client-route fallbacks are now served with \`no-cache\`, so a new deploy is picked up on the next visit instead of the browser rendering a stale UI from cache until a hard refresh. Content-hashed \`/assets/*\` are served \`immutable\` and cached long-term.

- Updated dependencies []:
- @executor-js/sdk@1.5.26
- @executor-js/runtime-quickjs@1.5.26
- @executor-js/local@1.4.4
- @executor-js/api@1.4.46
`);

expect(releases).toEqual([
{
version: "1.5.26",
entries: [
{
prNumber: 1221,
prUrl: "https://github.com/RhysSullivan/executor/pull/1221",
body: "Send correct `Cache-Control` headers for the self-hosted web app. The SPA shell (`index.html`) and its client-route fallbacks are now served with `no-cache`, so a new deploy is picked up on the next visit instead of the browser rendering a stale UI from cache until a hard refresh. Content-hashed `/assets/*` are served `immutable` and cached long-term.",
},
],
},
]);
});

it("keeps the PR number but drops a link that is not a pull-request permalink", () => {
const hostile = [
"javascript:alert",
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"https://other.example@github.com/UsefulSoftwareCo/executor/pull/1",
"https://evil.example/UsefulSoftwareCo/executor/pull/1",
"https://github.com/other/executor/pull/1",
"https://github.com.evil.example/UsefulSoftwareCo/executor/pull/1",
"http://github.com/UsefulSoftwareCo/executor/pull/1",
"https://github.com/UsefulSoftwareCo/executor/issues/1",
"https://github.com/UsefulSoftwareCo/executor/pull/1/../../attacker",
];

for (const url of hostile) {
const releases = parseChangelog(
`# executor\n\n## 1.5.30\n\n### Patch Changes\n\n- [#1](${url}) Fix a thing.\n`,
);

expect(releases, url).toEqual([
{ version: "1.5.30", entries: [{ prNumber: 1, body: "Fix a thing." }] },
]);
}
});

it("keeps pull-request permalinks on this repository", () => {
for (const owner of ["UsefulSoftwareCo", "RhysSullivan"]) {
const url = `https://github.com/${owner}/executor/pull/42`;
const releases = parseChangelog(
`# executor\n\n## 1.5.30\n\n### Patch Changes\n\n- [#42](${url}) Fix a thing.\n`,
);

expect(releases, owner).toEqual([
{ version: "1.5.30", entries: [{ prNumber: 42, prUrl: url, body: "Fix a thing." }] },
]);
}
});

it("passes plain entries through and dedents continuation lines", () => {
const releases = parseChangelog(`# executor

## 1.5.0

### Minor Changes

- Integrations and connections rework.

**Highlights**
- Sources are now split into integrations (the API surface) and connections (the credential). One integration can hold many connections — workspace-shared or personal — and each connection gets its own tool catalog.
`);

expect(releases).toEqual([
{
version: "1.5.0",
entries: [
{
body: "Integrations and connections rework.\n\n**Highlights**\n- Sources are now split into integrations (the API surface) and connections (the credential). One integration can hold many connections — workspace-shared or personal — and each connection gets its own tool catalog.",
},
],
},
]);
});
});
Loading
Loading