diff --git a/.github/workflows/check-pr-base.yml b/.github/workflows/check-pr-base.yml index 90801f72e02..dd65174561f 100644 --- a/.github/workflows/check-pr-base.yml +++ b/.github/workflows/check-pr-base.yml @@ -30,7 +30,7 @@ jobs: # This builds a small CI helper and inspects release metadata and git history. runs-on: spacetimedb-linux-small permissions: - contents: read + contents: write pull-requests: read env: GH_TOKEN: ${{ github.token }} @@ -41,6 +41,7 @@ jobs: with: # We inspect local tags and git commits in order to determine whether things have been released fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - uses: dsherret/rust-toolchain-file@v1 - name: Check release dependencies run: >- @@ -48,3 +49,23 @@ jobs: --current-repo . --allowed-reference-repo . --pr-number "${{ github.event.pull_request.number }}" + - name: Generate rollback file + run: >- + cargo ci other-workflows rollback-file + --pr-number "${{ github.event.pull_request.number }}" + - name: Commit updated rollback file + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if git diff --quiet -- earliest-allowed-rollback-point; then + exit 0 + fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add earliest-allowed-rollback-point + git commit -m "Update earliest allowed rollback point" + git push origin "HEAD:${HEAD_REF}" + - name: Require forked PR to commit generated rollback file + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: git diff --exit-code -- earliest-allowed-rollback-point diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a36348b6bb7..090d5d46142 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,10 +82,15 @@ jobs: build-cargo-release: needs: validate-release-invoker runs-on: spacetimedb-linux + env: + GH_TOKEN: ${{ github.token }} + RUST_LOG: rollback_coordination=info steps: - name: Checkout uses: actions/checkout@v4 with: + # Rollback coordination needs the release tags and the commits between them. + fetch-depth: 0 submodules: recursive - name: Set up Rust @@ -93,6 +98,9 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + - name: Check rollback file + run: cargo ci other-workflows rollback-file --check + - name: Install cargo-release run: | cargo install --path tools/release diff --git a/Cargo.lock b/Cargo.lock index a13e6d9b3a2..5787931e51b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1050,6 +1050,17 @@ dependencies = [ "duct", ] +[[package]] +name = "ci-rollback-file" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.5.50", + "rollback-coordination", + "toml 0.8.23", + "tracing-subscriber", +] + [[package]] name = "ci-run-spacetime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e126213341d..41599d30a5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ members = [ "tools/ci/commands/global-json-policy", "tools/ci/commands/publish-checks", "tools/ci/commands/run-spacetime", + "tools/ci/commands/rollback-file", "tools/ci/commands/typescript-test", "tools/ci/commands/version-upgrade-check", "tools/ci/commands/docs", diff --git a/Dockerfile b/Dockerfile index e37332b8af1..c41059398f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ RUN rustup target add wasm32-unknown-unknown # Copy over SpacetimeDB COPY --from=builder --chmod=755 /usr/src/app/target/release/spacetimedb-standalone /usr/src/app/target/release/spacetimedb-cli /opt/spacetime/ +COPY earliest-allowed-rollback-point /usr/share/spacetimedb/rollback-points/public RUN ln -s /opt/spacetime/spacetimedb-cli /usr/local/bin/spacetime # Create and switch to a non-root user @@ -62,4 +63,3 @@ EXPOSE 3000 # Define the entrypoint ENTRYPOINT ["spacetime"] - diff --git a/earliest-allowed-rollback-point b/earliest-allowed-rollback-point new file mode 100644 index 00000000000..b0c3c5ce99b --- /dev/null +++ b/earliest-allowed-rollback-point @@ -0,0 +1 @@ +v2.11.0 diff --git a/tools/ci/commands/rollback-file/Cargo.toml b/tools/ci/commands/rollback-file/Cargo.toml new file mode 100644 index 00000000000..f8ee40c8a39 --- /dev/null +++ b/tools/ci/commands/rollback-file/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ci-rollback-file" +version = "0.1.0" +edition.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +rollback-coordination = { path = "../../../rollback-coordination" } +toml.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[lints] +workspace = true diff --git a/tools/ci/commands/rollback-file/src/main.rs b/tools/ci/commands/rollback-file/src/main.rs new file mode 100644 index 00000000000..14c438a8f12 --- /dev/null +++ b/tools/ci/commands/rollback-file/src/main.rs @@ -0,0 +1,48 @@ +#![allow(clippy::disallowed_macros)] + +use anyhow::{Context, Result}; +use clap::Parser; +use rollback_coordination::{rollback_point_for_repo, write_or_check_rollback_point, Gh, Release}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Parser)] +#[command(about = "Generates or checks the repository's earliest allowed rollback point.")] +struct Args { + #[arg(long, default_value = ".")] + repo: PathBuf, + #[arg(long)] + check: bool, + /// Include the current PR, whose number is not yet present in its commit subjects. + #[arg(long)] + pr_number: Option, +} + +fn target_release(repo: &Path) -> Result { + let manifest_path = repo.join("Cargo.toml"); + let manifest = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))? + .parse::() + .with_context(|| format!("failed to parse {}", manifest_path.display()))?; + let version = manifest + .get("workspace") + .and_then(|workspace| workspace.get("package")) + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .context("workspace.package.version is missing from Cargo.toml")?; + Release::from_tag(&format!("v{version}"))?.context("workspace package version is not a compatible release") +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let args = Args::parse(); + let target = target_release(&args.repo)?; + let pull_requests = args.pr_number.into_iter().collect::>(); + let point = rollback_point_for_repo(&Gh, &args.repo, &[&args.repo], &target, false, &pull_requests, &[])?; + write_or_check_rollback_point(&args.repo, &point, args.check)?; + println!("Earliest allowed rollback point: {point}"); + Ok(()) +} diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 2be03751cc1..d79db09a798 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -84,6 +84,10 @@ const COMMANDS: &[Command] = &[ path: &["other-workflows", "run-spacetime"], package: "ci-run-spacetime", }, + Command { + path: &["other-workflows", "rollback-file"], + package: "ci-rollback-file", + }, Command { path: &["other-workflows", "check-release-deps"], package: "ci-check-release-deps", diff --git a/tools/rollback-coordination/src/lib.rs b/tools/rollback-coordination/src/lib.rs index 8b941b8732a..5b473fe4e89 100644 --- a/tools/rollback-coordination/src/lib.rs +++ b/tools/rollback-coordination/src/lib.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use duct::cmd; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; pub mod gh; @@ -10,6 +11,8 @@ mod release; pub use gh::{Gh, Github}; pub use release::Release; +pub const ROLLBACK_POINT_FILE: &str = "earliest-allowed-rollback-point"; + #[derive(Clone, Debug)] struct Repository { path: PathBuf, @@ -38,6 +41,120 @@ pub fn load_releases(repo_path: &Path, ignore_incompatible_tags: bool) -> Result Ok(releases.into_iter().rev().collect()) } +fn release_is_ancestor(repo_path: &Path, release: &Release) -> Result { + let status = cmd!("git", "merge-base", "--is-ancestor", release.to_string(), "HEAD") + .dir(repo_path) + .unchecked() + .run() + .with_context(|| format!("failed to inspect release history in {}", repo_path.display()))?; + Ok(status.status.success()) +} + +fn release_is_head(repo_path: &Path, release: &Release) -> Result { + let release_commit = cmd!("git", "rev-parse", format!("{}^{{commit}}", release)) + .dir(repo_path) + .read() + .with_context(|| format!("failed to resolve {release} in {}", repo_path.display()))?; + let head = cmd!("git", "rev-parse", "HEAD") + .dir(repo_path) + .read() + .with_context(|| format!("failed to resolve HEAD in {}", repo_path.display()))?; + Ok(release_commit == head) +} + +/// Returns the release from which rollback state and new PRs should be read. +/// +/// A release tag at `HEAD` is the release being validated, so it is excluded. +/// On later commits, that same tag becomes the base for the next release. +pub fn previous_release(repo_path: &Path, target_release: &Release, ignore_incompatible_tags: bool) -> Result { + for release in load_releases(repo_path, ignore_incompatible_tags)? { + if &release <= target_release + && release_is_ancestor(repo_path, &release)? + && !release_is_head(repo_path, &release)? + { + return Ok(release); + } + } + Err(anyhow!( + "{} has no compatible release tag before {target_release}", + repo_path.display() + )) +} + +pub fn read_rollback_point_at(repo_path: &Path, release: &Release) -> Result> { + let spec = format!("{release}:{ROLLBACK_POINT_FILE}"); + let output = cmd!("git", "show", &spec) + .dir(repo_path) + .stdout_capture() + .stderr_capture() + .unchecked() + .run()?; + if !output.status.success() { + tracing::info!(%release, "Release does not contain a rollback-point file"); + return Ok(None); + } + let contents = String::from_utf8(output.stdout).context("rollback-point file is not UTF-8")?; + Release::from_tag(contents.trim()) + .with_context(|| format!("{spec} contains an invalid release tag"))? + .with_context(|| format!("{spec} does not contain a compatible release tag")) + .map(Some) +} + +/// Computes the rollback point for `HEAD` without trusting the checked-out +/// rollback-point file, which is a generated output of this computation. +pub fn rollback_point_for_repo( + github: &impl Github, + current_repo: &Path, + allowed_reference_repos: &[&Path], + target_release: &Release, + ignore_incompatible_tags: bool, + additional_pull_requests: &[u64], + extra_constraints: &[Release], +) -> Result { + let base = previous_release(current_repo, target_release, ignore_incompatible_tags)?; + tracing::info!(%base, %target_release, "Computing rollback point"); + let previous_point = read_rollback_point_at(current_repo, &base)?; + let mut pull_requests = pull_requests_in_range(current_repo, &base.to_string(), "HEAD")?; + pull_requests.extend_from_slice(additional_pull_requests); + pull_requests.sort_unstable(); + pull_requests.dedup(); + let point = earliest_rollback_point( + github, + current_repo, + allowed_reference_repos, + None, + ignore_incompatible_tags, + &pull_requests, + )?; + let minor = Release::from_tag(&format!("v{}.{}.0", target_release.major(), target_release.minor()))? + .expect("a canonical minor release is compatible"); + Ok([Some(minor), previous_point, point] + .into_iter() + .flatten() + .chain(extra_constraints.iter().cloned()) + .max() + .expect("the minor release always supplies a rollback point")) +} + +pub fn write_or_check_rollback_point(repo_path: &Path, expected: &Release, check: bool) -> Result<()> { + let path = repo_path.join(ROLLBACK_POINT_FILE); + if check { + let contents = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let actual = Release::from_tag(contents.trim()) + .with_context(|| format!("{} contains an invalid release tag", path.display()))? + .with_context(|| format!("{} does not contain a compatible release tag", path.display()))?; + if actual != *expected { + return Err(anyhow!( + "{} contains {actual}, but the expected rollback point is {expected}; regenerate the rollback-point file", + path.display() + )); + } + } else { + fs::write(&path, format!("{expected}\n")).with_context(|| format!("failed to write {}", path.display()))?; + } + Ok(()) +} + // Loads repos found a specific paths, and returns a mapping from repo name to repo fn load_repos( github: &impl Github, @@ -319,6 +436,75 @@ mod tests { assert_eq!(pull_requests, vec![42]); } + #[test] + fn exact_head_release_uses_its_predecessor_as_the_base() { + let repository = repository(&["v2.7.0"]); + commit(repository.path(), "release", "Release commit"); + git(repository.path(), &["tag", "v2.8.0"]); + let target = Release::from_tag("v2.8.0").unwrap().unwrap(); + assert_eq!( + previous_release(repository.path(), &target, false).unwrap(), + Release::from_tag("v2.7.0").unwrap().unwrap() + ); + + commit(repository.path(), "next", "Post-release commit"); + assert_eq!(previous_release(repository.path(), &target, false).unwrap(), target); + } + + #[test] + fn reads_the_rollback_point_from_a_release_tag() { + let repository = repository(&["v2.7.0"]); + fs::write(repository.path().join(ROLLBACK_POINT_FILE), "v2.7.0\n").unwrap(); + git(repository.path(), &["add", ROLLBACK_POINT_FILE]); + git(repository.path(), &["commit", "-qm", "Add rollback point"]); + git(repository.path(), &["tag", "v2.8.0"]); + let release = Release::from_tag("v2.8.0").unwrap().unwrap(); + assert_eq!( + read_rollback_point_at(repository.path(), &release).unwrap(), + Some(Release::from_tag("v2.7.0").unwrap().unwrap()) + ); + } + + #[test] + fn computes_from_the_tagged_floor_without_trusting_the_worktree_file() { + let repository = repository(&["v2.8.0"]); + fs::write(repository.path().join(ROLLBACK_POINT_FILE), "v2.8.1\n").unwrap(); + git(repository.path(), &["add", ROLLBACK_POINT_FILE]); + git(repository.path(), &["commit", "-qm", "Record rollback point"]); + git(repository.path(), &["tag", "v2.8.1"]); + fs::write(repository.path().join(ROLLBACK_POINT_FILE), "v2.8.0\n").unwrap(); + git(repository.path(), &["add", ROLLBACK_POINT_FILE]); + git(repository.path(), &["commit", "-qm", "Stale worktree value"]); + let path = repository.path().canonicalize().unwrap(); + let github = FakeGithub { + names: HashMap::from([(path, "o/r".into())]), + responses: HashMap::new(), + }; + let target = Release::from_tag("v2.8.2").unwrap().unwrap(); + assert_eq!( + rollback_point_for_repo( + &github, + repository.path(), + &[repository.path()], + &target, + false, + &[], + &[] + ) + .unwrap(), + Release::from_tag("v2.8.1").unwrap().unwrap() + ); + } + + #[test] + fn check_rejects_a_stale_rollback_point() { + let repository = repository(&["v2.7.0"]); + fs::write(repository.path().join(ROLLBACK_POINT_FILE), "v2.7.0\n").unwrap(); + let expected = Release::from_tag("v2.8.0").unwrap().unwrap(); + let error = write_or_check_rollback_point(repository.path(), &expected, true).unwrap_err(); + assert!(error.to_string().contains("expected rollback point is v2.8.0")); + } + #[test] fn reports_all_unsatisfied_dependencies() { let repository = repository(&["v2.7.0", "v2.8.0"]);