diff --git a/.github/workflows/bump-release.yml b/.github/workflows/bump-release.yml index 31156e4..e9b2301 100644 --- a/.github/workflows/bump-release.yml +++ b/.github/workflows/bump-release.yml @@ -112,6 +112,26 @@ jobs: exit 1 fi + locked="$(awk ' + /^\[\[package\]\]$/ { package = ""; next } + /^name = "tink"$/ { package = "tink"; next } + package == "tink" && /^version = "/ { + version = $0 + sub(/^version = "/, "", version) + sub(/"$/, "", version) + print version + exit + } + ' Cargo.lock)" + if [ -z "${locked}" ]; then + echo "Could not read tink version from Cargo.lock" >&2 + exit 1 + fi + if [ "${locked}" != "${current}" ]; then + echo "Cargo.lock tink version ${locked} does not match Cargo.toml ${current}" >&2 + exit 1 + fi + current_tag="v${current}" if git rev-parse --verify --quiet "refs/tags/${current_tag}" >/dev/null; then current_tagged_version="$(git show "${current_tag}:Cargo.toml" | sed -n 's/^version = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"/\1/p' | head -n1)" @@ -132,6 +152,59 @@ jobs: fi fi + # An intentionally pre-bumped, untagged manifest (for example 1.0.0) + # publishes that exact version instead of incrementing it again. + if ! git rev-parse --verify --quiet "refs/tags/${current_tag}" >/dev/null; then + baseline_tag="$(gh release view --json tagName --jq '.tagName' 2>/dev/null || true)" + if [ -z "${baseline_tag}" ]; then + echo "Cannot publish ${current_tag} without a published release baseline" >&2 + exit 1 + fi + baseline_version="${baseline_tag#v}" + if ! python3 - "${current}" "${baseline_version}" <<'PY' + import re + import sys + + current, baseline = sys.argv[1:] + pattern = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") + if pattern.fullmatch(current) is None or pattern.fullmatch(baseline) is None: + raise SystemExit(1) + raise SystemExit(0 if tuple(map(int, current.split("."))) > tuple(map(int, baseline.split("."))) else 1) + PY + then + echo "Pre-bumped version ${current} must be newer than published ${baseline_tag}" >&2 + exit 1 + fi + if ! git rev-parse --verify --quiet "refs/tags/${baseline_tag}" >/dev/null; then + echo "Published baseline tag ${baseline_tag} is missing locally" >&2 + exit 1 + fi + baseline_tagged_version="$(git show "${baseline_tag}:Cargo.toml" | sed -n 's/^version = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"/\1/p' | head -n1)" + if [ "${baseline_tagged_version}" != "${baseline_version}" ]; then + echo "Baseline tag ${baseline_tag} does not contain Cargo version ${baseline_version}" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "${baseline_tag}^{commit}" origin/main; then + echo "Baseline tag ${baseline_tag} is not contained in origin/main" >&2 + exit 1 + fi + baseline_release_draft="$(gh release view "${baseline_tag}" --json isDraft --jq '.isDraft' 2>/dev/null || true)" + if [ "${baseline_release_draft}" != "false" ]; then + echo "Baseline release ${baseline_tag} is not published" >&2 + exit 1 + fi + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "origin/main advanced after this workflow started; a newer run owns the release" >&2 + exit 1 + fi + + git tag "${current_tag}" + git push --atomic origin HEAD:main "refs/tags/${current_tag}:refs/tags/${current_tag}" + gh workflow run release.yml --ref "${current_tag}" + echo "Dispatched intentional release for ${current_tag}" + exit 0 + fi + IFS=. read -r major minor patch <<< "${current}" new="${major}.${minor}.$((patch + 1))" tag="v${new}" diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index ff39cd5..31fdf86 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -178,7 +178,7 @@ Ids are stable. Tests must name or comment the id they prove. | C1 | `skill check` after valid `init` + `skill add` | Exit 0 | | C2 | `skill check` without `.agents/skills` | Exit ≠ 0 | | C3 | `skill check` when `.agents` is a symlink | Exit ≠ 0; refuse | -| C4 | `skill check` | Performs no network I/O and no filesystem writes. Sensor: manual. | +| C4 | `skill check` | Leaves the project and Tink home trees byte-for-byte and mode-for-mode unchanged; succeeds without external commands on `PATH` | | C5 | `skill check` after an installed skill's frontmatter name is corrupted | Exit ≠ 0; reports the skill-name mismatch | | C6 | `skill check` after an installed `SKILL.md` loses its YAML frontmatter | Exit ≠ 0; reports that YAML frontmatter is required | | C7 | `skill check` after an installed `SKILL.md` has unclosed frontmatter | Exit ≠ 0; reports that the frontmatter is not closed | @@ -318,8 +318,8 @@ Ids are stable. Tests must name or comment the id they prove. | Id | Action | Expect | |---|---|---| -| S1 | Any successful command | Does not `git init`, stage, commit, or push. Sensor: S1 (partial: `init` only). | -| S2 | Home root | Never treated as an agent discovery root. Sensor: manual. | +| S1 | Successful local and remote commands | Tink's centralized Git process boundary refuses root `init`, `add`, `commit`, and `push` commands; representative init and remote-add paths do not create project Git state | +| S2 | A valid skill exists only in the home library | Project `skill list` and `skill check` do not treat it as live; explicit `skill add ` promotes it into the project, after which both commands observe it | | S3 | `skill remove` or `destroy --yes` when neither `TINK_HOME` nor `HOME` can resolve an inventory | Exit 0; completes local cleanup without creating or mutating inventory state | ## Proof diff --git a/Cargo.lock b/Cargo.lock index f3a530e..819bea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -570,7 +570,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "tink" -version = "0.3.20" +version = "1.0.0" dependencies = [ "anstyle", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 581500f..44b93d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tink" -version = "0.3.20" +version = "1.0.0" edition = "2024" rust-version = "1.95" description = "Install Agent Skills into a project's .agents/skills/" diff --git a/README.md b/README.md index 9ab0bc5..444339c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Skill manager that makes sense to you, and your agent. +Tink 1.0 is feature-complete for the v1 acceptance boundary in +[`ACCEPTANCE.md`](ACCEPTANCE.md). Maintenance prioritizes correctness, +security, compatibility, and a simpler everyday experience over new lifecycle +machinery. + Live skills live only under a project’s `.agents/skills//`. Grouped skillsets use one canonical nested root at `.agents/skills/-skillset//`. There @@ -118,8 +123,10 @@ flowchart LR tink -->|"add "| live ``` -Home (`~/.tink` or `$TINK_HOME`) is **not** an agent discovery root. Library holds -skill trees; catalog holds by-project **names** only. +Tink lists and validates live project skills only under `.agents/skills/`. It +never promotes a home-library entry automatically or configures an agent +harness to discover the home. Library holds skill trees; catalog holds +by-project **names** only. ## Use (everyday) diff --git a/src/git.rs b/src/git.rs index 47599ce..797f2ce 100644 --- a/src/git.rs +++ b/src/git.rs @@ -31,6 +31,28 @@ fn git_command_args<'a>(args: &[&'a str]) -> Vec<&'a str> { full } +fn git_subcommand<'a>(args: &[&'a str]) -> Option<&'a str> { + let mut index = 0; + while let Some(argument) = args.get(index) { + match *argument { + "-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" => index += 2, + value if value.starts_with('-') => index += 1, + value => return Some(value), + } + } + None +} + +fn guard_git_command(args: &[&str]) -> Result<(), Error> { + if matches!( + git_subcommand(args), + Some("init" | "add" | "commit" | "push") + ) { + return Err(Error::msg("Tink refuses project-mutating Git commands")); + } + Ok(()) +} + fn run_git( args: &[&str], cwd: Option<&Path>, @@ -38,6 +60,7 @@ fn run_git( io_context: &str, missing_message: Option<&str>, ) -> Result { + guard_git_command(args)?; let mut command = Command::new("git"); command.args(git_command_args(args)); if non_interactive { @@ -236,4 +259,26 @@ mod tests { ] ); } + + #[test] + fn git_guard_rejects_project_mutations_and_allows_owned_reads() { + for args in [ + &["init"][..], + &["add", "."][..], + &["commit", "-m", "message"][..], + &["push", "origin", "main"][..], + &["-C", "/tmp/repo", "commit"][..], + ] { + assert!(guard_git_command(args).is_err(), "must refuse {args:?}"); + } + + for args in [ + &["ls-remote", "https://example.test", "HEAD"][..], + &["clone", "https://example.test/repo.git", "/tmp/repo"][..], + &["rev-parse", "HEAD"][..], + &["-C", "/tmp/repo", "worktree", "add", "/tmp/tree"][..], + ] { + assert!(guard_git_command(args).is_ok(), "must allow {args:?}"); + } + } } diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 1b4f35b..25c088e 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -4,6 +4,7 @@ use assert_cmd::Command; use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use std::fs; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::Command as StdCommand; use tempfile::TempDir; @@ -198,6 +199,59 @@ fn github_redirect(local_repo: &Path, public_url: &str) -> Vec<(String, String)> ] } +#[derive(Debug, Eq, PartialEq)] +struct TreeEntry { + path: PathBuf, + kind: &'static str, + mode: u32, + contents: Vec, +} + +fn snapshot_tree(root: &Path) -> Vec { + fn visit(root: &Path, path: &Path, entries: &mut Vec) { + let mut children: Vec<_> = fs::read_dir(path) + .expect("read snapshot directory") + .map(|entry| entry.expect("snapshot entry").path()) + .collect(); + children.sort(); + for child in children { + let metadata = fs::symlink_metadata(&child).expect("snapshot metadata"); + let file_type = metadata.file_type(); + let kind = if file_type.is_dir() { + "directory" + } else if file_type.is_file() { + "file" + } else if file_type.is_symlink() { + "symlink" + } else if file_type.is_socket() { + "socket" + } else { + "special" + }; + entries.push(TreeEntry { + path: child + .strip_prefix(root) + .expect("snapshot path under root") + .to_path_buf(), + kind, + mode: metadata.permissions().mode(), + contents: if file_type.is_file() { + fs::read(&child).expect("snapshot file") + } else { + Vec::new() + }, + }); + if file_type.is_dir() { + visit(root, &child, entries); + } + } + } + + let mut entries = Vec::new(); + visit(root, root, &mut entries); + entries +} + // --- I*: init --- #[test] @@ -2030,6 +2084,24 @@ fn c3_check_refuses_agents_symlink() { .stderr(predicate::str::contains("symlink").or(predicate::str::contains("Symlink"))); } +#[test] +fn c4_check_preserves_project_and_home_trees_without_external_commands() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + let project_before = snapshot_tree(&project); + let home_before = snapshot_tree(&ws.inventory); + + ws.cmd(&project) + .env("PATH", "") + .args(["skill", "check"]) + .assert() + .success(); + + assert_eq!(snapshot_tree(&project), project_before); + assert_eq!(snapshot_tree(&ws.inventory), home_before); +} + #[test] fn c5_check_fails_with_corrupt_installed_skill() { let ws = Workspace::new(); @@ -4430,12 +4502,59 @@ fn x7_remove_refuses_symlinked_catalog_without_external_or_project_writes() { // --- S*: safety --- #[test] -fn s1_init_does_not_create_git_repo() { +fn s1_successful_local_and_remote_commands_do_not_mutate_project_git() { let ws = Workspace::new(); let project = ws.project("app"); assert!(!project.join(".git").exists()); ws.cmd(&project).arg("init").assert().success(); assert!(!project.join(".git").exists()); + + let remote = ws.root.join("remote"); + init_repo(&remote); + write_skill(&remote, "remote-skill", "remote"); + commit_all(&remote, "initial"); + let public_url = "https://github.com/example/remote-skill.git"; + let envs = github_redirect(&remote, public_url); + ws.cmd(&project) + .envs(envs) + .args(["skill", "add", "example/remote-skill"]) + .assert() + .success(); + assert!(!project.join(".git").exists()); +} + +#[test] +fn s2_library_skill_is_not_project_live_until_explicitly_added() { + let ws = Workspace::new(); + let bootstrap = ws.project("bootstrap"); + ws.cmd(&bootstrap) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + write_skill(&ws.library_skill("library-only"), "library-only", "body"); + + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + ws.cmd(&project) + .args(["skill", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("library-only").not()); + ws.cmd(&project).args(["skill", "check"]).assert().success(); + + ws.cmd(&project) + .args(["skill", "add", "library-only"]) + .assert() + .success(); + ws.cmd(&project) + .args(["skill", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("library-only")); + ws.cmd(&project).args(["skill", "check"]).assert().success(); } #[test] diff --git a/tests/workflow_contract.rs b/tests/workflow_contract.rs index 965802c..5d2efee 100644 --- a/tests/workflow_contract.rs +++ b/tests/workflow_contract.rs @@ -56,6 +56,54 @@ fn bump_gates_the_tag_on_every_platform_and_publishes_refs_atomically() { assert!(existing_tag < release_check && release_check < dispatch); } +#[test] +fn bump_publishes_an_intentional_pre_bump_without_incrementing_it() { + let marker = "# An intentionally pre-bumped, untagged manifest"; + let pre_bump = BUMP + .split_once(marker) + .expect("intentional pre-bump branch") + .1 + .split_once("IFS=. read -r major minor patch") + .expect("pre-bump must precede patch calculation") + .0; + + assert!(BUMP.find("locked=\"").unwrap() < BUMP.find(marker).unwrap()); + assert!(pre_bump.contains("baseline_tag=")); + assert!(pre_bump.contains("must be newer than published")); + assert!(pre_bump.contains("baseline_tagged_version=")); + assert!(pre_bump.contains("git merge-base --is-ancestor")); + assert!(pre_bump.contains("baseline_release_draft=")); + assert!(pre_bump.contains("git tag \"${current_tag}\"")); + assert!(pre_bump.contains( + "git push --atomic origin HEAD:main \"refs/tags/${current_tag}:refs/tags/${current_tag}\"" + )); + assert!(pre_bump.contains("gh workflow run release.yml --ref \"${current_tag}\"")); + assert!(pre_bump.contains("exit 0")); + assert!(!pre_bump.contains("sed -i")); + assert!(!pre_bump.contains("git commit")); + + let tag = pre_bump.find("git tag \"${current_tag}\"").unwrap(); + let push = pre_bump.find("git push --atomic origin HEAD:main").unwrap(); + let dispatch = pre_bump.find("gh workflow run release.yml").unwrap(); + for guard in [ + "if ! git rev-parse --verify --quiet \"refs/tags/${current_tag}\"", + "if [ -z \"${baseline_tag}\" ]", + "if ! python3 - \"${current}\" \"${baseline_version}\"", + "if ! git rev-parse --verify --quiet \"refs/tags/${baseline_tag}\"", + "if [ \"${baseline_tagged_version}\" != \"${baseline_version}\" ]", + "if ! git merge-base --is-ancestor", + "if [ \"${baseline_release_draft}\" != \"false\" ]", + "if [ \"$(git rev-parse HEAD)\" != \"$(git rev-parse origin/main)\" ]", + ] { + assert!( + pre_bump.find(guard).unwrap() < tag, + "guard must precede tag creation: {guard}" + ); + } + assert!(tag < push, "tag must be created before atomic publication"); + assert!(push < dispatch, "publication must precede release dispatch"); +} + #[test] fn release_uploads_an_exact_asset_set_to_a_draft_before_publication() { let create = position(RELEASE, "gh release create \"${tag}\"");