Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/check-pr-base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -41,10 +41,31 @@ 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: >-
cargo ci other-workflows check-release-deps
--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
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,25 @@ 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
uses: dsherret/rust-toolchain-file@v1
- 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
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -62,4 +63,3 @@ EXPOSE 3000

# Define the entrypoint
ENTRYPOINT ["spacetime"]

1 change: 1 addition & 0 deletions earliest-allowed-rollback-point
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v2.11.0
14 changes: 14 additions & 0 deletions tools/ci/commands/rollback-file/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions tools/ci/commands/rollback-file/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<u64>,
}

fn target_release(repo: &Path) -> Result<Release> {
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::<toml::Table>()
.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::<Vec<_>>();
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(())
}
4 changes: 4 additions & 0 deletions tools/ci/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
186 changes: 186 additions & 0 deletions tools/rollback-coordination/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<bool> {
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<bool> {
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<Release> {
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<Option<Release>> {
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<Release> {
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,
Expand Down Expand Up @@ -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"]);
Expand Down
Loading