Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ on:
branches: [main]
push:
branches: [main]
# There was no way to ask this repository for a run, and on 2026-08-26 that
# mattered: GitHub dropped push events for about two hours, and six of the
# seven trees were left with no completed `ci` on the `main` they had just
# merged. The code was proven -- the same contexts were green on the pull
# request minutes earlier -- but nothing on `main` said so, and the only way
# to ask again was to push a commit that changed nothing.
#
# A repository whose proof can only be requested by changing it is a
# repository that invites empty commits. The monorepo has carried this
# trigger since its gate was built; the rendered trees did not, for no reason
# anyone chose.
workflow_dispatch:

permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
schedule:
# Weekly, so an advisory published after the last push is still seen.
- cron: '17 4 * * 1'
# Same reason as `ci`: an advisory worth re-checking now, or a scheduled run
# that never started, should not require a commit to ask about.
workflow_dispatch:

permissions:
contents: read
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,46 @@ including claims a later release made false.

## [Unreleased]

## [0.0.6] - 2026-08-27

Two defects a consumer found, one this build could not see, and the
last product that was an exception.

- **`provider_plan_digest` was null after every operation of every kind.** It
was read out of the plan *object*, which never carries it: the digest is
taken over the plan and travels beside it in the planner's envelope. Reported
as an empty-setup defect and never about emptiness at all. It stayed
invisible for four releases because `status` did not publish the field, and a
consumer skips what is absent -- publishing what is persisted is what made it
a value that could be compared and refused.
- **A backup refused a link while copying rather than before.** The slot was
created, files were written into it, and the walk then stopped -- a partial
operation and control artifacts for a shape that was knowable for free. Owned
paths are read before planning and again before any capture now, every
unsupported entry is named at once, and nothing is followed.
- **On Windows nothing was ever exposed, so no update was ever an update.**
Reading which version a prefix exposes resolved the command's path, and
Windows writes a hard link or a copy there rather than a link -- so the answer
was always "nothing is exposed" and every `software_update` refused as an
update of nothing. Shipped in 0.0.4 and 0.0.5. The version is recorded beside
the command and read back now; a dangling link still exposes nothing.
- **Pi installs like the other six.** It declared no software lifecycle on the
stated ground that npm resolves its closure at install time. The vendor ships
`npm-shrinkwrap.json`, so the closure is fixed -- and it does not matter,
because the published bundle imports only Node built-ins and runs with no
`node_modules` at all. All seven now declare the same four optional
operations. Its entry point is JavaScript, so Windows exposes `pi.cmd`
rather than a copy no platform would run.
- **A backup can be held.** The pool rolls at ten slots, so a long series of
captures evicted the baseline it meant to return to. A held slot is not
reclaimed and is not counted against the bound, the reason is recorded beside
it so a full pool says who would lose what, and the last reclaimable slot
cannot be held -- a target that can never be backed up again is worse than
the eviction.

Vendor versions advanced where they moved: claude 2.1.246, cursor
2026.08.25-3e8eec8, antigravity 1.1.21.

## [0.0.5] - 2026-08-26

The catalog now travels with the program, and a target's identity is
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "0.0.5"
version = "0.0.6"
edition = "2024"
rust-version = "1.89"
license = "AGPL-3.0-or-later"
Expand All @@ -23,9 +23,9 @@ sha2 = "0.11"
# `setup-core::archive`); an inflate loop is not, because its bugs are
# memory-safety bugs and it is not improved by being hand-written here.
miniz_oxide = "0.9"
setup-core = { path = "crates/setup-core", version = "0.0.5" }
provider-v3 = { path = "crates/provider-v3", version = "0.0.5" }
harness-runtime = { path = "crates/harness-runtime", version = "0.0.5" }
setup-core = { path = "crates/setup-core", version = "0.0.6" }
provider-v3 = { path = "crates/provider-v3", version = "0.0.6" }
harness-runtime = { path = "crates/harness-runtime", version = "0.0.6" }

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ release's own `SHA256SUMS`, and places it at a predictable path: `~/.local/bin`
on Linux and macOS, `%LOCALAPPDATA%\Programs` on Windows. Neither needs
privilege and neither registers anything anywhere.

Somewhere else instead:

```bash
OPENCODE_INSTALL_DIR=/opt/opencode-setup-system sh install.sh
```

The same variable on both scripts, and it is `OPENCODE_INSTALL_DIR`
rather than the longer prefix the setup-catalog variable uses -- the installer
is named after the product, not after the crate. It was always accepted and
never written down here, which is how someone reading only this page installs
into a home directory they did not mean to write to.

Releases carry six binaries — Linux, macOS and Windows, on x86_64 and arm64 —
which is what `provider-info` declares, so the declaration and what you can
download say the same thing.
Expand All @@ -151,7 +163,7 @@ release is a convenience, not the authorised copy.

```bash
docker run --rm -v "$HOME/.config:/config" \
ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.5 \
ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.6 \
status --target /config/<dir> --json
```

Expand Down
157 changes: 150 additions & 7 deletions crates/harness-runtime/src/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ pub enum Command {
/// The setup to apply.
setup: String,
},
/// Keep one backup until it is released, so retention cannot reclaim it.
Hold {
/// The target whose pool holds it.
target: PathBuf,
/// The slot to keep. Named, never inferred.
backup: Option<String>,
/// Why it is held, so whoever meets a full pool knows the cost.
reason: Option<String>,
},
/// Let retention have a held backup back.
Release {
/// The target whose pool holds it.
target: PathBuf,
/// The slot to let go.
backup: Option<String>,
},
/// Report which versions of the product are installed, and which is exposed.
Software {
/// The program directory to read.
Expand Down Expand Up @@ -123,6 +139,8 @@ pub fn is_human_command(name: &str) -> bool {
| "diff"
| "software"
| "rollback"
| "hold"
| "release"
)
}

Expand Down Expand Up @@ -151,6 +169,7 @@ struct Arguments {
backup: Option<String>,
prefix: Option<PathBuf>,
to: Option<String>,
reason: Option<String>,
positional: Vec<String>,
}

Expand All @@ -162,13 +181,14 @@ impl Arguments {
backup: None,
prefix: None,
to: None,
reason: None,
positional: Vec::new(),
};
let mut index = 0;
while index < rest.len() {
let Some(token) = rest.get(index) else { break };
match token.as_str() {
"--target" | "--backup" | "--prefix" | "--to" => {
"--target" | "--backup" | "--prefix" | "--to" | "--reason" => {
let Some(value) = rest.get(index + 1) else {
return Err(local(format!("{token} has no value")));
};
Expand All @@ -194,6 +214,12 @@ impl Arguments {
}
parsed.to = Some(value.clone());
}
"--reason" => {
if parsed.reason.is_some() {
return Err(local("--reason was given twice"));
}
parsed.reason = Some(value.clone());
}
_ => {
if parsed.backup.is_some() {
return Err(local("--backup was given twice"));
Expand Down Expand Up @@ -246,7 +272,7 @@ impl Arguments {
}

fn into_command(self, name: &str) -> Result<Command> {
if self.backup.is_some() && name != "restore" {
if self.backup.is_some() && !matches!(name, "restore" | "hold" | "release") {
return Err(local(format!("--backup is not an argument of {name}")));
}
if self.prefix.is_some() && !matches!(name, "software" | "rollback") {
Expand All @@ -255,11 +281,29 @@ impl Arguments {
if self.to.is_some() && name != "rollback" {
return Err(local(format!("--to is not an argument of {name}")));
}
if self.reason.is_some() && name != "hold" {
return Err(local(format!("--reason is not an argument of {name}")));
}
match name {
"list" => {
self.no_setup(name)?;
Ok(Command::List)
}
"hold" => {
self.no_setup(name)?;
Ok(Command::Hold {
target: self.target(name)?,
backup: self.backup,
reason: self.reason,
})
}
"release" => {
self.no_setup(name)?;
Ok(Command::Release {
target: self.target(name)?,
backup: self.backup,
})
}
"software" => {
self.no_setup(name)?;
Ok(Command::Software {
Expand Down Expand Up @@ -351,11 +395,106 @@ pub fn run(harness: &Harness, command: Command) -> Result<()> {
Command::Restore { target, backup } => restore(harness, &target, backup),
Command::Adopt { target } => adopt_target(harness, &target),
Command::Remove { target } => remove(harness, &target),
Command::Hold {
target,
backup,
reason,
} => hold(harness, &target, backup.as_deref(), reason.as_deref()),
Command::Release { target, backup } => release(harness, &target, backup.as_deref()),
Command::Software { prefix } => software(harness, &prefix),
Command::Rollback { prefix, to } => rollback(harness, &prefix, to.as_deref()),
}
}

/// Keep one backup until it is released.
///
/// The pool rolls: ten slots, oldest evicted. A long series of captures makes
/// more than that, so a baseline someone means to return to at the end is gone
/// by the time they get there. A hold is the smallest thing that stops it —
/// one marker eviction has to read.
fn hold(
harness: &Harness,
target: &Path,
backup: Option<&str>,
reason: Option<&str>,
) -> Result<()> {
let (resolved, pool) = pool_of(harness, target)?;
let reference = named_slot(&pool, backup, "hold")?;
// A pool can be held by more than one run. Without a reason, whoever meets
// a full pool knows which slots to release and not what releasing one
// would cost.
let reason = reason.unwrap_or("no reason recorded");
if pool.hold(&reference, reason)? {
println!(
"{} is held ({reason}). Retention will not reclaim it until it is released.",
reference.as_str()
);
} else {
let why = pool
.held_reason(&reference)?
.unwrap_or_else(|| "no reason recorded".to_owned());
println!("{} was already held ({why}).", reference.as_str());
}
println!(
" release it with: release --backup {} --target {}",
reference.as_str(),
resolved.root().display()
);
Ok(())
}

/// Let retention have a held backup back.
fn release(harness: &Harness, target: &Path, backup: Option<&str>) -> Result<()> {
let (_, pool) = pool_of(harness, target)?;
let reference = named_slot(&pool, backup, "release")?;
if pool.release(&reference)? {
println!(
"{} is released and will be reclaimed like any other slot.",
reference.as_str()
);
} else {
// Not a refusal: a run cleaning up after itself should not have to tell
// "nothing to do" apart from "something is wrong".
println!("{} was not held; nothing to release.", reference.as_str());
}
Ok(())
}

fn pool_of(harness: &Harness, target: &Path) -> Result<(Target, Pool)> {
let resolved = Target::resolve(target, harness.control_directory)?;
let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?;
Ok((resolved, pool))
}

/// The slot a caller named, or the list of the ones they could have named.
///
/// Never inferred. `restore` may default to the newest because that is the one
/// thing a caller wanting "undo" can mean; keeping a slot is a decision about a
/// specific capture, and guessing which would be guessing what a run is for.
fn named_slot(
pool: &Pool,
backup: Option<&str>,
verb: &str,
) -> Result<setup_core::backup::BackupRef> {
let available = pool.list()?;
let Some(text) = backup else {
return Err(local(if available.is_empty() {
format!("{verb} requires --backup <ref>, and this target has no backups")
} else {
format!(
"{verb} requires --backup <ref>; this target holds {}",
available
.iter()
.map(|record| record.backup_ref.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}));
};
setup_core::backup::BackupRef::parse(text)
.map_err(|error| local(format!("{text:?} is not a backup reference: {error}")))
}

/// What the program directory holds, and which version answers to the command.
///
/// Install and update are not here, and their absence is the design rather than
Expand Down Expand Up @@ -562,12 +701,16 @@ fn backups(harness: &Harness, target: &Path) -> Result<()> {
}
println!("Backups of {}, newest first:", resolved.root().display());
println!();
let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?;
for (position, record) in records.iter().enumerate() {
let marker = if position == 0 {
" (restored by default)"
} else {
""
};
let mut marker = String::new();
if position == 0 {
marker.push_str(" (restored by default)");
}
if let Some(why) = pool.held_reason(&record.backup_ref)? {
use std::fmt::Write as _;
let _ = write!(marker, " (held: {why})");
}
println!(" {}{marker}", record.backup_ref.as_str());
println!(
" before {}, setup {}",
Expand Down
Loading
Loading