use crate::build;
use crate::config::DogConfig;
use crate::daemon::Daemon;
use crate::deploy::{
self, Outcome, head_of, read_staged, record, refuse_ungated_verification, run_release_hook,
stage_release, swap_and_land,
};
use crate::error::Error;
use crate::paths::Tree;
use crate::state::{State, Watch};
use crate::{git, linkcheck, lock, retention, shared, swap};
#[derive(Debug, Clone, PartialEq, Eq)]
#[expect(
clippy::enum_variant_names,
reason = "`AlreadyStaged` names the answer, beside `AlreadyLive`"
)]
pub enum Staged {
Built {
sha: String,
},
AlreadyStaged {
sha: String,
},
AlreadyLive {
sha: String,
},
}
pub async fn stage(tree: &Tree, state: &mut State, config: &DogConfig) -> Result<Staged, Error> {
let sheep = tree.sheep();
let _staging = lock::hold(tree)?;
*state = State::read(&tree.state_file())?;
if state.deployed.is_none() {
return Err(Error::NotCutOver {
sheep: sheep.to_owned(),
tree: tree.root().to_owned(),
});
}
if state.watch == Watch::Auto {
return Err(Error::Config(format!(
"{sheep} is watch = auto, so the poll loop would deploy the head on its next tick \
and a stage would not hold. Run `shep-deploy deploy {sheep} --watch manual` first."
)));
}
let (git_dir, remote, tracked, budget) = (
tree.git(),
state.remote.clone(),
state.branch.clone(),
config.git_timeout,
);
shared::off_thread(move || git::fetch(&git_dir, &remote, &tracked, budget)).await?;
let head = head_of(tree, &state.branch)?;
if state.deployed.as_deref() == Some(head.as_str()) {
return Ok(Staged::AlreadyLive { sha: head });
}
if state.staged.as_deref() == Some(head.as_str())
&& tree.release(&head).is_dir()
&& tree.completion(&head).is_file()
{
return Ok(Staged::AlreadyStaged { sha: head });
}
let built = build_and_record(tree, state, &head, config).await;
let spared: Vec<String> = [state.deployed.as_deref(), state.staged.as_deref()]
.into_iter()
.flatten()
.map(str::to_owned)
.collect();
let pruned = {
let (tree, keep) = (tree.clone(), config.retention);
shared::off_thread(move || {
let spared: Vec<&str> = spared.iter().map(String::as_str).collect();
retention::prune(&tree, keep, &spared)
})
.await
};
if let Err(err) = pruned {
eprintln!("shep-deploy: {sheep}: could not reclaim old releases: {err}");
}
built.map(|()| Staged::Built { sha: head })
}
pub async fn promote<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
config: &DogConfig,
) -> Result<Outcome, Error> {
let sheep = tree.sheep();
let _promoting = lock::hold(tree)?;
*state = State::read(&tree.state_file())?;
if state.deployed.is_none() {
return Err(Error::NotCutOver {
sheep: sheep.to_owned(),
tree: tree.root().to_owned(),
});
}
let Some(head) = state.staged.clone() else {
return Err(Error::Config(format!(
"{sheep} has nothing staged. Run `shep-deploy deploy {sheep} --stage` first."
)));
};
if !tree.release(&head).is_dir() || !tree.completion(&head).is_file() {
return Err(Error::Config(format!(
"{sheep} has {head} staged, but its release is gone or unfinished. Run \
`shep-deploy deploy {sheep} --stage` to build it again."
)));
}
let outcome = land_staged(daemon, tree, state, &head, config).await;
record(tree, state, &head, &outcome, config.retention).await;
outcome
}
async fn land_staged<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
head: &str,
config: &DogConfig,
) -> Result<Outcome, Error> {
let started_at = swap::resolve(&tree.current())?;
let staged = {
let (tree, checkout, head) = (tree.clone(), state.checkout.clone(), head.to_owned());
shared::off_thread(move || read_staged(&tree, &checkout, &head)).await?
};
refuse_ungated_verification(tree.sheep(), &staged.app, state.verify)?;
run_release_hook(tree, head, &staged, config).await?;
swap_and_land(daemon, tree, state, head, started_at, &staged, config).await
}
async fn build_and_record(
tree: &Tree,
state: &mut State,
head: &str,
config: &DogConfig,
) -> Result<(), Error> {
let sheep = tree.sheep();
let release = tree.release(head);
let deploy::Staged {
app, spec, linked, ..
} = {
let (tree, checkout, head) = (tree.clone(), state.checkout.clone(), head.to_owned());
shared::off_thread(move || stage_release(&tree, &checkout, &head)).await?
};
refuse_ungated_verification(sheep, &app, state.verify)?;
let snapshot = linkcheck::Snapshot::before(sheep, &release, &linked);
let built = build::run(
sheep,
&release,
&spec,
app.user.as_deref(),
&config.passthrough,
&tree.cache_target(),
config.build_timeout,
)
.await;
for warning in snapshot.after() {
eprintln!("{warning}");
}
built?;
record_staged(tree, state, head)
}
fn record_staged(tree: &Tree, state: &mut State, head: &str) -> Result<(), Error> {
let _record = lock::hold_record(tree)?;
let on_disk = State::read(&tree.state_file())?;
if on_disk.branch != state.branch {
let message = format!(
"{}: the branch changed from {} to {} while {head} was building, so it was not \
staged. Run the stage again for the new branch.",
tree.sheep(),
state.branch,
on_disk.branch
);
*state = on_disk;
return Err(Error::Config(message));
}
let next = State {
staged: Some(head.to_owned()),
..on_disk
};
next.write(&tree.state_file())?;
*state = next;
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::deploy::Outcome;
use crate::deploy::test_support::{
FLOCKFILE, Shepherd, commit_on_origin, fixture_before_any_release,
fixture_with_previous_release,
};
use crate::{fixtures, swap};
use super::*;
fn commit_building(
fixture: &crate::deploy::test_support::Fixture,
command: &str,
name: &str,
) -> String {
fs::write(
fixture.origin.path().join("Flockfile.toml"),
format!("{FLOCKFILE}\n[dog.deploy.build]\ncommand = \"{command}\"\n"),
)
.expect("write Flockfile");
commit_on_origin(fixture, name)
}
async fn stage_a_build(
fixture: &mut crate::deploy::test_support::Fixture,
command: &str,
) -> String {
let second = commit_building(fixture, command, "second.txt");
stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages");
second
}
#[tokio::test]
async fn promote_swaps_to_the_staged_release_and_clears_the_record() {
let mut fixture = fixture_with_previous_release();
let second = stage_a_build(&mut fixture, "touch built.marker").await;
let marker = fixture.tree.release(&second).join("built.marker");
fs::remove_file(&marker).expect("marker");
let daemon = Shepherd::ready();
let outcome = promote(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("promotes");
assert_eq!(
outcome,
Outcome::Deployed {
sha: second.clone()
}
);
assert!(!marker.exists(), "no build ran");
assert_eq!(daemon.reload_count(), 1);
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&second))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(second.as_str()));
assert_eq!(fixture.state.staged, None);
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.deployed.as_deref(), Some(second.as_str()));
assert_eq!(written.staged, None);
assert_eq!(written.failed, None);
}
#[tokio::test]
async fn promote_with_nothing_staged_is_refused_and_touches_nothing() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let before = std::fs::read_to_string(fixture.tree.state_file()).expect("deploy.toml");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready();
let err = promote(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("refuses");
assert!(matches!(err, Error::Config(_)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("--stage"), "{shown}");
assert_eq!(daemon.reload_count(), 0);
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&previous))
);
assert_eq!(
fs::read_to_string(fixture.tree.state_file()).expect("deploy.toml"),
before
);
}
#[tokio::test]
async fn promote_after_the_branch_moved_still_deploys_the_staged_sha() {
let mut fixture = fixture_with_previous_release();
let second = stage_a_build(&mut fixture, "true").await;
let third = commit_on_origin(&fixture, "third.txt");
let remote = fixture.state.remote.clone();
git::fetch(&fixture.tree.git(), &remote, "main", fixtures::TEST_BUDGET).expect("fetch");
assert_eq!(
git::remote_head(&fixture.tree.git(), "main").expect("head"),
third,
"the branch has moved on"
);
let outcome = promote(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("promotes");
assert_eq!(
outcome,
Outcome::Deployed {
sha: second.clone()
}
);
assert_eq!(fixture.state.deployed.as_deref(), Some(second.as_str()));
assert!(
!fixture.tree.release(&third).exists(),
"the head was not built"
);
}
#[tokio::test]
async fn promote_with_the_staged_release_gone_names_stage() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let second = stage_a_build(&mut fixture, "true").await;
let daemon = Shepherd::ready();
fs::remove_file(fixture.tree.completion(&second)).expect("marker");
let err = promote(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("no marker");
assert!(matches!(err, Error::Config(_)), "{err:?}");
assert!(err.to_string().contains("--stage"), "{err}");
fs::remove_dir_all(fixture.tree.release(&second)).expect("release");
let err = promote(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("no release");
assert!(matches!(err, Error::Config(_)), "{err:?}");
assert!(err.to_string().contains("--stage"), "{err}");
assert_eq!(daemon.reload_count(), 0);
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.staged.as_deref(), Some(second.as_str()));
}
#[tokio::test]
async fn promote_refuses_a_flockfile_the_build_changed() {
for command in [
"echo '# changed' >> Flockfile.toml",
"echo '' > Flockfile.override.toml",
] {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
stage_a_build(&mut fixture, command).await;
let daemon = Shepherd::ready();
let err = promote(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("refuses");
assert!(matches!(err, Error::Config(_)), "{command}: {err:?}");
assert_eq!(daemon.reload_count(), 0, "{command}");
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&previous)),
"{command}"
);
}
}
#[tokio::test(start_paused = true)]
async fn a_promote_that_does_not_come_up_is_rolled_back_and_stays_staged() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let second = commit_on_origin(&fixture, "second.txt");
stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages");
let outcome = promote(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
assert_eq!(fixture.state.staged.as_deref(), Some(second.as_str()));
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.deployed.as_deref(), Some(previous.as_str()));
assert_eq!(written.staged.as_deref(), Some(second.as_str()));
assert!(
fixture.tree.release(&second).exists(),
"retention spares what is staged"
);
}
#[tokio::test]
async fn staging_builds_the_head_and_records_it_without_moving_current() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let second = commit_building(&fixture, "touch built.marker", "second.txt");
let staged = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages");
assert_eq!(
staged,
Staged::Built {
sha: second.clone()
}
);
assert!(
fixture.tree.release(&second).join("built.marker").exists(),
"the build ran in the release"
);
assert_eq!(fixture.state.staged.as_deref(), Some(second.as_str()));
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.staged.as_deref(), Some(second.as_str()));
assert_eq!(written.deployed.as_deref(), Some(previous.as_str()));
assert_eq!(written.failed, None);
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
Some(fixture.tree.release(&previous))
);
}
#[tokio::test]
async fn staging_the_deployed_head_says_it_is_already_live() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
let staged = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("answers");
assert_eq!(staged, Staged::AlreadyLive { sha: live });
assert_eq!(fixture.state.staged, None);
}
#[tokio::test]
async fn staging_twice_is_a_no_op_the_second_time() {
let mut fixture = fixture_with_previous_release();
let second = commit_building(&fixture, "touch built.marker", "second.txt");
stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages");
let marker = fixture.tree.release(&second).join("built.marker");
fs::remove_file(&marker).expect("marker");
let again = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("answers");
assert_eq!(again, Staged::AlreadyStaged { sha: second });
assert!(!marker.exists(), "nothing was built");
}
#[tokio::test]
async fn staging_again_rebuilds_a_staged_release_that_is_gone_or_unfinished() {
let mut fixture = fixture_with_previous_release();
let second = stage_a_build(&mut fixture, "touch built.marker").await;
let release = fixture.tree.release(&second);
fs::remove_file(fixture.tree.completion(&second)).expect("marker");
let again = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages again");
assert_eq!(
again,
Staged::Built {
sha: second.clone()
}
);
assert!(fixture.tree.completion(&second).is_file());
fs::remove_dir_all(&release).expect("release");
let again = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages again");
assert_eq!(
again,
Staged::Built {
sha: second.clone()
}
);
assert!(release.join("built.marker").exists(), "it was built again");
assert_eq!(fixture.state.staged.as_deref(), Some(second.as_str()));
}
#[tokio::test]
async fn staging_a_newer_head_replaces_the_staged_sha() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages");
let third = commit_on_origin(&fixture, "third.txt");
let staged = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("stages again");
assert_eq!(staged, Staged::Built { sha: third.clone() });
assert_eq!(fixture.state.staged.as_deref(), Some(third.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml")
.staged
.as_deref(),
Some(third.as_str())
);
}
#[tokio::test]
async fn a_failing_build_records_nothing_and_does_not_hold_the_sha() {
let mut fixture = fixture_with_previous_release();
commit_building(&fixture, "exit 3", "second.txt");
let err = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect_err("the build fails");
assert!(matches!(err, Error::Build { status: Some(3) }), "{err:?}");
assert_eq!(fixture.state.staged, None);
assert_eq!(fixture.state.failed, None);
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.staged, None);
assert_eq!(written.failed, None);
}
#[tokio::test]
async fn a_watched_target_is_refused_and_nothing_is_fetched() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
fixture.state.watch = Watch::Auto;
fixture
.state
.write(&fixture.tree.state_file())
.expect("write deploy.toml");
commit_on_origin(&fixture, "second.txt");
let err = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect_err("refuses");
assert!(matches!(err, Error::Config(_)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("--watch manual"), "{shown}");
assert_eq!(fixture.state.staged, None);
assert_eq!(
git::remote_head(&fixture.tree.git(), "main").expect("head"),
previous,
"the clone still has the head it had before"
);
}
#[tokio::test]
async fn a_tree_the_cutover_never_landed_on_is_refused() {
let mut fixture = fixture_before_any_release();
let err = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect_err("refuses");
assert!(matches!(err, Error::NotCutOver { .. }), "{err:?}");
assert_eq!(fixture.state.staged, None);
}
#[tokio::test]
async fn a_stage_answers_from_the_record_on_disk_not_the_callers_copy() {
let mut fixture = fixture_with_previous_release();
let second = commit_building(&fixture, "touch built.marker", "second.txt");
State {
staged: Some(second.clone()),
..fixture.state.clone()
}
.write(&fixture.tree.state_file())
.expect("another stage's record");
fs::create_dir_all(fixture.tree.release(&second)).expect("release");
fs::create_dir_all(fixture.tree.completions()).expect("markers");
fs::write(fixture.tree.completion(&second), b"").expect("marker");
let staged = stage(&fixture.tree, &mut fixture.state, &fixtures::dog_config())
.await
.expect("answers");
assert_eq!(
staged,
Staged::AlreadyStaged {
sha: second.clone()
}
);
assert!(
!fixture.tree.release(&second).join("built.marker").exists(),
"nothing was built"
);
assert_eq!(fixture.state.staged.as_deref(), Some(second.as_str()));
}
#[test]
fn a_branch_set_while_a_stage_builds_is_not_overwritten() {
let mut fixture = fixture_with_previous_release();
fixture.state.failed = Some(fixtures::OTHER_SHA.to_owned());
fixture
.state
.write(&fixture.tree.state_file())
.expect("write deploy.toml");
let mut in_flight = fixture.state.clone();
crate::retarget::set_branch(&fixture.tree, "release").expect("--branch release");
let err = record_staged(&fixture.tree, &mut in_flight, fixtures::SHA)
.expect_err("the branch moved");
assert!(matches!(err, Error::Config(_)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("main to release"), "{shown}");
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.branch, "release");
assert_eq!(written.failed, None);
assert_eq!(written.staged, None);
assert_eq!(in_flight, written, "memory follows the disk");
}
#[test]
fn a_stage_leaves_the_record_fields_it_does_not_own() {
let fixture = fixture_with_previous_release();
let mut in_flight = fixture.state.clone();
State {
failed: Some(fixtures::OTHER_SHA.to_owned()),
..fixture.state.clone()
}
.write(&fixture.tree.state_file())
.expect("a hold written meanwhile");
record_staged(&fixture.tree, &mut in_flight, fixtures::SHA).expect("records");
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.staged.as_deref(), Some(fixtures::SHA));
assert_eq!(written.failed.as_deref(), Some(fixtures::OTHER_SHA));
assert_eq!(in_flight, written);
}
}