Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

g1t-runner deploy builds the checkout a workflow step runs in and puts it on g1t.page, with the build's id and token from the API: without GIT_REMOTE it clones nothing and builds the current directory (ROOT_DIR below it), bundles a Workers project into the job's temporary space rather than the sandbox's, prints the build's log to the step as well as sending it to the deployment, with the token and the build secrets redacted, and exits 1 when the build fails or g1t does not take it, so the step fails with it. g1t-runner detect prints what that build would find, kind (workers, static, html or none), install, build and output-dir, as key=value lines for $GITHUB_OUTPUT. A sandbox's build is as it was.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent7fdf622Browse files
4 files+476−320/4 viewed
+310−32
2828 //! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
2929 //! variables and secrets for deploy builds. Both are set for the build;
3030 //! secrets' values are redacted from its log.
31+//!
32+//! From a workflow step, `g1t-runner deploy` runs the same build in the
33+//! job's own checkout: without `GIT_REMOTE` nothing is cloned, and the
34+//! project is the current directory (`ROOT_DIR` below it). The build's id
35+//! and token come from `POST /repos/{owner}/{repo}/deployments/builds`,
36+//! which the `g1t/deploy` action calls (actions/deploy). Its output goes
37+//! to the step's log as well as the deployment's. `g1t-runner detect`
38+//! says what the build would find, as `key=value` lines for
39+//! `$GITHUB_OUTPUT`, without building anything.
3140
3241 use std::collections::BTreeMap;
3342 use std::path::{Path, PathBuf};
4352 use crate::checks::{redact, run_command};
4453 use crate::{WORKDIR, auth_option, env, git};
4554
46−/// Where `wrangler deploy --dry-run` writes the bundle.
55+/// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
4756 const BUNDLE_DIR: &str = "/work/g1t-bundle";
4857 /// Cloudflare's limits on a Worker's static assets.
4958 const MAX_FILES: usize = 20_000;
8190 }
8291 }
8392
84−/// The build's log, kept to its end.
93+/// The build's log, kept to its end; also printed, in a workflow step.
8594 #[derive(Default)]
8695 struct Log {
8796 text: String,
97+ /// Each line to standard output as well, redacted.
98+ echo: Option<Vec<String>>,
8899 }
89100
90101 impl Log {
91102 fn line(&mut self, line: &str) {
92103 self.text.push_str(line);
93104 self.text.push('\n');
105+ if let Some(secrets) = &self.echo {
106+ println!("{}", redact(line, secrets));
107+ }
94108 }
95109
96110 fn tail(&self) -> String {
378392 }
379393
380394 /// The bundle `wrangler deploy --dry-run` wrote, main module first.
381−fn bundle_modules(main: &str) -> Result<(String, Vec<Module>)> {
395+fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> {
382396 let stem = Path::new(main)
383397 .file_stem()
384398 .map(|stem| stem.to_string_lossy().into_owned())
385399 .unwrap_or_else(|| "index".to_owned());
386400 let mut modules = Vec::new();
387401 let mut files = Vec::new();
388− collect_files(Path::new(BUNDLE_DIR), &mut files)?;
402+ collect_files(bundle, &mut files)?;
389403 for file in files {
390404 let name = file
391− .strip_prefix(BUNDLE_DIR)?
405+ .strip_prefix(bundle)?
392406 .to_string_lossy()
393407 .replace('\\', "/");
394408 let kind = match name.rsplit('.').next().unwrap_or("") {
470484 if !built && assets_dir == project { "html" } else { "static" }
471485 }
472486
487+/// The command that bundles a Workers project, without deploying it,
488+/// into `bundle` when given.
489+fn wrangler_build(bundle: Option<&Path>) -> String {
490+ match bundle {
491+ Some(bundle) => format!("npx --yes wrangler@4 deploy --dry-run --outdir {}", bundle.display()),
492+ None => "npx --yes wrangler@4 deploy --dry-run".to_owned(),
493+ }
494+}
495+
496+/// The first of the usual output directories a static build wrote (or the
497+/// project's own `index.html`), relative to the project: `.` for itself.
498+fn find_output(dir: &Path) -> Option<String> {
499+ OUTPUT_DIRS
500+ .iter()
501+ .find(|name| {
502+ let path = dir.join(name);
503+ path.join("index.html").exists() || (path.is_dir() && **name != "public")
504+ })
505+ .map(|name| (*name).to_owned())
506+ .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned()))
507+}
508+
509+/// What a build of the project at `dir` does, found before anything runs:
510+/// what it is, how its dependencies are installed, what builds it, and
511+/// where what it serves is. For a site with a build of its own, the output
512+/// directory may only exist once it has run; then `output_dir` is `None`
513+/// unless one was chosen.
514+#[derive(Debug, PartialEq, Eq)]
515+pub(crate) struct Detected {
516+ /// `workers`, `static`, `html`, or `none` when there is nothing to serve.
517+ pub kind: &'static str,
518+ pub install: Option<&'static str>,
519+ pub build: Option<String>,
520+ pub output_dir: Option<String>,
521+}
522+
523+pub(crate) fn detect(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>) -> Result<Detected> {
524+ let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
525+ let chosen_output = chosen_output.map(|d| d.trim().trim_matches('/')).filter(|d| !d.is_empty());
526+ let install = install_command(dir);
527+ if let Some(config) = read_wrangler(dir)? {
528+ let mut steps: Vec<String> = custom_build.map(str::to_owned).into_iter().collect();
529+ if config.main.is_some() {
530+ steps.push(wrangler_build(None));
531+ }
532+ return Ok(Detected {
533+ kind: "workers",
534+ install,
535+ build: (!steps.is_empty()).then(|| steps.join(" && ")),
536+ output_dir: config.assets.and_then(|assets| assets.directory),
537+ });
538+ }
539+ let build = custom_build
540+ .map(str::to_owned)
541+ .or_else(|| has_build_script(dir).then(|| "npm run build".to_owned()));
542+ let output_dir = chosen_output.map(str::to_owned).or_else(|| find_output(dir));
543+ // `.` is the project itself: its own files, served as they are.
544+ let kind = match (&build, output_dir.as_deref()) {
545+ (None, Some(".")) => "html",
546+ (None, None) => "none",
547+ _ => "static",
548+ };
549+ Ok(Detected { kind, install, build, output_dir })
550+}
551+
473552 fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
474553 let project = project_dir();
475554 let dir = project.as_path();
555+ let bundle = bundle_dir();
476556 let config = read_wrangler(dir)?;
477557 let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty());
478558 if let Some(install) = install_command(dir) {
493573 if let Some(main) = &config.main {
494574 // `--dry-run` runs the project's own build and bundles it,
495575 // without deploying anywhere.
496− step(
497− log,
498− &format!("npx --yes wrangler@4 deploy --dry-run --outdir {BUNDLE_DIR}"),
499− secrets,
500− )?;
501− let (main_module, modules) = bundle_modules(main)?;
576+ let _ = std::fs::remove_dir_all(&bundle);
577+ step(log, &wrangler_build(Some(&bundle)), secrets)?;
578+ let (main_module, modules) = bundle_modules(main, &bundle)?;
502579 worker["mainModule"] = main_module.into();
503580 worker["modules"] = serde_json::to_value(modules)?;
504581 }
530607 }
531608 path
532609 }
533− None => OUTPUT_DIRS
534− .iter()
535− .map(|name| dir.join(name))
536− .find(|path| path.join("index.html").exists() || (path.is_dir() && path != &dir.join("public")))
537− .or_else(|| dir.join("index.html").exists().then(|| dir.to_path_buf()))
538− .context(
539− "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public",
540− )?,
610+ None => match find_output(dir).context(
611+ "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public",
612+ )? {
613+ own if own == "." => dir.to_path_buf(),
614+ output => dir.join(output),
615+ },
541616 };
542617 let spa = !assets_dir.join("404.html").exists();
543618 Ok(Built {
555630 }
556631 }
557632
558−/// Where the project lives in the checkout: its root directory.
559−fn project_dir() -> PathBuf {
560− let root = std::env::var("ROOT_DIR").unwrap_or_default();
561− let root = root.trim_matches('/');
562− if root.is_empty() || root.split('/').any(|part| part == "..") {
633+/// Whether the build checks out its own commit (a sandbox, given
634+/// `GIT_REMOTE`) rather than building the checkout it runs in.
635+fn clones() -> bool {
636+ std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty())
637+}
638+
639+/// The checkout the build runs in: the sandbox's clone, or, from a
640+/// workflow step, the current directory.
641+fn checkout_dir() -> PathBuf {
642+ if clones() {
563643 PathBuf::from(WORKDIR)
564644 } else {
565− Path::new(WORKDIR).join(root)
645+ std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
646+ }
647+}
648+
649+/// Where `wrangler deploy --dry-run` writes the bundle: in a workflow
650+/// step, a directory of the job's temporary space, never the checkout.
651+fn bundle_dir() -> PathBuf {
652+ if clones() {
653+ return PathBuf::from(BUNDLE_DIR);
654+ }
655+ let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
656+ base.join(format!("g1t-bundle-{}", std::process::id()))
657+}
658+
659+/// Where the project lives in the checkout `base`: its root directory,
660+/// never above the checkout.
661+fn project_dir_in(base: &Path, root: &str) -> PathBuf {
662+ let root = root.trim().trim_matches('/');
663+ if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") {
664+ base.to_path_buf()
665+ } else {
666+ base.join(root)
566667 }
567668 }
568669
670+/// Where the project lives: `ROOT_DIR` in the checkout.
671+fn project_dir() -> PathBuf {
672+ project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default())
673+}
674+
569675 fn check_out(secrets: &[String]) -> Result<()> {
570676 let remote = env("GIT_REMOTE")?;
571677 let commit = env("GIT_COMMIT")?;
585691 }
586692
587693 fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
588− check_out(secrets).context("the commit could not be checked out")?;
694+ if clones() {
695+ check_out(secrets).context("the commit could not be checked out")?;
696+ } else {
697+ let dir = project_dir();
698+ if !dir.is_dir() {
699+ bail!("the project's directory {} does not exist in this checkout", dir.display());
700+ }
701+ log.line(&format!("Building {} as it is checked out.", dir.display()));
702+ }
589703 // The repository's variables and secrets for deploy builds.
590704 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
591705 if let Ok(vars) = std::env::var(source)
605719 "warnings": built.warnings,
606720 "detected": built.detected,
607721 });
722+ for warning in &built.warnings {
723+ log.line(&format!("Warning: {warning}"));
724+ }
608725 if let Some(dir) = &built.assets_dir {
609726 let skip_project = *dir == project_dir();
610727 let mut assets = Vec::new();
640757 Ok(finish)
641758 }
642759
760+/// A sandbox's build (`MODE=deploy`).
643761 pub fn main() -> i32 {
762+ run(false)
763+}
764+
765+/// `g1t-runner deploy`, from a workflow step: the build in the job's own
766+/// checkout, its log in the step's as well. Exits 1 when the build fails,
767+/// after reporting it, so the step fails too.
768+pub fn cli(args: &[String]) -> i32 {
769+ if args.iter().any(|arg| arg == "--help" || arg == "-h") {
770+ println!("{CLI_HELP}");
771+ return 0;
772+ }
773+ run(true)
774+}
775+
776+const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
777+
778+Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
779+G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
780+BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all.
781+
782+g1t-runner detect prints what the build would find, as key=value lines.";
783+
784+fn run(step_log: bool) -> i32 {
644785 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
645786 (Ok(api), Ok(id), Ok(token)) => Reporter {
646− base: format!("{api}/deployments/jobs/{id}"),
787+ base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
647788 token,
648789 },
649790 _ => {
665806 return 1;
666807 }
667808 let started = Instant::now();
668− let mut log = Log::default();
809+ let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
669810 let outcome = deploy(&reporter, &mut log, &secrets);
670811 let seconds = started.elapsed().as_secs();
671− let sent = match outcome {
812+ if step_log {
813+ let _ = std::fs::remove_dir_all(bundle_dir());
814+ }
815+ let (sent, failed) = match outcome {
672816 Ok(mut finish) => {
673817 finish["log"] = redact(&log.tail(), &secrets).into();
674818 finish["buildSeconds"] = seconds.into();
675− reporter.send("finish", finish)
819+ (reporter.send("finish", finish), false)
676820 }
677821 Err(error) => {
678822 let message = redact(&format!("{error:#}"), &secrets);
679823 log.line(&format!("The build failed: {message}"));
680− reporter.send(
824+ let sent = reporter.send(
681825 "fail",
682826 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
683− )
827+ );
828+ (sent, true)
684829 }
685830 };
686831 match sent {
832+ // A sandbox's build has said how it ended; a step fails with its build.
833+ Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => {
834+ if !failed {
835+ eprintln!("g1t-runner: g1t did not take the build; its deployment says why");
836+ }
837+ 1
838+ }
687839 Ok(_) => 0,
688840 Err(error) => {
689841 eprintln!("g1t-runner: {error:#}");
692844 }
693845 }
694846
847+/// `g1t-runner detect`: what a build of the project would find, as
848+/// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build` and
849+/// `output-dir`. Reads `ROOT_DIR`, `BUILD_COMMAND` and `OUTPUT_DIR` as the
850+/// build does; `--root-dir DIR` is the same as `ROOT_DIR`.
851+pub fn detect_cli(args: &[String]) -> i32 {
852+ let mut root = std::env::var("ROOT_DIR").unwrap_or_default();
853+ let mut rest = args.iter();
854+ while let Some(arg) = rest.next() {
855+ match arg.as_str() {
856+ "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
857+ "--help" | "-h" => {
858+ println!("g1t-runner detect [--root-dir DIR]: prints kind, install, build and output-dir as key=value lines.");
859+ return 0;
860+ }
861+ other => {
862+ eprintln!("g1t-runner detect: unknown argument {other}");
863+ return 2;
864+ }
865+ }
866+ }
867+ let dir = project_dir_in(&checkout_dir(), &root);
868+ let custom = std::env::var("BUILD_COMMAND").ok();
869+ let output = std::env::var("OUTPUT_DIR").ok();
870+ match detect(&dir, custom.as_deref(), output.as_deref()) {
871+ Ok(found) => {
872+ print!("{}", output_lines(&found));
873+ 0
874+ }
875+ Err(error) => {
876+ eprintln!("g1t-runner detect: {error:#}");
877+ 1
878+ }
879+ }
880+}
881+
882+/// `Detected` as `$GITHUB_OUTPUT` lines; a value on one line, empty when
883+/// there is none.
884+fn output_lines(found: &Detected) -> String {
885+ let one_line = |value: Option<&str>| value.unwrap_or_default().replace(['\r', '\n'], " ");
886+ format!(
887+ "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n",
888+ found.kind,
889+ one_line(found.install),
890+ one_line(found.build.as_deref()),
891+ one_line(found.output_dir.as_deref()),
892+ )
893+}
894+
695895 #[cfg(test)]
696896 mod tests {
697897 use super::*;
758958 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
759959 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
760960 }
961+
962+ /// A fresh directory with `files` (path, contents) in it.
963+ fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
964+ let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id()));
965+ let _ = std::fs::remove_dir_all(&dir);
966+ for (path, text) in files {
967+ let path = dir.join(path);
968+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
969+ std::fs::write(path, text).unwrap();
970+ }
971+ std::fs::create_dir_all(&dir).unwrap();
972+ dir
973+ }
974+
975+ #[test]
976+ fn detect_finds_a_workers_project_and_how_it_builds() {
977+ let dir = project(
978+ "workers",
979+ &[
980+ ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#),
981+ ("package.json", "{}"),
982+ ("pnpm-lock.yaml", ""),
983+ ],
984+ );
985+ let found = detect(&dir, None, None).unwrap();
986+ assert_eq!(found.kind, "workers");
987+ assert_eq!(found.install, Some("corepack enable && pnpm install --frozen-lockfile"));
988+ assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run"));
989+ assert_eq!(found.output_dir.as_deref(), Some("./public"));
990+ // The project's own build command runs first.
991+ let custom = detect(&dir, Some("npm run codegen"), None).unwrap();
992+ assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run"));
993+ }
994+
995+ #[test]
996+ fn detect_finds_a_static_build_and_a_plain_site() {
997+ let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]);
998+ let found = detect(&built, None, None).unwrap();
999+ assert_eq!(
1000+ found,
1001+ Detected { kind: "static", install: Some("npm ci"), build: Some("npm run build".into()), output_dir: None },
1002+ "its output is found once it has built"
1003+ );
1004+ std::fs::create_dir_all(built.join("dist")).unwrap();
1005+ std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap();
1006+ assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist"));
1007+ assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site"));
1008+
1009+ let html = project("html", &[("index.html", "<h1>hi</h1>")]);
1010+ assert_eq!(detect(&html, None, None).unwrap(), Detected { kind: "html", install: None, build: None, output_dir: Some(".".into()) });
1011+ // A `public` folder alone, without an index.html, is not a site.
1012+ let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]);
1013+ assert_eq!(detect(&empty, None, None).unwrap().kind, "none");
1014+ }
1015+
1016+ #[test]
1017+ fn detect_prints_lines_for_github_output() {
1018+ let found = Detected { kind: "static", install: Some("npm ci"), build: Some("a\nb".into()), output_dir: None };
1019+ assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
1020+ }
1021+
1022+ #[test]
1023+ fn the_root_directory_stays_inside_the_checkout() {
1024+ let base = Path::new("/w/repo");
1025+ assert_eq!(project_dir_in(base, ""), base);
1026+ assert_eq!(project_dir_in(base, "."), base);
1027+ assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site"));
1028+ assert_eq!(project_dir_in(base, "../etc"), base);
1029+ assert_eq!(project_dir_in(base, "apps/../../x"), base);
1030+ }
1031+
1032+ #[test]
1033+ fn a_step_prints_the_log_redacted() {
1034+ let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() };
1035+ log.line("token hunter22 used");
1036+ assert!(log.text.contains("hunter22"), "the deployment's log is redacted when it is sent");
1037+ assert_eq!(wrangler_build(Some(Path::new("/tmp/b"))), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b");
1038+ }
7611039 }
+10−0
3232 //! Given a command instead (`register`, `run`, `service`, `remove`,
3333 //! `update`, `version`), it is a self-hosted runner on someone's own
3434 //! machine, which runs work in these modes: see `selfhosted`.
35+//!
36+//! `deploy` and `detect` are for a workflow step: a g1t.page build of the
37+//! job's own checkout, and what that build would find (see `deploy`).
3538
3639 mod abuse;
3740 mod actions;
216219 // A self-hosted runner's commands; the modes below are what it, and
217220 // g1t's sandboxes, run work with.
218221 let args: Vec<String> = std::env::args().skip(1).collect();
222+ // From a workflow step: a g1t.page build of the job's own checkout,
223+ // and what it would find (deploy.rs).
224+ match args.first().map(String::as_str) {
225+ Some("deploy") => std::process::exit(deploy::cli(&args[1..])),
226+ Some("detect") => std::process::exit(deploy::detect_cli(&args[1..])),
227+ _ => {}
228+ }
219229 if selfhosted::is_command(&args) {
220230 std::process::exit(selfhosted::main(args));
221231 }
+4−0
150150 g1t-runner update update to the newest release
151151 g1t-runner version
152152
153+In a workflow step (the g1t/deploy and g1t/detect actions use these):
154+ g1t-runner deploy build this checkout and put it on g1t.page
155+ g1t-runner detect [--root-dir DIR] what that build would find
156+
153157 Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
154158 Make a registration token under Settings, Runners on g1t.sh.
155159 Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
+152−0
1+//! `g1t-runner deploy` and `g1t-runner detect` as a workflow step runs
2+//! them: in a checkout of its own, with nothing cloned, reporting to an API
3+//! (here a fake one on localhost) with the build's token.
4+
5+use std::io::{BufRead, BufReader, Read, Write};
6+use std::net::TcpListener;
7+use std::path::{Path, PathBuf};
8+use std::process::Command;
9+use std::sync::{Arc, Mutex};
10+
11+use serde_json::{Value, json};
12+
13+fn runner() -> Command {
14+ Command::new(env!("CARGO_BIN_EXE_g1t-runner"))
15+}
16+
17+/// A fresh checkout holding `files`.
18+fn checkout(name: &str, files: &[(&str, &str)]) -> PathBuf {
19+ let dir = std::env::temp_dir().join(format!("g1t-runner-cli-{name}-{}", std::process::id()));
20+ let _ = std::fs::remove_dir_all(&dir);
21+ std::fs::create_dir_all(&dir).unwrap();
22+ for (path, text) in files {
23+ let path = dir.join(path);
24+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
25+ std::fs::write(path, text).unwrap();
26+ }
27+ dir
28+}
29+
30+#[test]
31+fn detect_prints_what_the_build_would_find() {
32+ let dir = checkout("detect", &[("site/index.html", "<h1>hello</h1>")]);
33+ let output = runner().arg("detect").args(["--root-dir", "site"]).current_dir(&dir).env_remove("GIT_REMOTE").output().unwrap();
34+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
35+ assert_eq!(String::from_utf8_lossy(&output.stdout), "kind=html\ninstall=\nbuild=\noutput-dir=.\n");
36+ let unknown = runner().args(["detect", "--nope"]).current_dir(&dir).output().unwrap();
37+ assert_eq!(unknown.status.code(), Some(2));
38+}
39+
40+/// The API's `/deployments/jobs/<id>/<step>`: answers each step as the
41+/// deployments service does, and keeps what it was sent.
42+fn fake_api() -> (String, Arc<Mutex<Vec<(String, Value)>>>) {
43+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
44+ let base = format!("http://{}", listener.local_addr().unwrap());
45+ let seen = Arc::new(Mutex::new(Vec::new()));
46+ let kept = seen.clone();
47+ std::thread::spawn(move || {
48+ for stream in listener.incoming() {
49+ let Ok(mut stream) = stream else { continue };
50+ let mut reader = BufReader::new(stream.try_clone().unwrap());
51+ let mut first = String::new();
52+ reader.read_line(&mut first).unwrap();
53+ let path = first.split_whitespace().nth(1).unwrap_or_default().to_owned();
54+ let mut length = 0;
55+ loop {
56+ let mut line = String::new();
57+ reader.read_line(&mut line).unwrap();
58+ if line.trim().is_empty() {
59+ break;
60+ }
61+ if let Some((name, value)) = line.split_once(':')
62+ && name.eq_ignore_ascii_case("content-length")
63+ {
64+ length = value.trim().parse().unwrap_or(0);
65+ }
66+ }
67+ let mut body = vec![0; length];
68+ reader.read_exact(&mut body).unwrap();
69+ let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
70+ let answer = if path.ends_with("/session") {
71+ json!({ "ok": true, "value": { "jwt": "complete-me", "buckets": [], "upload_url": "http://127.0.0.1:9/never" } })
72+ } else {
73+ json!({ "ok": true, "value": true })
74+ };
75+ kept.lock().unwrap().push((path, body));
76+ let text = answer.to_string();
77+ let _ = write!(
78+ stream,
79+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}",
80+ text.len()
81+ );
82+ }
83+ });
84+ (base, seen)
85+}
86+
87+fn deploy(dir: &Path, api: &str, extra: &[(&str, &str)]) -> std::process::Output {
88+ let mut command = runner();
89+ command
90+ .arg("deploy")
91+ .current_dir(dir)
92+ .env_remove("GIT_REMOTE")
93+ .env("G1T_API", api)
94+ .env("DEPLOY_ID", "dpl_1")
95+ .env("DEPLOY_TOKEN", "build-token-0123456789")
96+ .env("BUILD_SECRETS", r#"{"API_KEY":"very-secret-value"}"#)
97+ .env("BUILD_ENV", r#"{"PUBLIC_URL":"https://example.com"}"#);
98+ for (name, value) in extra {
99+ command.env(name, value);
100+ }
101+ command.output().unwrap()
102+}
103+
104+#[test]
105+fn deploy_builds_the_checkout_it_runs_in_without_cloning() {
106+ let (api, seen) = fake_api();
107+ let dir = checkout("deploy", &[("site/index.html", "<h1>hello</h1>"), ("site/about.html", "<p>about</p>"), ("README.md", "not served")]);
108+ let output = deploy(&dir, &api, &[("ROOT_DIR", "site")]);
109+ let stdout = String::from_utf8_lossy(&output.stdout);
110+ assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr));
111+ // Its log is the step's too.
112+ assert!(stdout.contains("as it is checked out"), "{stdout}");
113+ assert!(stdout.contains("Uploading 2 files."), "{stdout}");
114+ let seen = seen.lock().unwrap();
115+ let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect();
116+ assert_eq!(steps, ["/deployments/jobs/dpl_1/started", "/deployments/jobs/dpl_1/session", "/deployments/jobs/dpl_1/finish"]);
117+ for (_, body) in seen.iter() {
118+ assert_eq!(body["token"], "build-token-0123456789");
119+ }
120+ let manifest = seen[1].1["manifest"].as_object().unwrap();
121+ let mut files: Vec<&String> = manifest.keys().collect();
122+ files.sort();
123+ assert_eq!(files, ["/about.html", "/index.html"]);
124+ let finish = &seen[2].1;
125+ assert_eq!(finish["detected"], "html");
126+ assert_eq!(finish["completionJwt"], "complete-me");
127+ assert!(!finish["log"].as_str().unwrap().contains("build-token-0123456789"));
128+}
129+
130+#[test]
131+fn a_failed_build_is_reported_and_fails_the_step() {
132+ let (api, seen) = fake_api();
133+ let dir = checkout("fail", &[("README.md", "nothing to serve")]);
134+ let output = deploy(&dir, &api, &[]);
135+ assert_eq!(output.status.code(), Some(1));
136+ let stdout = String::from_utf8_lossy(&output.stdout);
137+ assert!(stdout.contains("found nothing to serve"), "{stdout}");
138+ let seen = seen.lock().unwrap();
139+ assert_eq!(seen.last().unwrap().0, "/deployments/jobs/dpl_1/fail");
140+ // A missing root directory says so.
141+ drop(seen);
142+ let missing = deploy(&dir, &api, &[("ROOT_DIR", "apps/web")]);
143+ assert_eq!(missing.status.code(), Some(1));
144+ assert!(String::from_utf8_lossy(&missing.stdout).contains("does not exist in this checkout"));
145+}
146+
147+#[test]
148+fn deploy_needs_its_build() {
149+ let output = runner().arg("deploy").env_remove("DEPLOY_ID").env_remove("DEPLOY_TOKEN").output().unwrap();
150+ assert_eq!(output.status.code(), Some(2));
151+ assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID"));
152+}

Comments

Sign in to comment.