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