use assert_cmd::Command;
use chrono::Local;
use predicates::prelude::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command as ProcessCommand;
use tempfile::TempDir;
#[test]
fn publish_release_seals_publishes_verifies_and_then_skips_idempotently() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
let first_publish = fixture
.command()
.args(["publish", "--release", "--yes"])
.assert()
.success();
let progress = String::from_utf8_lossy(&first_publish.get_output().stderr);
assert_in_order(
&progress,
&[
"[release-tool] Resolve: repository snapshot",
"[release-tool] Doctor: tools and GitHub permissions",
"[release-tool] Inspect: target app is ABSENT",
"[release-tool] Prepare: project preflight",
"[release-tool] Prepare: target app",
"[release-tool] Seal: tag ",
"[release-tool] Publish: target app",
"[release-tool] Verify: target app",
],
);
let project_output = String::from_utf8_lossy(&first_publish.get_output().stdout);
assert!(
project_output.contains("[project] preflight running"),
"project preflight stdout was hidden:\n{project_output}"
);
assert!(
progress.contains("[project] image build running"),
"project build stderr was hidden:\n{progress}"
);
first_publish
.stdout(predicate::str::contains("release-tool 0.3.1"))
.stdout(predicate::str::contains(format!("published app at {tag}")));
assert_eq!(fixture.remote_commit(&tag), fixture.head());
assert_eq!(
fs::read_to_string(&fixture.command_log).unwrap(),
format!("preflight {tag}\nbuild example:{tag}\ncheck example:{tag}\n")
);
assert_eq!(
fs::read_to_string(&fixture.write_log).unwrap(),
format!("create {tag}\n")
);
let commands_after_first = fs::read_to_string(&fixture.command_log).unwrap();
let writes_after_first = fs::read_to_string(&fixture.write_log).unwrap();
let repeated_publish = fixture
.command()
.args(["publish", "--release", "--yes"])
.assert()
.success();
let repeated_progress = String::from_utf8_lossy(&repeated_publish.get_output().stderr);
assert_in_order(
&repeated_progress,
&[
"[release-tool] Resolve: repository snapshot",
"[release-tool] Doctor: tools and GitHub permissions",
"[release-tool] Inspect: target app is COMPLETE",
"[release-tool] Verify: existing target app",
],
);
for skipped_phase in ["Prepare", "Seal", "Publish"] {
assert!(
!repeated_progress.contains(&format!("[release-tool] {skipped_phase}:")),
"idempotent publish unexpectedly reported {skipped_phase}:\n{repeated_progress}"
);
}
repeated_publish.stdout(predicate::str::contains(format!(
"verified existing app at {tag}; skipped"
)));
assert_eq!(
fs::read_to_string(&fixture.command_log).unwrap(),
commands_after_first
);
assert_eq!(
fs::read_to_string(&fixture.write_log).unwrap(),
writes_after_first
);
}
#[test]
fn publish_result_contains_final_verification_for_new_and_existing_releases() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
let result_file = fixture._root.path().join("publish-result.json");
fixture
.command()
.args([
"publish",
"--release",
"--yes",
"--result-file",
result_file.to_str().unwrap(),
])
.assert()
.success();
let first: serde_json::Value =
serde_json::from_slice(&fs::read(&result_file).unwrap()).unwrap();
assert_eq!(first["schema_version"], 1);
assert_eq!(first["tool_version"], "0.3.1");
assert_eq!(first["repository"], "computed-parameter/example");
assert_eq!(first["tag"], tag);
assert_eq!(first["commit"], fixture.head());
assert_eq!(first["targets"].as_array().unwrap().len(), 1);
assert_eq!(first["targets"][0]["name"], "app");
assert_eq!(first["targets"][0]["publisher"], "release");
assert_eq!(first["targets"][0]["skipped"], false);
assert_eq!(
first["targets"][0]["artifacts"],
serde_json::json!([
format!("example-{tag}-linux-amd64.tar.xz"),
format!("example-{tag}-linux-amd64.tar.xz.sha256"),
])
);
fs::write(&result_file, "stale result\n").unwrap();
fixture
.command()
.args([
"publish",
"--release",
"--yes",
"--result-file",
result_file.to_str().unwrap(),
])
.assert()
.success();
let repeated: serde_json::Value =
serde_json::from_slice(&fs::read(&result_file).unwrap()).unwrap();
assert_eq!(repeated["targets"][0]["skipped"], true);
assert_eq!(
repeated["targets"][0]["artifacts"],
first["targets"][0]["artifacts"]
);
}
#[test]
fn failed_publish_does_not_replace_an_existing_result() {
let fixture = PublishFixture::new();
let result_file = fixture._root.path().join("publish-result.json");
fs::write(&result_file, "previous successful result\n").unwrap();
fixture
.command()
.env("MUTATE_TRACKED", "true")
.args([
"publish",
"--release",
"--yes",
"--result-file",
result_file.to_str().unwrap(),
])
.assert()
.failure();
assert_eq!(
fs::read_to_string(result_file).unwrap(),
"previous successful result\n"
);
}
#[test]
fn prepare_cannot_modify_tracked_source_or_create_a_tag() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
fixture
.command()
.env("MUTATE_TRACKED", "true")
.args(["publish", "--release", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("Prepare modified tracked source"));
assert!(fixture.remote_tag_optional(&tag).is_none());
assert_eq!(fs::read_to_string(&fixture.write_log).unwrap(), "");
}
#[test]
fn prepare_cannot_change_head_even_when_the_new_commit_is_clean() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
fixture
.command()
.env("CHANGE_HEAD", "true")
.args(["publish", "--release", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("HEAD changed during Prepare"));
assert!(fixture.remote_tag_optional(&tag).is_none());
assert_eq!(fs::read_to_string(&fixture.write_log).unwrap(), "");
}
#[test]
fn prepare_cannot_leave_untracked_source_outside_tool_staging() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
fixture
.command()
.env("CREATE_UNTRACKED", "true")
.args(["publish", "--release", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains(
"Prepare created untracked repository files",
));
assert!(fixture.remote_tag_optional(&tag).is_none());
assert_eq!(fs::read_to_string(&fixture.write_log).unwrap(), "");
}
#[test]
fn prepare_cannot_create_unrelated_local_git_refs() {
let fixture = PublishFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
fixture
.command()
.env("CREATE_LOCAL_TAG", "true")
.args(["publish", "--release", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains(
"local Git refs changed during Prepare",
));
assert!(fixture.remote_tag_optional(&tag).is_none());
assert_eq!(fs::read_to_string(&fixture.write_log).unwrap(), "");
}
struct PublishFixture {
_root: TempDir,
work: std::path::PathBuf,
remote: std::path::PathBuf,
bin: std::path::PathBuf,
gh_state: std::path::PathBuf,
command_log: std::path::PathBuf,
write_log: std::path::PathBuf,
}
impl PublishFixture {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let work = root.path().join("work");
let remote = root.path().join("remote.git");
let bin = root.path().join("bin");
let gh_state = root.path().join("gh-state");
let command_log = root.path().join("commands.log");
let write_log = root.path().join("writes.log");
fs::create_dir(&bin).unwrap();
fs::create_dir(&gh_state).unwrap();
fs::write(&command_log, "").unwrap();
fs::write(&write_log, "").unwrap();
run(
root.path(),
&["git", "init", "--bare", remote.to_str().unwrap()],
);
run(
root.path(),
&[
"git",
"init",
"--initial-branch=main",
work.to_str().unwrap(),
],
);
run(&work, &["git", "config", "user.name", "Release Test"]);
run(
&work,
&["git", "config", "user.email", "release@example.com"],
);
fs::write(
work.join("release.toml"),
r#"required_version = "0.1.0"
[repository]
github = "computed-parameter/example"
branch = "main"
[hooks]
preflight = ["project-check", "{version}"]
[publishers.release]
kind = "github_release"
title = "Example {version}"
prerelease = true
[[targets]]
name = "app"
kind = "docker_archive"
publisher = "release"
default = true
platform = "linux/amd64"
image = "example:{version}"
asset = "example-{version}-linux-amd64.tar.xz"
build = ["build-image", "{image}"]
local_check = ["check-image", "{image}"]
"#,
)
.unwrap();
fs::write(work.join("source"), "release source\n").unwrap();
run(&work, &["git", "add", "--", "release.toml", "source"]);
run(&work, &["git", "commit", "-m", "Release source"]);
run(
&work,
&["git", "remote", "add", "origin", remote.to_str().unwrap()],
);
run(&work, &["git", "push", "-u", "origin", "main"]);
executable(
&bin.join("git"),
r#"#!/usr/bin/env bash
set -eu
if [ "$*" = "remote get-url origin" ]; then
printf 'https://github.com/computed-parameter/example.git\n'
exit 0
fi
exec /usr/bin/git "$@"
"#,
);
executable(
&bin.join("project-check"),
"#!/usr/bin/env bash\nset -eu\nprintf '[project] preflight running\\n'\nprintf 'preflight %s\\n' \"$1\" >> \"$COMMAND_LOG\"\n",
);
executable(
&bin.join("build-image"),
"#!/usr/bin/env bash\nset -eu\nprintf '[project] image build running\\n' >&2\nprintf 'build %s\\n' \"$1\" >> \"$COMMAND_LOG\"\nif [ \"${MUTATE_TRACKED:-false}\" = true ]; then printf 'mutated\\n' > \"$PROJECT_SOURCE\"; fi\nif [ \"${CHANGE_HEAD:-false}\" = true ]; then printf 'committed mutation\\n' > \"$PROJECT_SOURCE\"; git add -- \"$PROJECT_SOURCE\"; git commit -m 'Mutate during Prepare' >/dev/null; fi\nif [ \"${CREATE_UNTRACKED:-false}\" = true ]; then printf 'unexpected\\n' > unexpected-source; fi\nif [ \"${CREATE_LOCAL_TAG:-false}\" = true ]; then git tag prepare-side-effect; fi\n",
);
executable(
&bin.join("check-image"),
"#!/usr/bin/env bash\nset -eu\nprintf 'check %s\\n' \"$1\" >> \"$COMMAND_LOG\"\n",
);
executable(
&bin.join("docker"),
r#"#!/usr/bin/env bash
set -eu
case "$1 $2" in
"image inspect") printf 'linux/amd64\n' ;;
"save -o") printf 'packaged image bytes\n' > "$3" ;;
*) printf 'unexpected docker: %s\n' "$*" >&2; exit 2 ;;
esac
"#,
);
executable(
&bin.join("xz"),
"#!/usr/bin/env bash\nset -eu\ncp \"$2\" \"$2.xz\"\nrm \"$2\"\n",
);
executable(&bin.join("gh"), FAKE_GH);
Self {
_root: root,
work,
remote,
bin,
gh_state,
command_log,
write_log,
}
}
fn command(&self) -> Command {
let mut command = Command::cargo_bin("release-tool").unwrap();
command
.current_dir(&self.work)
.env(
"PATH",
format!("{}:{}", self.bin.display(), std::env::var("PATH").unwrap()),
)
.env("FAKE_GH_STATE", &self.gh_state)
.env("COMMAND_LOG", &self.command_log)
.env("PROJECT_SOURCE", self.work.join("source"))
.env("WRITE_LOG", &self.write_log);
command
}
fn head(&self) -> String {
output(&self.work, "git", &["rev-parse", "HEAD"])
}
fn remote_commit(&self, tag: &str) -> String {
output(
self._root.path(),
"git",
&[
"--git-dir",
self.remote.to_str().unwrap(),
"rev-parse",
&format!("refs/tags/{tag}^{{}}"),
],
)
}
fn remote_tag_optional(&self, tag: &str) -> Option<String> {
let result = ProcessCommand::new("git")
.args([
"--git-dir",
self.remote.to_str().unwrap(),
"rev-parse",
&format!("refs/tags/{tag}"),
])
.output()
.unwrap();
result
.status
.success()
.then(|| String::from_utf8(result.stdout).unwrap().trim().to_owned())
}
}
const FAKE_GH: &str = r#"#!/usr/bin/python3
import json
import os
import pathlib
import shutil
import sys
args = sys.argv[1:]
state = pathlib.Path(os.environ["FAKE_GH_STATE"])
assets = state / "assets"
assets.mkdir(exist_ok=True)
release_file = state / "release.json"
if args == ["--version"]:
print("gh version test")
elif args == ["auth", "status", "--hostname", "github.com"]:
pass
elif args == ["auth", "token", "--hostname", "github.com"]:
print("super-secret-token")
elif args == ["api", "--hostname", "github.com", "user", "--jq", ".login"]:
print("release-bot")
elif args == ["repo", "view", "computed-parameter/example", "--json", "nameWithOwner,viewerPermission"]:
print(json.dumps({"nameWithOwner": "computed-parameter/example", "viewerPermission": "WRITE"}))
elif args[:1] == ["api"]:
if not release_file.exists():
print("HTTP 404: Not Found", file=sys.stderr)
sys.exit(1)
release = json.loads(release_file.read_text())
release["assets"] = [{"name": path.name} for path in sorted(assets.iterdir())]
print(json.dumps(release))
elif args[:2] == ["release", "create"]:
tag = args[2]
commit = args[args.index("--target") + 1]
for argument in args[3:]:
path = pathlib.Path(argument)
if path.is_file():
shutil.copyfile(path, assets / path.name)
release_file.write_text(json.dumps({
"tag_name": tag,
"target_commitish": commit,
"prerelease": "--prerelease" in args,
}))
with open(os.environ["WRITE_LOG"], "a") as log:
log.write(f"create {tag}\n")
elif args[:2] == ["release", "upload"]:
for argument in args[3:]:
path = pathlib.Path(argument)
if path.is_file():
shutil.copyfile(path, assets / path.name)
with open(os.environ["WRITE_LOG"], "a") as log:
log.write(f"upload {args[2]}\n")
elif args[:2] == ["release", "download"]:
name = args[args.index("--pattern") + 1]
directory = pathlib.Path(args[args.index("--dir") + 1])
directory.mkdir(parents=True, exist_ok=True)
shutil.copyfile(assets / name, directory / name)
else:
print(f"unexpected gh args: {args!r}", file=sys.stderr)
sys.exit(2)
"#;
fn executable(path: &Path, source: &str) {
fs::write(path, source).unwrap();
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
fn run(directory: &Path, command: &[&str]) {
let result = ProcessCommand::new(command[0])
.args(&command[1..])
.current_dir(directory)
.output()
.unwrap();
assert!(
result.status.success(),
"command {:?} failed: {}",
command,
String::from_utf8_lossy(&result.stderr)
);
}
fn output(directory: &Path, program: &str, args: &[&str]) -> String {
let result = ProcessCommand::new(program)
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(result.status.success(), "{program} {args:?} failed");
String::from_utf8(result.stdout).unwrap().trim().to_owned()
}
fn assert_in_order(actual: &str, expected: &[&str]) {
let mut remaining = actual;
for item in expected {
let index = remaining
.find(item)
.unwrap_or_else(|| panic!("missing `{item}` after previous progress in:\n{actual}"));
remaining = &remaining[index + item.len()..];
}
}