Step 2: Split Deployments into Nightly, Preview, and Stable

Ben Kunkle created

Change summary

.cloudflare/docs-proxy/src/worker.js             |  11 
.github/workflows/deploy_docs.yml                |  62 ++++
.github/workflows/run_tests.yml                  |   8 
crates/docs_preprocessor/src/main.rs             |   7 
docs/theme/index.hbs                             |   1 
tooling/xtask/src/tasks/workflows/deploy_docs.rs | 235 +++++++++++++++--
6 files changed, 292 insertions(+), 32 deletions(-)

Detailed changes

.cloudflare/docs-proxy/src/worker.js 🔗

@@ -1,8 +1,17 @@
 export default {
   async fetch(request, _env, _ctx) {
     const url = new URL(request.url);
-    url.hostname = "docs-anw.pages.dev";
 
+    let hostname;
+    if (url.pathname.startsWith("/docs/nightly")) {
+      hostname = "docs-nightly.pages.dev";
+    } else if (url.pathname.startsWith("/docs/preview")) {
+      hostname = "docs-preview.pages.dev";
+    } else {
+      hostname = "docs-anw.pages.dev";
+    }
+
+    url.hostname = hostname;
     let res = await fetch(url, request);
 
     if (res.status === 404) {

.github/workflows/deploy_docs.yml 🔗

@@ -5,19 +5,73 @@ on:
   push:
     branches:
     - main
-    - staged-docs-releases
+    - preview
+    - stable
+  workflow_dispatch:
+    inputs:
+      channel:
+        description: 'Docs channel to deploy: nightly, preview, or stable'
+        type: string
+        default: nightly
 jobs:
-  deploy_docs_job:
+  deploy_docs:
     if: github.repository_owner == 'zed-industries'
     name: Build and Deploy Docs
     runs-on: namespace-profile-16x32-ubuntu-2204
     env:
       DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
+      MDBOOK_BOOK__SITE_URL: ${{ steps.resolve-channel.outputs.site_url }}
+      DOCS_CHANNEL: ${{ steps.resolve-channel.outputs.channel }}
     steps:
     - name: steps::checkout_repo
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
       with:
         clean: false
+    - id: resolve-channel
+      name: deploy_docs::resolve_channel_step
+      run: |
+        if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+            CHANNEL="${{ inputs.channel }}"
+        else
+            case "${{ github.ref }}" in
+                "refs/heads/main")
+                    CHANNEL="nightly"
+                    ;;
+                "refs/heads/preview")
+                    CHANNEL="preview"
+                    ;;
+                "refs/heads/stable")
+                    CHANNEL="stable"
+                    ;;
+                *)
+                    echo "::error::Unsupported ref for docs deploy: ${{ github.ref }}"
+                    exit 1
+                    ;;
+            esac
+        fi
+
+        case "$CHANNEL" in
+            "nightly")
+                SITE_URL="/docs/nightly/"
+                PROJECT_NAME="docs-nightly"
+                ;;
+            "preview")
+                SITE_URL="/docs/preview/"
+                PROJECT_NAME="docs-preview"
+                ;;
+            "stable")
+                SITE_URL="/docs/"
+                PROJECT_NAME="docs"
+                ;;
+            *)
+                echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
+                exit 1
+                ;;
+        esac
+
+        echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
+        echo "site_url=$SITE_URL" >> "$GITHUB_OUTPUT"
+        echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
     - name: steps::setup_cargo_config
       run: |
         mkdir -p ./../.cargo
@@ -55,12 +109,12 @@ jobs:
         args: --no-progress --exclude '^http' 'target/deploy/docs'
         fail: true
         jobSummary: false
-    - name: deploy_docs::deploy_docs_to_pages
+    - name: deploy_docs::pages_deploy_step
       uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
       with:
         apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
         accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
-        command: pages deploy target/deploy --project-name=docs
+        command: pages deploy target/deploy --project-name=${{ steps.resolve-channel.outputs.project_name }}
     - name: deploy_docs::deploy_install_script
       uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
       with:

.github/workflows/run_tests.yml 🔗

@@ -505,11 +505,19 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     env:
       DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
+      MDBOOK_BOOK__SITE_URL: ${{ steps.resolve-channel.outputs.site_url }}
+      DOCS_CHANNEL: ${{ steps.resolve-channel.outputs.channel }}
     steps:
     - name: steps::checkout_repo
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
       with:
         clean: false
+    - id: resolve-channel
+      name: deploy_docs::static_channel_resolution_step
+      run: |
+        echo "channel=stable" >> "$GITHUB_OUTPUT"
+        echo "site_url=/docs/" >> "$GITHUB_OUTPUT"
+        echo "project_name=docs" >> "$GITHUB_OUTPUT"
     - name: steps::setup_cargo_config
       run: |
         mkdir -p ./../.cargo

crates/docs_preprocessor/src/main.rs 🔗

@@ -578,6 +578,12 @@ fn handle_postprocessing() -> Result<()> {
         .expect("Default title not a string")
         .to_string();
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
+    let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
+    let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
+        "<meta name=\"robots\" content=\"noindex, nofollow\">"
+    } else {
+        ""
+    };
 
     output.insert("html".to_string(), zed_html);
     mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
@@ -647,6 +653,7 @@ fn handle_postprocessing() -> Result<()> {
         zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
         let contents = contents.replace("#description#", meta_description);
         let contents = contents.replace("#amplitude_key#", &amplitude_key);
+        let contents = contents.replace("#noindex#", noindex);
         let contents = title_regex()
             .replace(&contents, |_: &regex::Captures| {
                 format!("<title>{}</title>", meta_title)

docs/theme/index.hbs 🔗

@@ -30,6 +30,7 @@
         {{#if is_print }}
         <meta name="robots" content="noindex">
         {{/if}}
+        #noindex#
         {{#if base_url}}
         <base href="{{ base_url }}">
         {{/if}}

tooling/xtask/src/tasks/workflows/deploy_docs.rs 🔗

@@ -1,11 +1,43 @@
-use gh_workflow::{Event, Expression, Job, Push, Run, Step, Use, Workflow};
+use gh_workflow::{Event, Expression, Job, Push, Run, Step, Use, Workflow, WorkflowDispatch};
 
 use crate::tasks::workflows::{
-    runners::{self, Platform},
+    runners,
     steps::{self, FluentBuilder as _, NamedJob, named, release_job},
-    vars,
+    vars::{self, StepOutput, WorkflowInput},
 };
 
+pub(crate) enum DocsChannel {
+    Nightly,
+    Preview,
+    Stable,
+}
+
+impl DocsChannel {
+    pub(crate) fn site_url(&self) -> &'static str {
+        match self {
+            Self::Nightly => "/docs/nightly/",
+            Self::Preview => "/docs/preview/",
+            Self::Stable => "/docs/",
+        }
+    }
+
+    pub(crate) fn project_name(&self) -> &'static str {
+        match self {
+            Self::Nightly => "docs-nightly",
+            Self::Preview => "docs-preview",
+            Self::Stable => "docs",
+        }
+    }
+
+    pub(crate) fn channel_name(&self) -> &'static str {
+        match self {
+            Self::Nightly => "nightly",
+            Self::Preview => "preview",
+            Self::Stable => "stable",
+        }
+    }
+}
+
 pub(crate) fn lychee_link_check(dir: &str) -> Step<Use> {
     named::uses(
         "lycheeverse",
@@ -33,7 +65,7 @@ pub(crate) fn build_docs_book() -> Step<Run> {
     "#})
 }
 
-fn deploy_docs_to_pages() -> Step<Use> {
+fn pages_deploy_step(project_name: &StepOutput) -> Step<Use> {
     named::uses(
         "cloudflare",
         "wrangler-action",
@@ -41,7 +73,13 @@ fn deploy_docs_to_pages() -> Step<Use> {
     ) // v3
     .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
     .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
-    .add_with(("command", "pages deploy target/deploy --project-name=docs"))
+    .add_with((
+        "command",
+        format!(
+            "pages deploy target/deploy --project-name=${{{{ {} }}}}",
+            project_name.expr()
+        ),
+    ))
 }
 
 fn deploy_install_script() -> Step<Use> {
@@ -80,54 +118,197 @@ fn upload_wrangler_logs() -> Step<Use> {
     .add_with(("path", "/home/runner/.config/.wrangler/logs/"))
 }
 
-fn docs_build_steps(job: Job) -> Job {
-    job.add_env(("DOCS_AMPLITUDE_API_KEY", vars::DOCS_AMPLITUDE_API_KEY))
-        .runs_on(runners::LINUX_XL)
+fn resolve_channel_step(
+    channel_input: &WorkflowInput,
+) -> (Step<Run>, StepOutput, StepOutput, StepOutput) {
+    let step = named::bash(format!(
+        indoc::indoc! {r#"
+            if [ "${{{{ github.event_name }}}}" = "workflow_dispatch" ]; then
+                CHANNEL="${{{{ {dispatch_channel} }}}}"
+            else
+                case "${{{{ github.ref }}}}" in
+                    "refs/heads/main")
+                        CHANNEL="nightly"
+                        ;;
+                    "refs/heads/preview")
+                        CHANNEL="preview"
+                        ;;
+                    "refs/heads/stable")
+                        CHANNEL="stable"
+                        ;;
+                    *)
+                        echo "::error::Unsupported ref for docs deploy: ${{{{ github.ref }}}}"
+                        exit 1
+                        ;;
+                esac
+            fi
+
+            case "$CHANNEL" in
+                "nightly")
+                    SITE_URL="{nightly_site_url}"
+                    PROJECT_NAME="{nightly_project_name}"
+                    ;;
+                "preview")
+                    SITE_URL="{preview_site_url}"
+                    PROJECT_NAME="{preview_project_name}"
+                    ;;
+                "stable")
+                    SITE_URL="{stable_site_url}"
+                    PROJECT_NAME="{stable_project_name}"
+                    ;;
+                *)
+                    echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
+                    exit 1
+                    ;;
+            esac
+
+            echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
+            echo "site_url=$SITE_URL" >> "$GITHUB_OUTPUT"
+            echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
+        "#},
+        dispatch_channel = channel_input.expr(),
+        nightly_site_url = DocsChannel::Nightly.site_url(),
+        preview_site_url = DocsChannel::Preview.site_url(),
+        stable_site_url = DocsChannel::Stable.site_url(),
+        nightly_project_name = DocsChannel::Nightly.project_name(),
+        preview_project_name = DocsChannel::Preview.project_name(),
+        stable_project_name = DocsChannel::Stable.project_name(),
+    ))
+    .id("resolve-channel");
+
+    let channel = StepOutput::new(&step, "channel");
+    let site_url = StepOutput::new(&step, "site_url");
+    let project_name = StepOutput::new(&step, "project_name");
+    (step, channel, site_url, project_name)
+}
+
+fn static_channel_resolution_step(
+    channel: DocsChannel,
+) -> (Step<Run>, StepOutput, StepOutput, StepOutput) {
+    let (channel_name, site_url, project_name) = match channel {
+        DocsChannel::Nightly => (
+            DocsChannel::Nightly.channel_name(),
+            DocsChannel::Nightly.site_url(),
+            DocsChannel::Nightly.project_name(),
+        ),
+        DocsChannel::Preview => (
+            DocsChannel::Preview.channel_name(),
+            DocsChannel::Preview.site_url(),
+            DocsChannel::Preview.project_name(),
+        ),
+        DocsChannel::Stable => (
+            DocsChannel::Stable.channel_name(),
+            DocsChannel::Stable.site_url(),
+            DocsChannel::Stable.project_name(),
+        ),
+    };
+
+    let step = named::bash(format!(
+        indoc::indoc! {r#"
+            echo "channel={channel_name}" >> "$GITHUB_OUTPUT"
+            echo "site_url={site_url}" >> "$GITHUB_OUTPUT"
+            echo "project_name={project_name}" >> "$GITHUB_OUTPUT"
+        "#},
+        channel_name = channel_name,
+        site_url = site_url,
+        project_name = project_name,
+    ))
+    .id("resolve-channel");
+
+    let channel = StepOutput::new(&step, "channel");
+    let site_url = StepOutput::new(&step, "site_url");
+    let project_name = StepOutput::new(&step, "project_name");
+    (step, channel, site_url, project_name)
+}
+
+fn docs_build_steps(
+    job: Job,
+    resolved_channel_step: Step<Run>,
+    channel: &StepOutput,
+    site_url: &StepOutput,
+    project_name: &StepOutput,
+    include_deploy_steps: bool,
+) -> Job {
+    let mut job = job
+        .add_env(("DOCS_AMPLITUDE_API_KEY", vars::DOCS_AMPLITUDE_API_KEY))
         .add_step(steps::checkout_repo())
-        .add_step(steps::setup_cargo_config(Platform::Linux))
+        .add_step(resolved_channel_step)
+        .add_env(("MDBOOK_BOOK__SITE_URL", site_url.to_string()))
+        .add_env(("DOCS_CHANNEL", channel.to_string()))
+        .runs_on(runners::LINUX_XL)
+        .add_step(steps::setup_cargo_config(runners::Platform::Linux))
         .add_step(steps::cache_rust_dependencies_namespace())
         .map(steps::install_linux_dependencies)
         .add_step(steps::script("./script/generate-action-metadata"))
         .add_step(lychee_link_check("./docs/src/**/*"))
         .add_step(install_mdbook())
         .add_step(build_docs_book())
-        .add_step(lychee_link_check("target/deploy/docs"))
+        .add_step(lychee_link_check("target/deploy/docs"));
+
+    if include_deploy_steps {
+        job = job
+            .add_step(pages_deploy_step(project_name))
+            .add_step(deploy_install_script())
+            .add_step(deploy_docs_worker())
+            .add_step(upload_wrangler_logs());
+    }
+
+    job
 }
 
 pub(crate) fn check_docs() -> NamedJob {
+    let (resolve_step, channel, site_url, project_name) =
+        static_channel_resolution_step(DocsChannel::Stable);
+
     NamedJob {
         name: "check_docs".to_owned(),
-        job: docs_build_steps(release_job(&[])),
+        job: docs_build_steps(
+            release_job(&[]),
+            resolve_step,
+            &channel,
+            &site_url,
+            &project_name,
+            false,
+        ),
     }
 }
 
-pub(crate) fn deploy_docs_job() -> NamedJob {
-    named::job(
-        docs_build_steps(
+pub(crate) fn deploy_docs_job(channel_input: &WorkflowInput) -> NamedJob {
+    let (resolve_step, channel, site_url, project_name) = resolve_channel_step(channel_input);
+
+    NamedJob {
+        name: "deploy_docs".to_owned(),
+        job: docs_build_steps(
             release_job(&[])
                 .name("Build and Deploy Docs")
                 .cond(Expression::new(
                     "github.repository_owner == 'zed-industries'",
                 )),
-        )
-        .add_step(deploy_docs_to_pages())
-        .add_step(deploy_install_script())
-        .add_step(deploy_docs_worker())
-        .add_step(upload_wrangler_logs()),
-    )
+            resolve_step,
+            &channel,
+            &site_url,
+            &project_name,
+            true,
+        ),
+    }
 }
 
 pub(crate) fn deploy_docs() -> Workflow {
-    let deploy_docs = deploy_docs_job();
+    let channel = WorkflowInput::string("channel", Some("nightly".to_string()))
+        .description("Docs channel to deploy: nightly, preview, or stable");
+
+    let deploy_docs = deploy_docs_job(&channel);
 
     named::workflow()
-        .add_event(
-            Event::default().push(
+        .on(Event::default()
+            .push(
                 Push::default()
                     .add_branch("main")
-                    // todo! remove
-                    .add_branch("staged-docs-releases"),
-            ),
-        )
+                    .add_branch("preview")
+                    .add_branch("stable"),
+            )
+            .workflow_dispatch(
+                WorkflowDispatch::default().add_input(channel.name, channel.input()),
+            ))
         .add_job(deploy_docs.name, deploy_docs.job)
 }