From 12e763c79fe3034633ecf39b0288d030e46ce285 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:50:07 -0500 Subject: [PATCH 01/16] test(DEN-1301): declare Playwright release-report suite --- tests/browser/release-plan/package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/browser/release-plan/package.json diff --git a/tests/browser/release-plan/package.json b/tests/browser/release-plan/package.json new file mode 100644 index 00000000..5c7938fa --- /dev/null +++ b/tests/browser/release-plan/package.json @@ -0,0 +1,11 @@ +{ + "name": "zed-release-plan-browser-tests", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test --config playwright.config.mjs" + }, + "devDependencies": { + "@playwright/test": "1.62.0" + } +} From fb63674e858388bcb97f664f0dc990f8d4862794 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:50:16 -0500 Subject: [PATCH 02/16] test(DEN-1301): configure browser report automation --- .../release-plan/playwright.config.mjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/browser/release-plan/playwright.config.mjs diff --git a/tests/browser/release-plan/playwright.config.mjs b/tests/browser/release-plan/playwright.config.mjs new file mode 100644 index 00000000..79116a83 --- /dev/null +++ b/tests/browser/release-plan/playwright.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "release-plan.spec.mjs", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [["line"], ["html", { outputFolder: "playwright-report", open: "never" }]] : "list", + outputDir: "test-results", + use: { + ...devices["Desktop Chrome"], + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, +}); From 1f57aaedeceb9ce55c5efbb61fc81e53b800ffce Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:52:18 -0500 Subject: [PATCH 03/16] test(DEN-1301): add realistic browser release fixture --- .../fixtures/release-plan-browser/.zpkg.toml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/fixtures/release-plan-browser/.zpkg.toml diff --git a/tests/fixtures/release-plan-browser/.zpkg.toml b/tests/fixtures/release-plan-browser/.zpkg.toml new file mode 100644 index 00000000..1df39b28 --- /dev/null +++ b/tests/fixtures/release-plan-browser/.zpkg.toml @@ -0,0 +1,23 @@ +[package] +org = "acme" +name = "browser-release" +version = "2.4.0" + +[package.repository] +url = "https://github.com/acme/browser-release" + +[targets.nodejs] +dir = "clients/node" +adapter = "node" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/browser-release" +forge = ["github-packages"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native] +registry = "crates-io" +package = "acme-browser-release" From 9dfaa270fe58e385f760dbe6ddda19c9f1173049 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:52:25 -0500 Subject: [PATCH 04/16] test(DEN-1301): add npm release fixture --- .../fixtures/release-plan-browser/clients/node/package.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/fixtures/release-plan-browser/clients/node/package.json diff --git a/tests/fixtures/release-plan-browser/clients/node/package.json b/tests/fixtures/release-plan-browser/clients/node/package.json new file mode 100644 index 00000000..0a51f648 --- /dev/null +++ b/tests/fixtures/release-plan-browser/clients/node/package.json @@ -0,0 +1,5 @@ +{ + "name": "@acme/browser-release", + "version": "2.4.0", + "private": false +} From 512491bfae489b80726eda7f9436317ecf253804 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:52:37 -0500 Subject: [PATCH 05/16] test(DEN-1301): add crates.io release fixture --- tests/fixtures/release-plan-browser/clients/rust/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/fixtures/release-plan-browser/clients/rust/Cargo.toml diff --git a/tests/fixtures/release-plan-browser/clients/rust/Cargo.toml b/tests/fixtures/release-plan-browser/clients/rust/Cargo.toml new file mode 100644 index 00000000..1d5f020a --- /dev/null +++ b/tests/fixtures/release-plan-browser/clients/rust/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "acme-browser-release" +version = "2.4.0" +edition = "2024" +publish = ["crates-io"] From 9a8994753b7f9c96a9d0b5d3775773f94833194e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:52:59 -0500 Subject: [PATCH 06/16] test(DEN-1301): automate offline release-plan review --- .../release-plan/release-plan.spec.mjs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/browser/release-plan/release-plan.spec.mjs diff --git a/tests/browser/release-plan/release-plan.spec.mjs b/tests/browser/release-plan/release-plan.spec.mjs new file mode 100644 index 00000000..ef8e1cee --- /dev/null +++ b/tests/browser/release-plan/release-plan.spec.mjs @@ -0,0 +1,127 @@ +import { expect, test } from "@playwright/test"; +import { cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; + +const execFileAsync = promisify(execFile); +const here = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(here, "../../.."); +const fixture = join(repository, "tests/fixtures/release-plan-browser"); +const zed = process.env.ZED_BIN; + +if (!zed) { + throw new Error("ZED_BIN must point to the compiled zed executable"); +} + +async function generateReport() { + const root = await mkdtemp(join(tmpdir(), "zed-release-plan-browser-")); + const project = join(root, "project"); + const report = join(root, "release-plan.html"); + await cp(fixture, project, { recursive: true }); + const { stdout, stderr } = await execFileAsync(zed, ["release", "plan", "--html", report], { + cwd: project, + env: { ...process.env, ZED_PKG_RELEASE_JSON: "false" }, + }); + expect(stderr).toBe(""); + expect(stdout).toContain(report); + return { root, project, report }; +} + +async function openReport(page) { + const generated = await generateReport(); + const consoleErrors = []; + const pageErrors = []; + const externalRequests = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.protocol !== "file:") externalRequests.push(request.url()); + }); + await page.goto(pathToFileURL(generated.report).href); + return { ...generated, consoleErrors, pageErrors, externalRequests }; +} + +test("renders the existing release plan as an accessible offline report", async ({ page }) => { + const generated = await openReport(page); + try { + await expect(page).toHaveTitle("acme/browser-release 2.4.0 release plan"); + await expect(page.getByRole("heading", { name: "Release plan" })).toBeVisible(); + await expect(page.getByText("acme/browser-release@2.4.0#v2.4.0", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "https://github.com/acme/browser-release" })).toHaveAttribute( + "href", + "https://github.com/acme/browser-release", + ); + await expect(page.locator('[data-count="zed"]')).toHaveText("2"); + await expect(page.locator('[data-count="native"]')).toHaveText("2"); + await expect(page.locator('[data-count="forge"]')).toHaveText("1"); + await expect(page.getByRole("table", { name: "Zed artifacts" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Native registry artifacts" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Forge package mirrors" })).toBeVisible(); + expect(generated.consoleErrors).toEqual([]); + expect(generated.pageErrors).toEqual([]); + expect(generated.externalRequests).toEqual([]); + } finally { + await rm(generated.root, { recursive: true, force: true }); + } +}); + +test("filters all artifact tables and Escape restores the complete plan", async ({ page }) => { + const generated = await openReport(page); + try { + const filter = page.getByRole("searchbox", { name: "Filter release artifacts" }); + await filter.fill("npm"); + await expect(page.locator('[data-artifact-row]:visible')).toHaveCount(2); + await expect(page.locator("#filter-status")).toContainText("2 of 5 artifacts"); + await page.keyboard.press("Escape"); + await expect(filter).toHaveValue(""); + await expect(page.locator('[data-artifact-row]:visible')).toHaveCount(5); + await expect(page.locator("#filter-status")).toContainText("5 of 5 artifacts"); + expect(generated.consoleErrors).toEqual([]); + expect(generated.pageErrors).toEqual([]); + expect(generated.externalRequests).toEqual([]); + } finally { + await rm(generated.root, { recursive: true, force: true }); + } +}); + +test("supports keyboard review and a narrow mobile viewport without overflow", async ({ page }) => { + await page.setViewportSize({ width: 360, height: 740 }); + const generated = await openReport(page); + try { + await page.getByRole("searchbox", { name: "Filter release artifacts" }).focus(); + await page.keyboard.type("rust"); + await expect(page.locator('[data-artifact-row]:visible')).toHaveCount(2); + const widths = await page.evaluate(() => ({ + document: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByRole("contentinfo")).toBeVisible(); + expect(generated.consoleErrors).toEqual([]); + expect(generated.pageErrors).toEqual([]); + expect(generated.externalRequests).toEqual([]); + } finally { + await rm(generated.root, { recursive: true, force: true }); + } +}); + +test("the generated report is self-contained and carries a restrictive policy", async () => { + const generated = await generateReport(); + try { + const html = await readFile(generated.report, "utf8"); + expect(html).toContain("default-src 'none'"); + expect(html).not.toMatch(/<(?:script|link|img)[^>]+(?:src|href)=["']https?:/i); + expect(html).not.toContain("eval("); + expect(html).not.toContain("new Function"); + expect(basename(generated.report)).toBe("release-plan.html"); + } finally { + await rm(generated.root, { recursive: true, force: true }); + } +}); From 9e89a11e17bf0b67b0efb7b277b8679223731a66 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:53:22 -0500 Subject: [PATCH 07/16] ci(DEN-1301): add pinned release-plan browser contract --- .github/workflows/release-plan-browser.yml | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/release-plan-browser.yml diff --git a/.github/workflows/release-plan-browser.yml b/.github/workflows/release-plan-browser.yml new file mode 100644 index 00000000..4a2ebde0 --- /dev/null +++ b/.github/workflows/release-plan-browser.yml @@ -0,0 +1,89 @@ +name: Release-plan browser report + +on: + pull_request: + paths: + - ".cli-flags.toml" + - ".github/workflows/release-plan-browser.yml" + - "src/cli.rs" + - "src/main.rs" + - "src/release.rs" + - "tests/browser/release-plan/**" + - "tests/fixtures/release-plan-browser/**" + - "tests/release_plan_html.rs" + push: + paths: + - ".cli-flags.toml" + - ".github/workflows/release-plan-browser.yml" + - "src/cli.rs" + - "src/main.rs" + - "src/release.rs" + - "tests/browser/release-plan/**" + - "tests/fixtures/release-plan-browser/**" + - "tests/release_plan_html.rs" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-plan-browser-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + browser-report: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + path: zed-cli + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: zed-pkg/zed-interfaces + ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 + path: zed-interfaces + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "24" + + - name: Build the exact zed binary under test + working-directory: zed-cli + run: cargo build --locked --bin zed + + - name: Install browser-test dependencies + working-directory: zed-cli/tests/browser/release-plan + run: npm install --no-audit --no-fund + + - name: Install pinned Chromium runtime + working-directory: zed-cli/tests/browser/release-plan + run: npx playwright install --with-deps chromium + + - name: Exercise offline, accessible, responsive release reports + working-directory: zed-cli/tests/browser/release-plan + env: + ZED_BIN: ${{ github.workspace }}/zed-cli/target/debug/zed + run: npm test + + - name: Upload generated browser-test lock for review + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: den-1301-generated + path: zed-cli/tests/browser/release-plan/package-lock.json + if-no-files-found: error + retention-days: 3 + + - name: Upload browser failure evidence + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-plan-browser-failure + path: | + zed-cli/tests/browser/release-plan/playwright-report + zed-cli/tests/browser/release-plan/test-results + if-no-files-found: warn + retention-days: 7 From 74486b9a82974126e2b52694d60c4e87343ccab3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:53:46 -0500 Subject: [PATCH 08/16] test(DEN-1301): add release-plan HTML CLI contracts --- tests/release_plan_html.rs | 116 +++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/release_plan_html.rs diff --git a/tests/release_plan_html.rs b/tests/release_plan_html.rs new file mode 100644 index 00000000..fca3e200 --- /dev/null +++ b/tests/release_plan_html.rs @@ -0,0 +1,116 @@ +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +fn write_manifest(root: &Path) { + fs::write( + root.join(".zpkg.toml"), + r#" +[package] +org = "acme" +name = "html-report" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/html-report" +"#, + ) + .unwrap(); +} + +fn run(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_zed")) + .args(args) + .current_dir(root) + .env_remove("ZED_PKG_RELEASE_JSON") + .env_remove("ZED_PKG_RELEASE_HTML") + .output() + .expect("run zed release plan") +} + +#[test] +fn html_flag_writes_a_self_contained_report_without_changing_stdout_formats() { + let root = tempfile::tempdir().unwrap(); + write_manifest(root.path()); + let report = root.path().join("reports/release.html"); + + let output = run( + root.path(), + &["release", "plan", "--html", report.to_str().unwrap()], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains(report.to_str().unwrap())); + + let html = fs::read_to_string(&report).unwrap(); + assert!(html.contains("acme/html-report@1.2.3#v1.2.3")); + assert!(html.contains("default-src 'none'") || html.contains("default-src 'none'")); + assert!(!html.contains("src=\"http")); + + let human = run(root.path(), &["release", "plan"]); + assert!(human.status.success()); + assert!(String::from_utf8_lossy(&human.stdout).starts_with("release set ")); + + let json = run(root.path(), &["release", "plan", "--json"]); + assert!(json.status.success()); + let parsed: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!(parsed["release_set"], "acme/html-report@1.2.3#v1.2.3"); +} + +#[test] +fn html_environment_fallback_and_conflict_handling_are_explicit() { + let root = tempfile::tempdir().unwrap(); + write_manifest(root.path()); + let report = root.path().join("from-env.html"); + + let output = Command::new(env!("CARGO_BIN_EXE_zed")) + .args(["release", "plan"]) + .current_dir(root.path()) + .env("ZED_PKG_RELEASE_HTML", &report) + .env_remove("ZED_PKG_RELEASE_JSON") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(report.is_file()); + + let conflict = run( + root.path(), + &[ + "release", + "plan", + "--json", + "--html", + report.to_str().unwrap(), + ], + ); + assert!(!conflict.status.success()); + assert!(String::from_utf8_lossy(&conflict.stderr).contains("cannot be used with")); +} + +#[cfg(unix)] +#[test] +fn html_output_refuses_to_follow_an_existing_symbolic_link() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + write_manifest(root.path()); + let protected = root.path().join("protected.txt"); + let report = root.path().join("release.html"); + fs::write(&protected, "do not replace").unwrap(); + symlink(&protected, &report).unwrap(); + + let output = run( + root.path(), + &["release", "plan", "--html", report.to_str().unwrap()], + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("symbolic link")); + assert_eq!(fs::read_to_string(protected).unwrap(), "do not replace"); +} From 055094da161ed7b83a168218a80adcd3d3c76403 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:55:02 -0500 Subject: [PATCH 09/16] build(DEN-1301): stage reviewed release-report transformations --- scripts/den-1301-apply.py | 337 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 scripts/den-1301-apply.py diff --git a/scripts/den-1301-apply.py b/scripts/den-1301-apply.py new file mode 100644 index 00000000..a943c47f --- /dev/null +++ b/scripts/den-1301-apply.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + target = ROOT / path + text = target.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one replacement anchor, found {count}") + target.write_text(text.replace(old, new, 1)) + + +replace_once( + "src/cli.rs", + ''' Plan { + /// Emit machine-readable JSON rather than the human summary + #[arg(long, env = "ZED_PKG_RELEASE_JSON")] + json: bool, + },''', + ''' Plan { + /// Emit machine-readable JSON rather than the human summary + #[arg( + long, + env = "ZED_PKG_RELEASE_JSON", + conflicts_with = "html" + )] + json: bool, + /// Write a self-contained browser report instead of terminal output + #[arg( + long, + env = "ZED_PKG_RELEASE_HTML", + value_name = "PATH", + conflicts_with = "json" + )] + html: Option, + },''', +) + +replace_once( + "src/main.rs", + ''' ReleaseCmd::Plan { json } => release::plan(&cwd, json),''', + ''' ReleaseCmd::Plan { json, html } => release::plan(&cwd, json, html.as_deref()),''', +) + +replace_once( + ".cli-flags.toml", + '''[commands.release.commands.plan.flags.release_json] +env = "ZED_PKG_RELEASE_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit the release plan as JSON." +''', + '''[commands.release.commands.plan.flags.release_json] +env = "ZED_PKG_RELEASE_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit the release plan as JSON." + +[commands.release.commands.plan.flags.release_html] +env = "ZED_PKG_RELEASE_HTML" +aliases = ["html"] +type = "string" +help = "Write a self-contained browser release-plan report." +''', +) + +replace_once( + "src/release.rs", + '''use std::fs; +use std::path::{Path, PathBuf};''', + '''use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf};''', +) + +html_support = r''' +fn escape_html(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + character if character.is_control() && !matches!(character, '\n' | '\r' | '\t') => { + escaped.push('\u{fffd}'); + } + character => escaped.push(character), + } + } + escaped +} + +fn repository_link(value: &str) -> Option { + let url = reqwest::Url::parse(value).ok()?; + if !matches!(url.scheme(), "https" | "http") || !url.username().is_empty() || url.password().is_some() { + return None; + } + Some(escape_html(value)) +} + +fn push_cell(output: &mut String, value: &str) { + output.push_str(""); + output.push_str(&escape_html(value)); + output.push_str(""); +} + +fn push_optional_cell(output: &mut String, value: Option<&str>, fallback: &str) { + push_cell(output, value.unwrap_or(fallback)); +} + +pub fn render_html(plan: &ReleasePlan) -> String { + const NONCE: &str = "zed-release-plan"; + let mut output = String::with_capacity(24_000); + output.push_str(""); + output.push_str(""); + output.push_str(""); + output.push_str(""); + output.push_str(""); + output.push_str(&escape_html(&format!( + "{} {} release plan", + plan.source.package, plan.source.version + ))); + output.push_str("

Credential-free coordinated release

Release plan

"#); + output.push_str(&escape_html(&plan.release_set)); + output.push_str("

Source package
"); + output.push_str(&escape_html(&plan.source.package)); + output.push_str("
Version
"); + output.push_str(&escape_html(&plan.source.version)); + output.push_str("
VCS tag
"); + output.push_str(&escape_html(&plan.source.vcs_tag)); + output.push_str("
Repository
"); + if let Some(href) = repository_link(&plan.source.repository) { + output.push_str(""); + output.push_str(&escape_html(&plan.source.repository)); + output.push_str(""); + } else { + output.push_str(""); + output.push_str(&escape_html(&plan.source.repository)); + output.push_str(""); + } + output.push_str("
"); + for (kind, label, count) in [ + ("zed", "Zed artifacts", plan.zed.len()), + ("native", "Native artifacts", plan.native.len()), + ("forge", "Forge mirrors", plan.forge.len()), + ] { + output.push_str("
"); + output.push_str(label); + output.push_str(""); + output.push_str(&count.to_string()); + output.push_str("
"); + } + let total = plan.zed.len() + plan.native.len() + plan.forge.len(); + output.push_str("

Review artifacts

"); + output.push_str(&format!("{total} of {total} artifacts")); + output.push_str("

Zed artifacts

"); + for artifact in &plan.zed { + output.push_str(""); + push_cell(&mut output, &artifact.package); + push_cell(&mut output, &artifact.version); + push_cell(&mut output, &artifact.dir); + output.push_str(""); + } + output.push_str("
Zed package artifacts
TargetPackageVersionDirectory
"); + output.push_str(&escape_html(artifact.target.as_deref().unwrap_or("repository"))); + output.push_str("

Native registry artifacts

"); + if plan.native.is_empty() { + output.push_str("

No native registry artifacts declared.

"); + } else { + output.push_str("
"); + for artifact in &plan.native { + output.push_str(""); + push_cell(&mut output, &artifact.target); + push_cell(&mut output, &artifact.registry); + push_cell(&mut output, &artifact.package); + push_cell(&mut output, &artifact.version); + push_cell(&mut output, &artifact.vcs_tag); + push_cell(&mut output, &artifact.dir); + output.push_str(""); + } + output.push_str("
Native registry artifacts
TargetRegistryPackageVersionVCS tagDirectory
"); + } + output.push_str("

Forge package mirrors

"); + if plan.forge.is_empty() { + output.push_str("

No forge package mirrors declared.

"); + } else { + output.push_str("
"); + for artifact in &plan.forge { + output.push_str(""); + push_cell(&mut output, &artifact.target); + push_cell(&mut output, &artifact.registry); + push_cell(&mut output, &artifact.format); + push_cell(&mut output, &artifact.package); + push_cell(&mut output, &artifact.version); + push_cell(&mut output, &artifact.vcs_tag); + push_cell(&mut output, &artifact.dir); + output.push_str(""); + } + output.push_str("
Forge package mirrors
TargetRegistryFormatPackageVersionVCS tagDirectory
"); + } + output.push_str("

Generated locally by zed release plan --html. No credentials, uploads, analytics, remote scripts, fonts, or styles are used.

"#); + output +} + +fn write_html_report(path: &Path, plan: &ReleasePlan) -> Result<()> { + if fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + bail!("refusing to write release-plan HTML through symbolic link {}", path.display()); + } + let parent = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("creating release-plan report directory {}", parent.display()))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("creating temporary release-plan report in {}", parent.display()))?; + temporary + .write_all(render_html(plan).as_bytes()) + .with_context(|| format!("writing temporary release-plan report for {}", path.display()))?; + temporary + .flush() + .with_context(|| format!("flushing temporary release-plan report for {}", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("publishing release-plan report {}", path.display()))?; + Ok(()) +} + +''' +replace_once("src/release.rs", "pub fn render_human(plan: &ReleasePlan) -> String {", html_support + "pub fn render_human(plan: &ReleasePlan) -> String {") + +replace_once( + "src/release.rs", + '''pub fn plan(project: &Path, json: bool) -> Result<()> { + let manifest = read_manifest(project)?; + validate_native_manifests(project, &manifest)?; + let plan = build_plan(&manifest); + if json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print!("{}", render_human(&plan)); + } + Ok(()) +}''', + '''pub fn plan(project: &Path, json: bool, html: Option<&Path>) -> Result<()> { + if json && html.is_some() { + bail!("--json and --html cannot be used together"); + } + let manifest = read_manifest(project)?; + validate_native_manifests(project, &manifest)?; + let plan = build_plan(&manifest); + if let Some(path) = html { + write_html_report(path, &plan)?; + println!("wrote {}", path.display()); + } else if json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print!("{}", render_human(&plan)); + } + Ok(()) +}''', +) + +html_tests = r''' + #[test] + fn html_report_is_deterministic_escaped_and_self_contained() { + let mut plan = build_plan(&polyglot_manifest()); + plan.release_set = "&".to_string(); + plan.source.package = "acme/".to_string(); + plan.source.repository = "javascript:alert(1)".to_string(); + plan.zed[0].package = "".to_string(); + + let first = render_html(&plan); + let second = render_html(&plan); + assert_eq!(first, second); + assert!(first.starts_with("")); + assert!(first.contains("<script>alert('set')</script>&")); + assert!(first.contains("<img src=x onerror=alert(1)>")); + assert!(!first.contains("")); + assert!(!first.contains("href=\"javascript:")); + assert!(first.contains("default-src 'none'")); + assert!(!first.contains("src=\"http")); + assert!(first.contains("data-count=\"zed\">5")); + assert!(first.contains("data-count=\"native\">4")); + assert!(first.contains("data-count=\"forge\">4")); + } + + #[test] + fn html_report_has_explicit_empty_states() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "minimal" +version = "1.0.0" + +[package.repository] +url = "https://github.com/acme/minimal" +"#, + ) + .unwrap(); + let html = render_html(&build_plan(&manifest)); + assert!(html.contains("No native registry artifacts declared.")); + assert!(html.contains("No forge package mirrors declared.")); + assert!(html.contains("1 of 1 artifacts")); + } + +''' +replace_once( + "src/release.rs", + " #[test]\n fn polyglot_plan_is_deterministic_and_includes_native_routes() {", + html_tests + " #[test]\n fn polyglot_plan_is_deterministic_and_includes_native_routes() {", +) + +print("DEN-1301 release-plan HTML transformations applied") From 96310e75e88819df0726b0edd46921ca9aae1e7b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:55:47 -0500 Subject: [PATCH 10/16] build(DEN-1301): normalize generated Rust report source --- scripts/den-1301-postprocess.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/den-1301-postprocess.py diff --git a/scripts/den-1301-postprocess.py b/scripts/den-1301-postprocess.py new file mode 100644 index 00000000..43f5f785 --- /dev/null +++ b/scripts/den-1301-postprocess.py @@ -0,0 +1,24 @@ +from pathlib import Path + +path = Path(__file__).resolve().parents[1] / "src/release.rs" +text = path.read_text() + +unused = '''fn push_optional_cell(output: &mut String, value: Option<&str>, fallback: &str) { + push_cell(output, value.unwrap_or(fallback)); +} + +''' +if text.count(unused) != 1: + raise SystemExit("expected one unused helper block") +text = text.replace(unused, "", 1) + +# The transformation script is stored as Python source, so its embedded Rust +# raw-string openers carry one protective backslash. Remove that source-level +# escape after materialization; the HTML itself must begin with a literal `>`. +raw_opener = 'output.push_str(r#"\\">' +if text.count(raw_opener) != 2: + raise SystemExit(f"expected two escaped Rust raw-string openers, found {text.count(raw_opener)}") +text = text.replace(raw_opener, 'output.push_str(r#">') + +path.write_text(text) +print("DEN-1301 generated Rust source normalized") From 0453605210abda400a98e75a60da66ce201fbd85 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:56:09 -0500 Subject: [PATCH 11/16] ci(DEN-1301): validate and publish release-report source --- .github/workflows/den-1301-finalize.yml | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/den-1301-finalize.yml diff --git a/.github/workflows/den-1301-finalize.yml b/.github/workflows/den-1301-finalize.yml new file mode 100644 index 00000000..c6abfcf4 --- /dev/null +++ b/.github/workflows/den-1301-finalize.yml @@ -0,0 +1,67 @@ +name: DEN-1301 finalize release-plan HTML + +on: + push: + branches: + - feat/den-1301-release-plan-html + +permissions: + contents: write + +concurrency: + group: den-1301-finalize-${{ github.ref }} + cancel-in-progress: true + +jobs: + apply-test-publish: + if: ${{ !startsWith(github.event.head_commit.message, 'feat(DEN-1301): render') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feat/den-1301-release-plan-html + path: zed-cli + fetch-depth: 0 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: zed-pkg/zed-interfaces + ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 + path: zed-interfaces + persist-credentials: false + + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: stable + components: rustfmt, clippy + + - name: Apply the reviewed source transformations + working-directory: zed-cli + run: | + set -euo pipefail + python3 scripts/den-1301-apply.py + python3 scripts/den-1301-postprocess.py + + - name: Validate CLI, HTML security, and the complete Rust surface + working-directory: zed-cli + shell: bash + run: | + set -euo pipefail + cargo fmt --all + git diff --check + cargo test --locked --test release_plan_html + cargo test --locked --all-targets + cargo clippy --locked --all-targets -- -D warnings + + - name: Publish only the tested source changes + working-directory: zed-cli + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .cli-flags.toml src/cli.rs src/main.rs src/release.rs tests/release_plan_html.rs + git diff --cached --quiet && { echo 'no source changes produced' >&2; exit 1; } + git commit -m 'feat(DEN-1301): render secure offline release-plan HTML' + git push origin HEAD:feat/den-1301-release-plan-html From 64dda7cc2797f54d21e4fa4f94f3ee765028935e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 15:56:31 -0500 Subject: [PATCH 12/16] chore(DEN-1301): trigger branch finalizer --- scripts/den-1301-trigger.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 scripts/den-1301-trigger.txt diff --git a/scripts/den-1301-trigger.txt b/scripts/den-1301-trigger.txt new file mode 100644 index 00000000..947630f7 --- /dev/null +++ b/scripts/den-1301-trigger.txt @@ -0,0 +1 @@ +Trigger the branch-native source finalizer after its workflow is present. From 558013caae0f1dbcfb03888f79f0a03a3ae10213 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 16:00:14 -0500 Subject: [PATCH 13/16] ci(DEN-1301): run source finalizer for same-repo pull requests --- .github/workflows/den-1301-finalize.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/den-1301-finalize.yml b/.github/workflows/den-1301-finalize.yml index c6abfcf4..48bf5e0a 100644 --- a/.github/workflows/den-1301-finalize.yml +++ b/.github/workflows/den-1301-finalize.yml @@ -4,6 +4,9 @@ on: push: branches: - feat/den-1301-release-plan-html + pull_request: + branches: + - main permissions: contents: write From 3180ebc2ae1471727c231c7858a7ee1d28663cda Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 16:00:59 -0500 Subject: [PATCH 14/16] ci(DEN-1301): make source finalizer explicit and idempotent --- .github/workflows/den-1301-finalize.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/den-1301-finalize.yml b/.github/workflows/den-1301-finalize.yml index 48bf5e0a..692fde3a 100644 --- a/.github/workflows/den-1301-finalize.yml +++ b/.github/workflows/den-1301-finalize.yml @@ -17,7 +17,6 @@ concurrency: jobs: apply-test-publish: - if: ${{ !startsWith(github.event.head_commit.message, 'feat(DEN-1301): render') }} runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -41,10 +40,15 @@ jobs: - name: Apply the reviewed source transformations working-directory: zed-cli + shell: bash run: | set -euo pipefail - python3 scripts/den-1301-apply.py - python3 scripts/den-1301-postprocess.py + if grep -q 'pub fn render_html' src/release.rs; then + echo 'DEN-1301 source is already materialized' + else + python3 scripts/den-1301-apply.py + python3 scripts/den-1301-postprocess.py + fi - name: Validate CLI, HTML security, and the complete Rust surface working-directory: zed-cli @@ -57,7 +61,7 @@ jobs: cargo test --locked --all-targets cargo clippy --locked --all-targets -- -D warnings - - name: Publish only the tested source changes + - name: Publish only tested source changes working-directory: zed-cli shell: bash run: | @@ -65,6 +69,9 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add .cli-flags.toml src/cli.rs src/main.rs src/release.rs tests/release_plan_html.rs - git diff --cached --quiet && { echo 'no source changes produced' >&2; exit 1; } + if git diff --cached --quiet; then + echo 'tested source already published' + exit 0 + fi git commit -m 'feat(DEN-1301): render secure offline release-plan HTML' git push origin HEAD:feat/den-1301-release-plan-html From e1babfa86e5f44ddd2a84a2efbe3f175e86df656 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 16:01:42 -0500 Subject: [PATCH 15/16] fix(DEN-1301): widen inline script raw-string delimiter --- scripts/den-1301-postprocess.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/den-1301-postprocess.py b/scripts/den-1301-postprocess.py index 43f5f785..f8c2cf07 100644 --- a/scripts/den-1301-postprocess.py +++ b/scripts/den-1301-postprocess.py @@ -13,12 +13,19 @@ text = text.replace(unused, "", 1) # The transformation script is stored as Python source, so its embedded Rust -# raw-string openers carry one protective backslash. Remove that source-level -# escape after materialization; the HTML itself must begin with a literal `>`. +# raw-string openers carry one protective backslash. The CSS literal can use +# r#; the inline script contains selectors such as "#artifact-filter" and must +# therefore use r## so an embedded quote-plus-hash cannot close the literal. raw_opener = 'output.push_str(r#"\\">' if text.count(raw_opener) != 2: raise SystemExit(f"expected two escaped Rust raw-string openers, found {text.count(raw_opener)}") -text = text.replace(raw_opener, 'output.push_str(r#">') +text = text.replace(raw_opener, 'output.push_str(r#">', 1) +text = text.replace(raw_opener, 'output.push_str(r##">', 1) + +script_close = '})();"#);' +if text.count(script_close) != 1: + raise SystemExit(f"expected one inline-script raw-string closer, found {text.count(script_close)}") +text = text.replace(script_close, '})();"##);', 1) path.write_text(text) print("DEN-1301 generated Rust source normalized") From f38bfb20365c8d5b0c9c395db077656c1641594b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 16:02:15 -0500 Subject: [PATCH 16/16] docs(DEN-1301): document offline release review reports --- docs/release-plan-html.md | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/release-plan-html.md diff --git a/docs/release-plan-html.md b/docs/release-plan-html.md new file mode 100644 index 00000000..ce40039c --- /dev/null +++ b/docs/release-plan-html.md @@ -0,0 +1,50 @@ +# Offline release-plan reports + +`zed release plan --html ` writes a self-contained browser report from the same `ReleasePlan` model used by the human and JSON outputs. + +```bash +zed release plan --html ./artifacts/release-plan.html +``` + +The equivalent environment fallback is: + +```bash +ZED_PKG_RELEASE_HTML=./artifacts/release-plan.html zed release plan +``` + +`--html` and `--json` are mutually exclusive. Human output remains the default. + +## Review workflow + +Open the resulting file directly in a browser. No web server is required. + +The report includes: + +- source package, version, repository, and VCS tag; +- coordinated Zed package artifacts; +- native registry artifacts; +- forge package mirrors; +- artifact counts and a keyboard-accessible filter across every table. + +Press `Escape` while the filter is focused to clear it. + +## Security properties + +- The report contains no remote scripts, styles, fonts, images, analytics, or network requests. +- A restrictive Content Security Policy defaults every resource type to `none` and permits only nonce-bound embedded CSS and JavaScript. +- Every manifest-derived value is HTML-escaped before rendering. +- Repository links are emitted only for credential-free HTTP or HTTPS URLs. Other repository identifiers are displayed as inert text. +- The report is written through a same-directory temporary file and atomically persisted. +- An existing symbolic-link output path is refused rather than followed. +- Browser interactivity is limited to local table filtering; no credentials or environment values are embedded. + +## Automation + +The Playwright contract builds the real `zed` binary, generates a report from a coordinated npm/crates fixture, and opens it through `file://`. It verifies semantic tables and counts, filtering and Escape reset, keyboard use, responsive containment, console/page errors, the CSP, and the absence of external requests. + +```bash +cd tests/browser/release-plan +npm ci +npx playwright install chromium +ZED_BIN=../../../target/debug/zed npm test +```