fabro/lib/crates/fabro-dev/tests/it/release.rs
Bryan Helmkamp b5de404354
Some checks failed
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
ci: lock Cargo resolution and fix cancellation flake (#461)
## Summary

Enforce Cargo lockfile use across CI and release automation so jobs fail
on stale `Cargo.lock` state instead of resolving dependencies
implicitly. This adds `--locked` to Rust CI, release builds/tests,
nightly release tagging, the TypeScript workflow's embedded Rust build,
and helper-owned Cargo calls in `fabro-dev`.

This also fixes the Linux CI flake exposed by the PR: canceling a
durably blocked in-process run could take the abort path while the
workflow was still unwinding a human-input gate, causing
`run.failed(cancelled)` to be followed by `run.unblocked`. That invalid
event order broke projection rebuilds and made `GET /runs/{id}` return
404. Cancellation now uses the durable lifecycle status when selecting
the in-process blocked-run path, so the pending interview is cancelled
before the terminal event is emitted.

The release command's intentional `cargo update --workspace` step is
unchanged, because that step updates `Cargo.lock` after bumping the
workspace version.

## Testing

- `cargo nextest run --locked -p fabro-dev --features dev -E
'test(dry_run_computes_stable_version_from_date) |
test(dry_run_prints_equivalent_build_commands)'`
- `cargo --locked dev release --dry-run --skip-tests --release-date
2026-01-01`
- `cargo --locked dev docs check`
- `cargo nextest run --locked -p fabro-server --features test-support
cancel_durably_blocked_in_process_run_cancels_pending_interview_without_abort_signal
--status-level fail --final-status-level fail --show-progress none`
- `cargo nextest run --locked -p fabro-server --features test-support
--test it scenario::lifecycle --profile ci --status-level fail
--final-status-level fail --show-progress none --no-fail-fast`
- Linux Docker stress reproduction: `cargo nextest run --locked -p
fabro-server --features test-support --test it
scenario::lifecycle::full_http_lifecycle_cancel --profile ci
--stress-count 200 --status-level fail --final-status-level fail
--show-progress none`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --locked -p fabro-dev --features dev
--all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy --locked -p fabro-server --features
test-support --all-targets -- -D warnings`
- `git diff --check`

Full `cargo nextest run --locked -p fabro-dev --features dev` currently
has two unrelated policy-test failures:
`policy::catalog_builtin_references_stay_in_allowlist` and
`policy::workflow_template_rendering_call_sites_stay_in_allowlist`.

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)

---------

Co-authored-by: Release Repro <release-repro@example.com>
2026-05-30 15:07:41 -04:00

226 lines
5.9 KiB
Rust

use std::path::Path;
use std::process::Command;
use super::{fabro_dev, output_text, write_file};
#[expect(
clippy::disallowed_methods,
reason = "integration tests intentionally shell out to git in temporary fixture repositories"
)]
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git should run");
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
output_text(&output.stdout),
output_text(&output.stderr)
);
}
fn release_fixture() -> tempfile::TempDir {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"Cargo.toml",
r#"[workspace]
members = []
[workspace.package]
version = "0.1.0"
"#,
);
git(fixture.path(), &["init"]);
git(fixture.path(), &["config", "user.name", "Release Test"]);
git(fixture.path(), &[
"config",
"user.email",
"release-test@example.com",
]);
git(fixture.path(), &["add", "Cargo.toml"]);
git(fixture.path(), &["commit", "-m", "initial"]);
fixture
}
#[test]
fn help_lists_release_flags() {
let output = fabro_dev()
.args(["release", "--help"])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
for flag in ["--dry-run", "--skip-tests", "--release-date", "--nightly"] {
assert!(
stdout.contains(flag),
"release help should list {flag}:\n{stdout}"
);
}
assert!(
!stdout.contains("[PRERELEASE_LABEL]"),
"release help should not list a prerelease positional:\n{stdout}"
);
}
#[test]
fn dry_run_computes_stable_version_from_date() {
let fixture = release_fixture();
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("Releasing 0.100.0 (tag v0.100.0)"),
"dry-run should compute base version from date:\n{stdout}"
);
assert!(
stdout.contains("cargo --locked dev spa refresh"),
"dry-run should print one SPA refresh command:\n{stdout}"
);
assert!(
!stdout.contains("git diff --exit-code -- lib/crates/fabro-spa/assets"),
"dry-run should not print a separate SPA asset diff command:\n{stdout}"
);
assert!(
stdout.contains("git tag -a v0.100.0 -m v0.100.0"),
"dry-run should print release tag command:\n{stdout}"
);
assert!(
stdout.contains(
"unset GH_TOKEN GITHUB_TOKEN && SEGMENT_WRITE_KEY=fake-for-local-smoke cargo nextest run --locked"
),
"dry-run should show release tests without inherited GitHub tokens:\n{stdout}"
);
}
#[test]
fn dry_run_increments_existing_prerelease_number() {
let fixture = release_fixture();
git(fixture.path(), &["tag", "v0.100.0-nightly.0"]);
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
"--nightly",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("Releasing 0.100.0-nightly.1 (tag v0.100.0-nightly.1)"),
"dry-run should increment existing nightly tag:\n{stdout}"
);
}
#[test]
fn positional_nightly_fails_with_clap_error() {
let output = fabro_dev()
.args(["release", "nightly"])
.assert()
.failure()
.code(2)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("unexpected argument 'nightly'"),
"nightly positional should be rejected by clap:\n{stderr}"
);
}
#[test]
fn dry_run_reports_skip_tests_without_running_release_tests() {
let fixture = release_fixture();
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
"--skip-tests",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("--skip-tests set, would skip release-mode test smoke"),
"dry-run should report skip-tests behavior:\n{stdout}"
);
assert!(
!stdout.contains("cargo nextest run"),
"dry-run with skip-tests should not print release test command:\n{stdout}"
);
}
#[test]
fn dirty_worktree_errors_unless_dry_run() {
let fixture = release_fixture();
write_file(fixture.path(), "dirty.txt", "dirty\n");
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--skip-tests",
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("working tree is dirty"),
"dirty non-dry-run release should fail before mutating:\n{stderr}"
);
}