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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
MDBASE_TS_DIR: .sources/mdbase

- name: Type and content checks
run: pnpm check
run: pnpm test:deploy:dev && pnpm check

- name: Build website
run: pnpm build
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,22 @@ remain traceable to the release artifacts they document.

The first-party contract catalog is built from the commit pinned in
`site-sources.json`, then published as static files under `/contracts/`.

## Development deployment

```sh
pnpm dlx wrangler@4.120.0 login # first use only
pnpm deploy:dev
```

The command builds the current working tree for
`https://mdbase-dev.pages.dev`, imports the specification, checks local links,
and deploys the `main` branch of the standalone `mdbase-dev` Cloudflare Pages
project. It verifies the live homepage, specification, canonical URLs, and
indexing controls after deployment. The production GitHub Pages deployment and
`mdbase.dev` domain are unchanged.

The command uses the existing `mdbase-dev` Cloudflare Pages project, whose
production branch remains `main`. The sibling `mdbase-spec/site/dist` build is
required, as it is for a local production build; `MDBASE_SPEC_DIR` and
`MDBASE_SPEC_SITE_DIST` can point to another prepared checkout or artifact.
5 changes: 3 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

const site = process.env.MDBASE_SITE_ORIGIN ?? "https://mdbase.dev";

export default defineConfig({
site: "https://mdbase.dev",
site,
trailingSlash: "always",
integrations: [sitemap()],
build: {
format: "directory"
}
});

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
"deploy:dev": "node scripts/deploy-pages-dev.mjs",
"check": "astro check",
"preview": "astro preview",
"sync:sources": "node scripts/sync-sources.mjs && node scripts/sync-contracts.mjs",
"sync:contracts": "node scripts/sync-contracts.mjs",
"import:spec": "node scripts/import-spec.mjs",
"check:links": "node scripts/check-links.mjs",
"test": "pnpm check && pnpm build && pnpm import:spec && pnpm check:links"
"test:deploy:dev": "node --test scripts/deploy-pages-dev.test.mjs",
"test": "pnpm test:deploy:dev && pnpm check && pnpm build && pnpm import:spec && pnpm check:links"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.3",
Expand Down
2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
192 changes: 192 additions & 0 deletions scripts/deploy-pages-dev.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { spawn } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

export const developmentDeployment = Object.freeze({
siteOrigin: "https://mdbase-dev.pages.dev",
project: "mdbase-dev",
branch: "main",
wranglerVersion: "4.120.0",
});

const projectRoot = resolve(import.meta.dirname, "..");
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await deployDevelopmentSite(process.env);
}

export function developmentDeploymentEnvironment(environment) {
return {
...environment,
MDBASE_SITE_ORIGIN: developmentDeployment.siteOrigin,
};
}

export async function deployDevelopmentSite(
environment,
dependencies = {
run: runCommand,
prepareBuild: prepareDevelopmentBuild,
verifyBuild: verifyDevelopmentBuild,
verifyDeployment: verifyLiveDeployment,
},
) {
const deploymentEnvironment = developmentDeploymentEnvironment(environment);

await dependencies.run(pnpm, ["build"], deploymentEnvironment);
await dependencies.run(pnpm, ["import:spec"], deploymentEnvironment);
await dependencies.prepareBuild();
await dependencies.run(pnpm, ["check:links"], deploymentEnvironment);
await dependencies.verifyBuild();

await dependencies.run(
pnpm,
[
"dlx",
`wrangler@${developmentDeployment.wranglerVersion}`,
"pages",
"deploy",
"dist",
`--project-name=${developmentDeployment.project}`,
`--branch=${developmentDeployment.branch}`,
"--commit-dirty=true",
],
deploymentEnvironment,
);
await dependencies.verifyDeployment();

console.log(
`Development mdbase.dev deployed: ${developmentDeployment.siteOrigin}/`,
);
}

export async function prepareDevelopmentBuild() {
await Promise.all([
writeFile(
resolve(projectRoot, "dist", "robots.txt"),
"User-agent: *\nDisallow: /\n",
),
writeFile(
resolve(projectRoot, "dist", "_headers"),
"/*\n X-Robots-Tag: noindex, nofollow\n",
),
]);
}

export async function verifyDevelopmentBuild() {
const [homepage, specification, sitemap, robots, headers] = await Promise.all(
[
readFile(resolve(projectRoot, "dist", "index.html"), "utf8"),
readFile(resolve(projectRoot, "dist", "spec", "index.html"), "utf8"),
readFile(resolve(projectRoot, "dist", "sitemap-0.xml"), "utf8"),
readFile(resolve(projectRoot, "dist", "robots.txt"), "utf8"),
readFile(resolve(projectRoot, "dist", "_headers"), "utf8"),
],
);

verifyCanonical(homepage, `${developmentDeployment.siteOrigin}/`, "homepage");
verifyCanonical(
specification,
`${developmentDeployment.siteOrigin}/spec/`,
"specification",
);
if (
!sitemap.includes(`<loc>${developmentDeployment.siteOrigin}/</loc>`) ||
!sitemap.includes(`<loc>${developmentDeployment.siteOrigin}/spec/</loc>`)
) {
throw new Error("Development sitemap does not declare the staging origin.");
}
if (
!robots.includes("Disallow: /") ||
!headers.includes("X-Robots-Tag: noindex")
) {
throw new Error("Development deployment is missing its indexing controls.");
}
}

async function verifyLiveDeployment() {
let lastError;
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const [homepageResponse, specificationResponse, robotsResponse] =
await Promise.all([
fetch(`${developmentDeployment.siteOrigin}/?attempt=${attempt}`, {
cache: "no-store",
}),
fetch(
`${developmentDeployment.siteOrigin}/spec/?attempt=${attempt}`,
{
cache: "no-store",
},
),
fetch(
`${developmentDeployment.siteOrigin}/robots.txt?attempt=${attempt}`,
{
cache: "no-store",
},
),
]);
for (const [label, response] of [
["homepage", homepageResponse],
["specification", specificationResponse],
["robots.txt", robotsResponse],
]) {
if (!response.ok)
throw new Error(`${label} returned HTTP ${response.status}`);
}

verifyCanonical(
await homepageResponse.text(),
`${developmentDeployment.siteOrigin}/`,
"homepage",
);
verifyCanonical(
await specificationResponse.text(),
`${developmentDeployment.siteOrigin}/spec/`,
"specification",
);
if (!(await robotsResponse.text()).includes("Disallow: /")) {
throw new Error("robots.txt does not disallow indexing.");
}
return;
} catch (error) {
lastError = error;
if (attempt < 12) await delay(5_000);
}
}
throw new Error(
`mdbase.dev development deployment verification failed: ${String(lastError)}`,
);
}

function verifyCanonical(document, expected, label) {
if (!document.includes(`<link rel="canonical" href="${expected}">`)) {
throw new Error(`Development ${label} does not declare ${expected}.`);
}
}

async function runCommand(command, arguments_, environment) {
const child = spawn(command, arguments_, {
cwd: projectRoot,
env: environment,
stdio: "inherit",
});
const exitCode = await new Promise((resolveExit, rejectExit) => {
child.once("error", rejectExit);
child.once("exit", (code, signal) => {
if (signal) rejectExit(new Error(`${command} was stopped by ${signal}.`));
else resolveExit(code);
});
});
if (exitCode !== 0) {
throw new Error(
`${command} ${arguments_.join(" ")} exited with code ${exitCode}.`,
);
}
}

function delay(milliseconds) {
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
}
62 changes: 62 additions & 0 deletions scripts/deploy-pages-dev.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
deployDevelopmentSite,
developmentDeployment,
developmentDeploymentEnvironment,
} from "./deploy-pages-dev.mjs";

test("uses the permanent development Pages origin", () => {
assert.deepEqual(developmentDeploymentEnvironment({ EXISTING: "kept" }), {
EXISTING: "kept",
MDBASE_SITE_ORIGIN: "https://mdbase-dev.pages.dev",
});
});

test("builds, checks, and deploys only the Pages production branch", async () => {
const calls = [];
let prepared = false;
let buildVerified = false;
let deploymentVerified = false;

await deployDevelopmentSite(
{},
{
run: async (command, arguments_, environment) => {
calls.push({ command, arguments_, environment });
},
prepareBuild: async () => {
prepared = true;
},
verifyBuild: async () => {
buildVerified = true;
},
verifyDeployment: async () => {
deploymentVerified = true;
},
},
);

assert.equal(prepared, true);
assert.equal(buildVerified, true);
assert.equal(deploymentVerified, true);
assert.deepEqual(
calls.slice(0, 3).map(({ arguments_ }) => arguments_),
[["build"], ["import:spec"], ["check:links"]],
);
assert.ok(
calls[3].arguments_.includes(
`wrangler@${developmentDeployment.wranglerVersion}`,
),
);
assert.ok(calls[3].arguments_.includes("--project-name=mdbase-dev"));
assert.ok(calls[3].arguments_.includes("--branch=main"));
assert.equal(
calls.every(
({ environment }) =>
environment.MDBASE_SITE_ORIGIN === developmentDeployment.siteOrigin,
),
true,
);
});
7 changes: 4 additions & 3 deletions scripts/import-spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const specDir = resolve(process.env.MDBASE_SPEC_DIR ?? join(root, "..", "mdbase-spec"));
const source = resolve(process.env.MDBASE_SPEC_SITE_DIST ?? join(specDir, "site", "dist"));
const destination = join(root, "dist", "spec");
const siteOrigin = new URL(process.env.MDBASE_SITE_ORIGIN ?? "https://mdbase.dev").origin;

required(join(root, "dist", "index.html"), "Build mdbase.dev before importing the specification");
required(join(source, "spec.html"), "Build mdbase-spec/site before importing it");
Expand Down Expand Up @@ -43,8 +44,8 @@ console.log(`Imported specification pages from ${source}`);

function rewrite(html, archive) {
const pageUrl = archive
? "https://mdbase.dev/spec/v0.2/"
: "https://mdbase.dev/spec/";
? `${siteOrigin}/spec/v0.2/`
: `${siteOrigin}/spec/`;
const pageTitle = archive
? "mdbase specification v0.2 archive"
: "mdbase specification v0.3";
Expand Down Expand Up @@ -95,7 +96,7 @@ function addSitemapRoutes() {
required(path, "Build the Astro sitemap before importing the specification");
let sitemap = readFileSync(path, "utf8");
for (const route of ["/spec/", "/spec/v0.2/"]) {
const entry = `<url><loc>https://mdbase.dev${route}</loc></url>`;
const entry = `<url><loc>${siteOrigin}${route}</loc></url>`;
if (!sitemap.includes(entry)) {
sitemap = sitemap.replace("</urlset>", `${entry}</urlset>`);
}
Expand Down
1 change: 1 addition & 0 deletions src/components/SiteFooter.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<div>
<strong>Product</strong>
<a href="/">Overview</a>
<a href="/apps/">Applications</a>
<a href="/downloads/">Downloads</a>
<a href="/connect/">Connect</a>
<a href="/beta/">Connect beta waitlist</a>
Expand Down
5 changes: 3 additions & 2 deletions src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
import BrandMark from "./BrandMark.astro";

interface Props {
current?: "home" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
current?: "home" | "apps" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
}

const { current } = Astro.props;
const links = [
{ href: "/", label: "Overview", key: "home" },
{ href: "/downloads/", label: "Download", key: "downloads" },
{ href: "/connect/", label: "Connect", key: "connect" },
{ href: "/apps/", label: "Applications", key: "apps" },
{ href: "/downloads/", label: "Download", key: "downloads" },
{ href: "/sdk/", label: "Developers", key: "sdk" },
{ href: "/spec/", label: "Specification", key: "spec" }
] as const;
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import ThemeControlScript from "../components/ThemeControlScript.astro";
interface Props {
title: string;
description: string;
current?: "home" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
current?: "home" | "apps" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
noIndex?: boolean;
}

Expand Down
Loading