diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 00000000..5d3e5747 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,99 @@ +name: Prepare release +run-name: Prepare Orchestral v${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: Stable MAJOR.MINOR.PATCH version to prepare from Unreleased notes + required: true + type: string + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + version-pr: + if: github.repository == 'sizzlecar/orchestral' + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + RELEASE_VERSION: ${{ inputs.version }} + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Require main and a release PR credential + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + run: | + if [ "$GITHUB_REF" != refs/heads/main ]; then + echo 'Prepare release must be dispatched on main.' >&2 + exit 1 + fi + if [ -z "$RELEASE_TOKEN" ]; then + echo 'RELEASE_GITHUB_TOKEN needs contents and pull request write access to this repository.' >&2 + exit 1 + fi + # Use a PAT or App token for both push and PR creation so ordinary required + # CI starts automatically. Do not fall back to the workflow GITHUB_TOKEN. + - uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.RELEASE_GITHUB_TOKEN }} + fetch-depth: 0 + - name: Install repository Rust toolchain + run: rustup show active-toolchain + - uses: Swatinem/rust-cache@v2 + with: + key: release-tools + - name: Prepare coordinated versions and release notes + run: | + cargo run --locked -p orchestral-release -- prepare-version --version "$RELEASE_VERSION" + git diff --check + if git show-ref --verify --quiet "refs/tags/v$RELEASE_VERSION"; then + echo 'The requested release tag already exists; no release branch will be changed.' >&2 + exit 1 + fi + git config user.name orchestral-release + git config user.email orchestral-release@users.noreply.github.com + git switch -c "release/v$RELEASE_VERSION" + git add -- Cargo.toml Cargo.lock CHANGELOG.md + # A preparer change outside its declared files must not be omitted. + git diff --exit-code + if git diff --cached --quiet; then + echo 'The release preparer produced no version changes.' >&2 + exit 1 + fi + # A retry can follow a successful push but a failed PR API request. + # Reuse identical content; never overwrite a different release proposal. + if git show-ref --verify --quiet "refs/remotes/origin/release/v$RELEASE_VERSION"; then + if [ "$(git write-tree)" != "$(git rev-parse "origin/release/v$RELEASE_VERSION^{tree}")" ]; then + echo 'The existing release branch differs from this proposal; review it before retrying.' >&2 + exit 1 + fi + else + git commit -m "chore: prepare v$RELEASE_VERSION" + git push origin "HEAD:refs/heads/release/v$RELEASE_VERSION" + fi + - name: Open or reuse the release PR + env: + GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + body="$RUNNER_TEMP/release-pr.md" + printf 'Prepare Orchestral v%s with coordinated workspace/internal dependency versions, Cargo.lock, and the reviewed Unreleased notes.\n\nNormal PR CI must pass and this change must be reviewed before merge. After merge, push the matching annotated tag to run the existing Release workflow; it creates a draft for review before public distribution.\n' "$RELEASE_VERSION" > "$body" + pr=$(gh pr list --head "release/v$RELEASE_VERSION" --base main --state open --json url --jq '.[0].url // empty') + if [ -z "$pr" ]; then + pr=$(gh pr create --head "release/v$RELEASE_VERSION" --base main \ + --title "chore: prepare v$RELEASE_VERSION" --body-file "$body") + fi + test -n "$pr" + printf 'Release PR: %s\n\nReview and merge after its required CI passes. Tagging and public distribution remain separate.\n' "$pr" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cad463..07017a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [Unreleased] + +### Model selection and reasoning controls + +- `/model` discovers model IDs from the configured service API while preserving its + endpoint, authentication, and protocol settings. Explicit profile selection remains + available through `/model profiles`; model and reasoning switches retain session history. +- `/reasoning`, `--reasoning`, and model profiles support provider-defined effort names + without a client release, preserving their spelling through discovery and requests. + Missing capability metadata no longer produces an invented menu of supported efforts; + explicit values can still be submitted for the service to validate. +- Default omits reasoning controls, `none` requests an explicit effort, and `on`/`off` + control binary thinking. Use `effort:on`, `effort:off`, or `effort:default` for literal + provider efforts that share those names. +- Native Codex choices follow its model catalog and configured defaults. Unknown effort + names remain intact, and missing catalog metadata leaves optional controls for native + validation rather than selecting fixed fallback models or effort levels. +- Web forms can clear optional model and reasoning selections back to the provider default + without weakening required sandbox or approval settings. + +### Runtime and Web reliability + +- Live session updates and reconciliation continue after the dialog that started them + closes. A recovered session clears superseded error messages while retaining genuinely + new failures and the original history. +- Process sessions wait for both stdout and stderr readers to finish before reporting + completion. Poll deadlines preserve unread output, and session cleanup stops its readers. + +### Distribution and compatibility + +- Published-release checks exercise public installers and startup of the installed CLI, + including onboarding against a local fixture service without paid model calls. +- The SDK adds optional `AgentConfig.reasoning` and extensible reasoning preference types. + Direct `AgentConfig` struct literals need the new field or a `Default` update; existing + serialized configuration remains valid. Explicit reasoning controls participate in request + and unfinished-run recovery identity; an omitted control preserves the default identity. +- OpenAI discovery adds `DiscoveredModel.reasoning`. Downstream code constructing this public + struct directly must supply `reasoning: None` when capabilities are unknown, or provide the + declared capabilities. Existing model IDs and context-capacity metadata retain their meanings. +- Agent Protocol remains v1. Native Codex validates model/effort combinations; an HTTP effort + named `ultra` does not imply Codex's native preset or multi-agent behavior. + ## [0.3.1] The v0.3.0 tag remains an unpublished candidate; v0.3.1 includes the changes below. diff --git a/tools/orchestral-release/src/main.rs b/tools/orchestral-release/src/main.rs index 58369853..42c64159 100644 --- a/tools/orchestral-release/src/main.rs +++ b/tools/orchestral-release/src/main.rs @@ -1,5 +1,6 @@ mod formula; mod registry; +mod version; use anyhow::{bail, ensure, Context, Result}; use clap::{Parser, Subcommand}; @@ -21,6 +22,11 @@ struct Args { #[derive(Subcommand)] enum Action { + /// Prepare a new stable version and promote nonempty Unreleased notes. + PrepareVersion { + #[arg(long)] + version: String, + }, /// Validate Cargo publication metadata and extract reviewed release notes. Prepare { #[arg(long)] @@ -67,6 +73,10 @@ fn main() -> Result<()> { let root = args.workspace.canonicalize().context("workspace path")?; let inventory = inventory(&root)?; match args.command { + Action::PrepareVersion { version } => { + version::prepare(&root, &inventory.version, &version)?; + println!("{version}"); + } Action::Prepare { output, tag } => { if let Some(tag) = tag { ensure!( @@ -126,23 +136,7 @@ fn inventory(root: &Path) -> Result { .context("workspace version")? .to_owned(); validate_version(&version)?; - let metadata = Command::new("cargo") - .args([ - "metadata", - "--locked", - "--offline", - "--no-deps", - "--format-version", - "1", - ]) - .current_dir(root) - .output()?; - ensure!( - metadata.status.success(), - "Cargo metadata failed: {}", - String::from_utf8_lossy(&metadata.stderr) - ); - let metadata: serde_json::Value = serde_json::from_slice(&metadata.stdout)?; + let metadata = cargo_metadata(root)?; let members = metadata["workspace_members"] .as_array() .context("workspace members")?; @@ -190,6 +184,26 @@ fn inventory(root: &Path) -> Result { }) } +fn cargo_metadata(root: &Path) -> Result { + let metadata = Command::new("cargo") + .args([ + "metadata", + "--locked", + "--offline", + "--no-deps", + "--format-version", + "1", + ]) + .current_dir(root) + .output()?; + ensure!( + metadata.status.success(), + "Cargo metadata failed: {}", + String::from_utf8_lossy(&metadata.stderr) + ); + Ok(serde_json::from_slice(&metadata.stdout)?) +} + fn validate_version(version: &str) -> Result<()> { let parts: Vec<_> = version.split('.').collect(); ensure!( diff --git a/tools/orchestral-release/src/version.rs b/tools/orchestral-release/src/version.rs new file mode 100644 index 00000000..68f7a03b --- /dev/null +++ b/tools/orchestral-release/src/version.rs @@ -0,0 +1,289 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{ensure, Context, Result}; +use serde::Deserialize; +use toml::Spanned; + +#[derive(Deserialize)] +struct Manifest { + workspace: Workspace, +} + +#[derive(Deserialize)] +struct Workspace { + package: Package, + #[serde(default)] + dependencies: BTreeMap, +} + +#[derive(Deserialize)] +struct Package { + version: Spanned, +} + +enum Dependency { + Version, + Detail { + path: Option, + version: Option>, + }, +} + +impl<'de> Deserialize<'de> for Dependency { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Dependency; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a dependency version string or table") + } + + fn visit_str( + self, + _: &str, + ) -> std::result::Result { + Ok(Dependency::Version) + } + + fn visit_map>( + self, + mut map: M, + ) -> std::result::Result { + let mut path = None; + let mut version = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "path" => path = Some(map.next_value()?), + "version" => version = Some(map.next_value()?), + _ => { + map.next_value::()?; + } + } + } + Ok(Dependency::Detail { path, version }) + } + } + // Deserialize directly: an untagged enum's intermediate value loses + // TOML source spans needed to preserve manifest comments and formatting. + deserializer.deserialize_any(Visitor) + } +} + +fn stable_version(value: &str) -> Result<[u64; 3]> { + super::validate_version(value)?; + let parts = value + .split('.') + .map(str::parse) + .collect::, _>>() + .context("semantic version component exceeds u64")?; + Ok([parts[0], parts[1], parts[2]]) +} + +fn promote_notes(changelog: &str, target: &str) -> Result { + let heading = |line: &str, version: &str| { + let prefix = format!("## [{version}]"); + line == prefix + || line + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with(" - ")) + }; + ensure!( + changelog + .lines() + .filter(|line| heading(line, "Unreleased")) + .count() + == 1, + "CHANGELOG.md must have exactly one Unreleased section" + ); + ensure!( + !changelog.lines().any(|line| heading(line, target)), + "CHANGELOG.md already contains version {target}" + ); + super::release_notes(changelog, "Unreleased")?; + let promoted = changelog.replacen("## [Unreleased]", &format!("## [{target}]"), 1); + super::release_notes(&promoted, target)?; + Ok(promoted) +} + +/// Prepare exactly the versioned workspace packages. Cargo owns lockfile refresh; +/// any unrelated lockfile change fails and restores all three original files. +pub(super) fn prepare(root: &Path, current: &str, target: &str) -> Result<()> { + ensure!( + stable_version(target)? > stable_version(current)?, + "target version must be greater than {current}" + ); + let paths = [ + root.join("Cargo.toml"), + root.join("Cargo.lock"), + root.join("CHANGELOG.md"), + ]; + let original = paths + .iter() + .map(fs::read_to_string) + .collect::>>()?; + let changelog = promote_notes(&original[2], target)?; + let metadata = super::cargo_metadata(root)?; + let members = metadata["workspace_members"] + .as_array() + .context("workspace members")?; + let mut inherited_directories = BTreeSet::new(); + let mut inherited_packages = BTreeSet::new(); + for package in metadata["packages"] + .as_array() + .context("workspace packages")? + { + if !members.contains(&package["id"]) { + continue; + } + let manifest_path = Path::new( + package["manifest_path"] + .as_str() + .context("package manifest")?, + ); + let manifest: toml::Value = fs::read_to_string(manifest_path)?.parse()?; + if manifest + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true) + { + inherited_directories.insert( + manifest_path + .parent() + .context("package directory")? + .canonicalize()?, + ); + ensure!( + package["version"] == current, + "inherited package version differs from workspace" + ); + inherited_packages.insert(package["name"].as_str().context("package name")?.to_owned()); + } + } + let manifest: Manifest = toml::from_str(&original[0])?; + ensure!( + manifest.workspace.package.version.get_ref() == current, + "workspace version changed during preparation" + ); + let mut replacements = vec![manifest.workspace.package.version.span()]; + for dependency in manifest.workspace.dependencies.values() { + match dependency { + Dependency::Detail { + path: Some(path), + version: Some(version), + } if inherited_directories.contains(&root.join(path).canonicalize()?) => { + ensure!( + version.get_ref() == current, + "internal dependency version must match workspace version {current}" + ); + replacements.push(version.span()); + } + _ => {} + } + } + replacements.sort_by_key(|range| std::cmp::Reverse(range.start)); + let mut updated_manifest = original[0].clone(); + for range in replacements { + updated_manifest.replace_range(range, &format!("\"{target}\"")); + } + let expected_lock = expected_lock(&original[1], &inherited_packages, current, target)?; + + let result = (|| -> Result<()> { + fs::write(&paths[0], updated_manifest)?; + fs::write(&paths[2], changelog)?; + let update = Command::new("cargo") + .args(["update", "--offline", "--workspace"]) + .current_dir(root) + .output()?; + ensure!( + update.status.success(), + "refresh internal lock versions: {}", + String::from_utf8_lossy(&update.stderr) + ); + let lock: toml::Value = fs::read_to_string(&paths[1])?.parse()?; + ensure!( + lock == expected_lock, + "Cargo changed lock entries beyond the inherited workspace package versions" + ); + ensure!( + super::inventory(root)?.version == target, + "prepared inventory version differs from target" + ); + Ok(()) + })(); + if let Err(error) = result { + let mut failures = Vec::new(); + for (path, text) in paths.iter().zip(&original) { + if let Err(restore) = fs::write(path, text) { + failures.push(format!("{}: {restore}", path.display())); + } + } + ensure!( + failures.is_empty(), + "{error:#}; restoring original files failed: {}", + failures.join("; ") + ); + return Err(error); + } + Ok(()) +} + +fn expected_lock( + text: &str, + inherited: &BTreeSet, + current: &str, + target: &str, +) -> Result { + let mut lock: toml::Value = text.parse()?; + let mut found = BTreeSet::new(); + for package in lock + .get_mut("package") + .and_then(toml::Value::as_array_mut) + .context("lock packages")? + { + let name = package["name"] + .as_str() + .context("locked package name")? + .to_owned(); + if package.get("source").is_none() && inherited.contains(&name) { + ensure!( + package["version"].as_str() == Some(current), + "unexpected locked version for {name}" + ); + ensure!( + found.insert(name.clone()), + "duplicate local locked package {name}" + ); + package["version"] = target.into(); + } + if let Some(dependencies) = package + .get_mut("dependencies") + .and_then(toml::Value::as_array_mut) + { + for dependency in dependencies { + if let Some(value) = dependency.as_str() { + if let Some(name) = value.strip_suffix(&format!(" {current}")) { + if inherited.contains(name) { + *dependency = format!("{name} {target}").into(); + } + } + } + } + } + } + ensure!( + &found == inherited, + "lockfile omits inherited workspace packages" + ); + Ok(lock) +} + +#[cfg(test)] +mod tests; diff --git a/tools/orchestral-release/src/version/tests.rs b/tools/orchestral-release/src/version/tests.rs new file mode 100644 index 00000000..56533f89 --- /dev/null +++ b/tools/orchestral-release/src/version/tests.rs @@ -0,0 +1,178 @@ +use super::*; + +fn command(root: &Path, program: &str, args: &[&str]) { + let output = Command::new(program) + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "{program}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn fixture() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + fs::write( + root.join("Cargo.toml"), + r#"# Keep this comment and dependency formatting. +[workspace] +resolver = "2" +members = ["sdk", "app", "testkit"] +[workspace.package] +version = "0.3.1" +edition = "2021" +[workspace.dependencies] +sdk = { path = "sdk", version = "0.3.1" } # keep this too +testkit = { path = "testkit", version = "0.1.0" } +unused-registry = "1.2.3" +unused-registry-detail = { version = "4.5.6", features = [] } +"#, + ) + .unwrap(); + for (name, manifest) in [ + ("sdk", "[package]\nname = \"sdk\"\nversion.workspace = true\nedition.workspace = true\n"), + ("app", "[package]\nname = \"app\"\nversion.workspace = true\nedition.workspace = true\n[dependencies]\nsdk.workspace = true\n"), + ("testkit", "[package]\nname = \"testkit\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n"), + ] { + fs::create_dir_all(root.join(name).join("src")).unwrap(); + fs::write(root.join(name).join("Cargo.toml"), manifest).unwrap(); + fs::write(root.join(name).join("src/lib.rs"), "").unwrap(); + } + fs::write(root.join("CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n- Reviewed behavior change.\n\n## [0.3.1]\n\nEarlier release.\n").unwrap(); + command(root, "git", &["init", "--quiet"]); + command( + root, + "git", + &[ + "-c", + "user.name=Release fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--quiet", + "--allow-empty", + "-m", + "fixture", + ], + ); + command(root, "cargo", &["generate-lockfile", "--offline"]); + directory +} + +fn snapshot(root: &Path) -> Vec { + ["Cargo.toml", "Cargo.lock", "CHANGELOG.md"] + .into_iter() + .map(|file| fs::read_to_string(root.join(file)).unwrap()) + .collect() +} + +#[test] +fn prepares_real_workspace_without_changing_independent_packages_or_notes() { + let fixture = fixture(); + let root = fixture.path(); + let original = snapshot(root); + prepare(root, "0.3.1", "0.4.0").unwrap(); + let updated = snapshot(root); + assert_eq!(updated[0], original[0].replace("\"0.3.1\"", "\"0.4.0\"")); + assert_eq!( + updated[2], + original[2].replace("## [Unreleased]", "## [0.4.0]") + ); + assert!(updated[1].contains("name = \"testkit\"\nversion = \"0.1.0\"")); + let inventory = super::super::inventory(root).unwrap(); + assert_eq!(inventory.version, "0.4.0"); + assert_eq!(inventory.packages, ["app", "sdk"]); + command( + root, + "cargo", + &["metadata", "--locked", "--offline", "--format-version", "1"], + ); +} + +#[test] +fn same_version_downgrades_and_non_stable_versions_are_rejected_before_changes() { + let fixture = fixture(); + let original = snapshot(fixture.path()); + for target in [ + "0.3.1", + "0.3.0", + "0.4.0-rc.1", + "0.4.0+build", + "v0.4.0", + "00.4.0", + "0.4", + "18446744073709551616.0.0", + ] { + assert!( + prepare(fixture.path(), "0.3.1", target).is_err(), + "accepted {target}" + ); + assert_eq!(snapshot(fixture.path()), original); + } + assert!(stable_version("0.10.0").unwrap() > stable_version("0.9.9").unwrap()); +} + +#[test] +fn empty_missing_duplicate_unreleased_and_existing_target_notes_are_rejected() { + for changelog in [ + "## [Unreleased]\n \n## [0.3.1]\nold\n", + "## [0.3.1]\nold\n", + "## [Unreleased]\nnotes\n## [Unreleased]\nother\n", + "## [Unreleased]\nnotes\n## [0.4.0]\nexists\n", + "## [Unreleased]\nnotes\n## [0.4.0] - 2026-09-18\nexists\n", + ] { + assert!(promote_notes(changelog, "0.4.0").is_err()); + } +} + +#[test] +fn lock_expectation_keeps_registry_versions_checksums_and_independent_packages() { + let lock = r#"version = 4 +[[package]] +name = "sdk" +version = "0.3.1" +[[package]] +name = "sdk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "unchanged" +[[package]] +name = "testkit" +version = "0.1.0" +dependencies = ["sdk 0.3.1", "sdk 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)"] +"#; + let expected = + expected_lock(lock, &BTreeSet::from(["sdk".to_owned()]), "0.3.1", "0.4.0").unwrap(); + let packages = expected["package"].as_array().unwrap(); + assert_eq!(packages[0]["version"].as_str(), Some("0.4.0")); + assert_eq!(packages[1]["version"].as_str(), Some("0.3.1")); + assert_eq!(packages[1]["checksum"].as_str(), Some("unchanged")); + assert_eq!(packages[2]["version"].as_str(), Some("0.1.0")); + assert_eq!(packages[2]["dependencies"][0].as_str(), Some("sdk 0.4.0")); + assert_eq!( + packages[2]["dependencies"][1].as_str(), + Some("sdk 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)") + ); +} + +#[test] +fn unexpected_lock_changes_restore_all_original_files() { + let fixture = fixture(); + let root = fixture.path(); + // Cargo removes an unused entry when updating. Even this unrelated cleanup + // is outside the release command's scope and must leave all inputs intact. + let mut lock = fs::read_to_string(root.join("Cargo.lock")).unwrap(); + lock.push_str("\n[[package]]\nname = \"unused-independent-package\"\nversion = \"9.9.9\"\n"); + fs::write(root.join("Cargo.lock"), lock).unwrap(); + let original = snapshot(root); + let error = prepare(root, "0.3.1", "0.4.0").unwrap_err(); + assert!( + error.to_string().contains("beyond the inherited workspace"), + "{error:#}" + ); + assert_eq!(snapshot(root), original); +}