1use gh_workflow::{
2 Event, Expression, Input, Job, Level, Permissions, Push, Run, Step, Use, UsesJob, Workflow,
3 WorkflowCall, WorkflowCallSecret, WorkflowDispatch,
4};
5
6use crate::tasks::workflows::{
7 runners,
8 steps::{self, CommonJobConditions, 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(docs_channel, site_url))
99 .add_step(lychee_link_check(&format!("{BUILD_OUTPUT_DIR}/docs"))),
100 )
101}
102
103fn docs_deploy_steps(job: Job, project_name: &StepOutput) -> Job {
104 fn deploy_to_cf_pages(project_name: &StepOutput) -> Step<Use> {
105 named::uses(
106 "cloudflare",
107 "wrangler-action",
108 "da0e0dfe58b7a431659754fdf3f186c529afbe65",
109 ) // v3
110 .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
111 .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
112 .add_with((
113 "command",
114 format!(
115 "pages deploy {BUILD_OUTPUT_DIR} --project-name=${{{{ {} }}}} --branch main",
116 project_name.expr()
117 ),
118 ))
119 }
120
121 fn upload_install_script() -> Step<Use> {
122 named::uses(
123 "cloudflare",
124 "wrangler-action",
125 "da0e0dfe58b7a431659754fdf3f186c529afbe65",
126 ) // v3
127 .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
128 .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
129 .add_with((
130 "command",
131 "r2 object put -f script/install.sh zed-open-source-website-assets/install.sh",
132 ))
133 }
134
135 fn deploy_docs_worker() -> Step<Use> {
136 named::uses(
137 "cloudflare",
138 "wrangler-action",
139 "da0e0dfe58b7a431659754fdf3f186c529afbe65",
140 ) // v3
141 .add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
142 .add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
143 .add_with(("command", "deploy .cloudflare/docs-proxy/src/worker.js"))
144 }
145
146 fn upload_wrangler_logs() -> Step<Use> {
147 named::uses(
148 "actions",
149 "upload-artifact",
150 "ea165f8d65b6e75b540449e92b4886f43607fa02",
151 ) // v4
152 .if_condition(Expression::new("always()"))
153 .add_with(("name", "wrangler_logs"))
154 .add_with(("path", "/home/runner/.config/.wrangler/logs/"))
155 }
156
157 job.add_step(deploy_to_cf_pages(project_name))
158 .add_step(upload_install_script())
159 .add_step(deploy_docs_worker())
160 .add_step(upload_wrangler_logs())
161}
162
163pub(crate) fn check_docs() -> NamedJob {
164 NamedJob {
165 name: "check_docs".to_owned(),
166 job: docs_build_steps(
167 release_job(&[]),
168 None,
169 DocsChannel::Stable.channel_name(),
170 DocsChannel::Stable.site_url(),
171 ),
172 }
173}
174
175fn resolve_channel_step(
176 channel_expr: impl Into<String>,
177) -> (Step<Run>, StepOutput, StepOutput, StepOutput) {
178 let step = Step::new("deploy_docs::resolve_channel_step").run(format!(
179 indoc::indoc! {r#"
180 if [ -z "$CHANNEL" ]; then
181 if [ "$GITHUB_REF" = "refs/heads/main" ]; then
182 CHANNEL="nightly"
183 else
184 echo "::error::channel input is required when ref is not main."
185 exit 1
186 fi
187 fi
188
189 case "$CHANNEL" in
190 "nightly")
191 SITE_URL="{nightly_site_url}"
192 PROJECT_NAME="{nightly_project_name}"
193 ;;
194 "preview")
195 SITE_URL="{preview_site_url}"
196 PROJECT_NAME="{preview_project_name}"
197 ;;
198 "stable")
199 SITE_URL="{stable_site_url}"
200 PROJECT_NAME="{stable_project_name}"
201 ;;
202 *)
203 echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
204 exit 1
205 ;;
206 esac
207
208 {{
209 echo "channel=$CHANNEL"
210 echo "site_url=$SITE_URL"
211 echo "project_name=$PROJECT_NAME"
212 }} >> "$GITHUB_OUTPUT"
213 "#},
214 nightly_site_url = DocsChannel::Nightly.site_url(),
215 preview_site_url = DocsChannel::Preview.site_url(),
216 stable_site_url = DocsChannel::Stable.site_url(),
217 nightly_project_name = DocsChannel::Nightly.project_name(),
218 preview_project_name = DocsChannel::Preview.project_name(),
219 stable_project_name = DocsChannel::Stable.project_name(),
220 ))
221 .id("resolve-channel")
222 .add_env(("CHANNEL", channel_expr.into()));
223
224 let channel = StepOutput::new(&step, "channel");
225 let site_url = StepOutput::new(&step, "site_url");
226 let project_name = StepOutput::new(&step, "project_name");
227 (step, channel, site_url, project_name)
228}
229
230fn docs_job(channel_expr: impl Into<String>, checkout_ref: Option<String>) -> NamedJob {
231 let (resolve_step, channel, site_url, project_name) = resolve_channel_step(channel_expr);
232
233 NamedJob {
234 name: "deploy_docs".to_owned(),
235 job: docs_deploy_steps(
236 docs_build_steps(
237 release_job(&[])
238 .cond(Expression::new(
239 "github.repository_owner == 'zed-industries'",
240 ))
241 .name("Build and Deploy Docs")
242 .add_step(resolve_step),
243 checkout_ref,
244 channel.to_string(),
245 site_url.to_string(),
246 ),
247 &project_name,
248 ),
249 }
250}
251
252pub(crate) fn deploy_docs_workflow_call(
253 channel: impl Into<String>,
254 checkout_ref: impl Into<String>,
255) -> NamedJob<UsesJob> {
256 let job = Job::default()
257 .with_repository_owner_guard()
258 .permissions(Permissions::default().contents(Level::Read))
259 .uses(
260 "zed-industries",
261 "zed",
262 ".github/workflows/deploy_docs.yml",
263 "main",
264 )
265 .with(
266 Input::default()
267 .add("channel", channel.into())
268 .add("checkout_ref", checkout_ref.into()),
269 )
270 .secrets(indexmap::IndexMap::from([
271 (
272 "DOCS_AMPLITUDE_API_KEY".to_owned(),
273 vars::DOCS_AMPLITUDE_API_KEY.to_owned(),
274 ),
275 (
276 "CLOUDFLARE_API_TOKEN".to_owned(),
277 vars::CLOUDFLARE_API_TOKEN.to_owned(),
278 ),
279 (
280 "CLOUDFLARE_ACCOUNT_ID".to_owned(),
281 vars::CLOUDFLARE_ACCOUNT_ID.to_owned(),
282 ),
283 ]));
284
285 NamedJob {
286 name: "deploy_docs".to_owned(),
287 job,
288 }
289}
290
291pub(crate) fn deploy_docs_job(
292 channel_input: &WorkflowInput,
293 checkout_ref_input: &WorkflowInput,
294) -> NamedJob {
295 docs_job(
296 channel_input.to_string(),
297 Some(format!(
298 "${{{{ {} != '' && {} || github.sha }}}}",
299 checkout_ref_input.expr(),
300 checkout_ref_input.expr()
301 )),
302 )
303}
304
305pub(crate) fn deploy_docs() -> Workflow {
306 let channel = WorkflowInput::string("channel", Some(String::new()))
307 .description("Docs channel to deploy: nightly, preview, or stable");
308 let checkout_ref = WorkflowInput::string("checkout_ref", Some(String::new()))
309 .description("Git ref to checkout and deploy. Defaults to event SHA when omitted.");
310 let deploy_docs = deploy_docs_job(&channel, &checkout_ref);
311
312 named::workflow()
313 .add_event(
314 Event::default().workflow_dispatch(
315 WorkflowDispatch::default()
316 .add_input(channel.name, channel.input())
317 .add_input(checkout_ref.name, checkout_ref.input()),
318 ),
319 )
320 .add_event(
321 Event::default().workflow_call(
322 WorkflowCall::default()
323 .add_input(channel.name, channel.call_input())
324 .add_input(checkout_ref.name, checkout_ref.call_input())
325 .secrets([
326 (
327 "DOCS_AMPLITUDE_API_KEY".to_owned(),
328 WorkflowCallSecret {
329 description: "DOCS_AMPLITUDE_API_KEY".to_owned(),
330 required: true,
331 },
332 ),
333 (
334 "CLOUDFLARE_API_TOKEN".to_owned(),
335 WorkflowCallSecret {
336 description: "CLOUDFLARE_API_TOKEN".to_owned(),
337 required: true,
338 },
339 ),
340 (
341 "CLOUDFLARE_ACCOUNT_ID".to_owned(),
342 WorkflowCallSecret {
343 description: "CLOUDFLARE_ACCOUNT_ID".to_owned(),
344 required: true,
345 },
346 ),
347 ]),
348 ),
349 )
350 .add_job(deploy_docs.name, deploy_docs.job)
351}
352
353pub(crate) fn deploy_nightly_docs() -> Workflow {
354 let deploy_docs = deploy_docs_workflow_call("nightly", "${{ github.sha }}");
355
356 named::workflow()
357 .name("deploy_nightly_docs")
358 .add_event(Event::default().push(Push::default().add_branch("main")))
359 .add_job(deploy_docs.name, deploy_docs.job)
360}