deploy_docs.rs

  1use gh_workflow::{
  2    Event, Expression, Job, Run, Step, Use, Workflow, WorkflowCall, WorkflowCallSecret,
  3    WorkflowDispatch,
  4};
  5
  6use crate::tasks::workflows::{
  7    runners,
  8    steps::{self, FluentBuilder as _, NamedJob, named, release_job},
  9    vars::{self, StepOutput, WorkflowInput},
 10};
 11
 12const BUILD_OUTPUT_DIR: &str = "target/deploy";
 13
 14pub(crate) enum DocsChannel {
 15    Nightly,
 16    Preview,
 17    Stable,
 18}
 19
 20impl DocsChannel {
 21    pub(crate) fn site_url(&self) -> &'static str {
 22        match self {
 23            Self::Nightly => "/docs/nightly/",
 24            Self::Preview => "/docs/preview/",
 25            Self::Stable => "/docs/",
 26        }
 27    }
 28
 29    pub(crate) fn project_name(&self) -> &'static str {
 30        match self {
 31            Self::Nightly => "docs-nightly",
 32            Self::Preview => "docs-preview",
 33            Self::Stable => "docs",
 34        }
 35    }
 36
 37    pub(crate) fn channel_name(&self) -> &'static str {
 38        match self {
 39            Self::Nightly => "nightly",
 40            Self::Preview => "preview",
 41            Self::Stable => "stable",
 42        }
 43    }
 44}
 45
 46pub(crate) fn lychee_link_check(dir: &str) -> Step<Use> {
 47    named::uses(
 48        "lycheeverse",
 49        "lychee-action",
 50        "82202e5e9c2f4ef1a55a3d02563e1cb6041e5332",
 51    ) // v2.4.1
 52    .add_with(("args", format!("--no-progress --exclude '^http' '{dir}'")))
 53    .add_with(("fail", true))
 54    .add_with(("jobSummary", false))
 55}
 56
 57pub(crate) fn install_mdbook() -> Step<Use> {
 58    named::uses(
 59        "peaceiris",
 60        "actions-mdbook",
 61        "ee69d230fe19748b7abf22df32acaa93833fad08", // v2
 62    )
 63    .with(("mdbook-version", "0.4.37"))
 64}
 65
 66pub(crate) fn build_docs_book(docs_channel: String, site_url: String) -> Step<Run> {
 67    named::bash(indoc::formatdoc! {r#"
 68        mkdir -p {BUILD_OUTPUT_DIR}
 69        mdbook build ./docs --dest-dir=../{BUILD_OUTPUT_DIR}/docs/
 70    "#})
 71    .add_env(("DOCS_CHANNEL", docs_channel))
 72    .add_env(("MDBOOK_BOOK__SITE_URL", site_url))
 73}
 74
 75fn docs_build_steps(
 76    job: Job,
 77    checkout_ref: Option<String>,
 78    docs_channel: impl Into<String>,
 79    site_url: impl Into<String>,
 80) -> Job {
 81    let docs_channel = docs_channel.into();
 82    let site_url = site_url.into();
 83
 84    steps::use_clang(
 85        job.add_env(("DOCS_AMPLITUDE_API_KEY", vars::DOCS_AMPLITUDE_API_KEY))
 86            .add_step(
 87                steps::checkout_repo().when_some(checkout_ref, |step, checkout_ref| {
 88                    step.with_ref(checkout_ref)
 89                }),
 90            )
 91            .runs_on(runners::LINUX_XL)
 92            .add_step(steps::setup_cargo_config(runners::Platform::Linux))
 93            .add_step(steps::cache_rust_dependencies_namespace())
 94            .map(steps::install_linux_dependencies)
 95            .add_step(steps::script("./script/generate-action-metadata"))
 96            .add_step(lychee_link_check("./docs/src/**/*"))
 97            .add_step(install_mdbook())
 98            .add_step(build_docs_book(
 99                docs_channel.to_string(),
100                site_url.to_string(),
101            ))
102            .add_step(lychee_link_check(&format!("{BUILD_OUTPUT_DIR}/docs"))),
103    )
104}
105
106fn docs_deploy_steps(job: Job, project_name: &StepOutput) -> Job {
107    fn deploy_to_cf_pages(project_name: &StepOutput) -> Step<Use> {
108        named::uses(
109            "cloudflare",
110            "wrangler-action",
111            "da0e0dfe58b7a431659754fdf3f186c529afbe65",
112        ) // v3
113        .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
114        .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
115        .add_with((
116            "command",
117            format!(
118                "pages deploy {BUILD_OUTPUT_DIR} --project-name=${{{{ {} }}}} --branch main",
119                project_name.expr()
120            ),
121        ))
122    }
123
124    fn upload_install_script() -> Step<Use> {
125        named::uses(
126            "cloudflare",
127            "wrangler-action",
128            "da0e0dfe58b7a431659754fdf3f186c529afbe65",
129        ) // v3
130        .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
131        .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
132        .add_with((
133            "command",
134            "r2 object put -f script/install.sh zed-open-source-website-assets/install.sh",
135        ))
136    }
137
138    fn deploy_docs_worker() -> Step<Use> {
139        named::uses(
140            "cloudflare",
141            "wrangler-action",
142            "da0e0dfe58b7a431659754fdf3f186c529afbe65",
143        ) // v3
144        .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
145        .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
146        .add_with(("command", "deploy .cloudflare/docs-proxy/src/worker.js"))
147    }
148
149    fn upload_wrangler_logs() -> Step<Use> {
150        named::uses(
151            "actions",
152            "upload-artifact",
153            "ea165f8d65b6e75b540449e92b4886f43607fa02",
154        ) // v4
155        .if_condition(Expression::new("always()"))
156        .add_with(("name", "wrangler_logs"))
157        .add_with(("path", "/home/runner/.config/.wrangler/logs/"))
158    }
159
160    job.add_step(deploy_to_cf_pages(project_name))
161        .add_step(upload_install_script())
162        .add_step(deploy_docs_worker())
163        .add_step(upload_wrangler_logs())
164}
165
166pub(crate) fn check_docs() -> NamedJob {
167    NamedJob {
168        name: "check_docs".to_owned(),
169        job: docs_build_steps(
170            release_job(&[]),
171            None,
172            DocsChannel::Stable.channel_name(),
173            DocsChannel::Stable.site_url(),
174        ),
175    }
176}
177
178fn resolve_channel_step(
179    channel_expr: impl Into<String>,
180) -> (Step<Run>, StepOutput, StepOutput, StepOutput) {
181    let step = Step::new("deploy_docs::resolve_channel_step").run(format!(
182        indoc::indoc! {r#"
183            if [ -z "$CHANNEL" ]; then
184                if [ "$GITHUB_REF" = "refs/heads/main" ]; then
185                    CHANNEL="nightly"
186                else
187                    echo "::error::channel input is required when ref is not main."
188                    exit 1
189                fi
190            fi
191
192            case "$CHANNEL" in
193                "nightly")
194                    SITE_URL="{nightly_site_url}"
195                    PROJECT_NAME="{nightly_project_name}"
196                    ;;
197                "preview")
198                    SITE_URL="{preview_site_url}"
199                    PROJECT_NAME="{preview_project_name}"
200                    ;;
201                "stable")
202                    SITE_URL="{stable_site_url}"
203                    PROJECT_NAME="{stable_project_name}"
204                    ;;
205                *)
206                    echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
207                    exit 1
208                    ;;
209            esac
210
211            {{
212                echo "channel=$CHANNEL"
213                echo "site_url=$SITE_URL"
214                echo "project_name=$PROJECT_NAME"
215            }} >> "$GITHUB_OUTPUT"
216        "#},
217        nightly_site_url = DocsChannel::Nightly.site_url(),
218        preview_site_url = DocsChannel::Preview.site_url(),
219        stable_site_url = DocsChannel::Stable.site_url(),
220        nightly_project_name = DocsChannel::Nightly.project_name(),
221        preview_project_name = DocsChannel::Preview.project_name(),
222        stable_project_name = DocsChannel::Stable.project_name(),
223    ))
224    .id("resolve-channel")
225    .add_env(("CHANNEL", channel_expr.into()));
226
227    let channel = StepOutput::new(&step, "channel");
228    let site_url = StepOutput::new(&step, "site_url");
229    let project_name = StepOutput::new(&step, "project_name");
230    (step, channel, site_url, project_name)
231}
232
233fn docs_job(channel_expr: impl Into<String>, checkout_ref: Option<String>) -> NamedJob {
234    let (resolve_step, channel, site_url, project_name) = resolve_channel_step(channel_expr);
235
236    NamedJob {
237        name: "deploy_docs".to_owned(),
238        job: docs_deploy_steps(
239            docs_build_steps(
240                release_job(&[])
241                    .name("Build and Deploy Docs")
242                    .cond(Expression::new(
243                        "github.repository_owner == 'zed-industries'",
244                    ))
245                    .add_step(resolve_step),
246                checkout_ref,
247                channel.to_string(),
248                site_url.to_string(),
249            ),
250            &project_name,
251        ),
252    }
253}
254
255pub(crate) fn deploy_docs_job(
256    channel_input: &WorkflowInput,
257    checkout_ref_input: &WorkflowInput,
258) -> NamedJob {
259    docs_job(
260        channel_input.to_string(),
261        Some(format!(
262            "${{{{ {} != '' && {} || github.sha }}}}",
263            checkout_ref_input.expr(),
264            checkout_ref_input.expr()
265        )),
266    )
267}
268
269pub(crate) fn deploy_docs() -> Workflow {
270    let channel = WorkflowInput::string("channel", Some(String::new()))
271        .description("Docs channel to deploy: nightly, preview, or stable");
272    let checkout_ref = WorkflowInput::string("checkout_ref", Some(String::new()))
273        .description("Git ref to checkout and deploy. Defaults to event SHA when omitted.");
274    let deploy_docs = deploy_docs_job(&channel, &checkout_ref);
275
276    named::workflow()
277        .add_event(
278            Event::default().workflow_dispatch(
279                WorkflowDispatch::default()
280                    .add_input(channel.name, channel.input())
281                    .add_input(checkout_ref.name, checkout_ref.input()),
282            ),
283        )
284        .add_event(
285            Event::default().workflow_call(
286                WorkflowCall::default()
287                    .add_input(channel.name, channel.call_input())
288                    .add_input(checkout_ref.name, checkout_ref.call_input())
289                    .secrets([
290                        (
291                            "DOCS_AMPLITUDE_API_KEY".to_owned(),
292                            WorkflowCallSecret {
293                                description: "DOCS_AMPLITUDE_API_KEY".to_owned(),
294                                required: true,
295                            },
296                        ),
297                        (
298                            "CLOUDFLARE_API_TOKEN".to_owned(),
299                            WorkflowCallSecret {
300                                description: "CLOUDFLARE_API_TOKEN".to_owned(),
301                                required: true,
302                            },
303                        ),
304                        (
305                            "CLOUDFLARE_ACCOUNT_ID".to_owned(),
306                            WorkflowCallSecret {
307                                description: "CLOUDFLARE_ACCOUNT_ID".to_owned(),
308                                required: true,
309                            },
310                        ),
311                    ]),
312            ),
313        )
314        .add_job(deploy_docs.name, deploy_docs.job)
315}