Skip to content

Commit 4f4be04

Browse files
joshwilhelmiclaude
andcommitted
[gobby-cli-#142] feat(ghook): diagnose schema v2 + install provenance + release CI guard
Bump ghook 0.2.2 -> 0.3.0. Add install_method and install_source_url to --diagnose output, sourced from an optional .ghook-install.json sidecar next to the binary. Adds the v2 schema; v1 stays as a frozen historical schema. Adds a release-time guard that fails the workflow if the pushed ghook-v{X} tag version does not match crates/ghook/Cargo.toml, closing the drift mode in #4 that broke the public installer GitHub-asset lookup. Also folds in the unreleased 0.2.2 #141 fix (preserve non-stop block JSON. Refs #4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )
1 parent acfeefd commit 4f4be04

9 files changed

Lines changed: 311 additions & 37 deletions

File tree

.github/workflows/release-ghook.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@ jobs:
2020
with:
2121
components: clippy
2222

23+
- name: Verify tag matches Cargo.toml version
24+
# Guards against the tag/crate/release drift that broke installer
25+
# discovery in 0.2.x (see GobbyAI/gobby-cli#4): the public installer
26+
# resolves a version from crates.io and then looks for a matching
27+
# ghook-v{version} GitHub asset, so the tag, the crate version, and
28+
# the release name all have to line up exactly. Fail fast here so a
29+
# mismatched tag never produces a published crate or release.
30+
run: |
31+
set -euo pipefail
32+
tag="${GITHUB_REF#refs/tags/}"
33+
tag_version="${tag#ghook-v}"
34+
if [ "$tag" = "$tag_version" ]; then
35+
echo "::error::Tag '$tag' does not start with 'ghook-v'." >&2
36+
exit 1
37+
fi
38+
cargo_version="$(cargo pkgid -p gobby-hooks | sed 's/.*@//')"
39+
if [ "$tag_version" != "$cargo_version" ]; then
40+
echo "::error::Tag version '$tag_version' does not match crates/ghook/Cargo.toml version '$cargo_version'." >&2
41+
exit 1
42+
fi
43+
echo "Tag '$tag' aligns with crates/ghook/Cargo.toml version '$cargo_version'."
44+
2345
- name: Clippy
2446
run: cargo clippy -p gobby-hooks --all-targets -- -D warnings
2547

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ All notable changes to gobby-cli are documented in this file.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.3.0] — gobby-hooks
11+
12+
### Added
13+
14+
#### gobby-hooks
15+
16+
- **Diagnose schema v2 with install provenance**`ghook --diagnose` now emits two new fields, `install_method` and `install_source_url`, and stamps the output with `schema_version: 2`. Both fields are sourced from an optional sidecar file, `.ghook-install.json`, written by the installer next to the `ghook` binary. When no sidecar is present (e.g. plain `cargo install gobby-hooks`), both fields are `null` — so consumers can identify which install path produced a given binary in bug reports. The new schema lives at `crates/ghook/schemas/diagnose-output.v2.schema.json`; the v1 schema file is preserved unchanged as a frozen historical schema for tools that pinned to v1. The Gobby installer is the canonical sidecar writer; see `docs/guides/ghook-development-guide.md` for the full contract. (#4)
17+
18+
### Changed
19+
20+
#### CI/CD
21+
22+
- **Release-time tag/version alignment guard** — the `release-ghook` workflow now fails fast if the pushed `ghook-v{X}` tag's version suffix doesn't match the version in `crates/ghook/Cargo.toml`. This closes the drift mode that produced [GobbyAI/gobby-cli#4](https://github.com/GobbyAI/gobby-cli/issues/4), where the public installer's `ghook-v{version}` GitHub-asset lookup could silently miss because the tag, crate version, and release name had diverged. The guard runs before clippy/tests so a misaligned tag never reaches crates.io or the GitHub release. (#4)
23+
24+
### Fixed
25+
26+
#### gobby-hooks
27+
28+
- **Preserve non-stop block JSON** — folded forward from the unreleased 0.2.2 prep: `ghook` no longer collapses non-Stop block responses to a bare `Blocked by hook` message; the original block JSON is preserved for downstream consumers. (#141)
29+
1030
## [0.2.1] — gobby-hooks
1131

1232
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ghook/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-hooks"
3-
version = "0.2.2"
3+
version = "0.3.0"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ Exit codes:
2525
## Schemas
2626

2727
- `schemas/inbox-envelope.v1.schema.json` — what lands in the inbox.
28-
- `schemas/diagnose-output.v1.schema.json` — what `--diagnose` prints.
28+
- `schemas/diagnose-output.v2.schema.json` — what `--diagnose` prints. Adds `install_method` and `install_source_url` fields sourced from an installer-written sidecar (`.ghook-install.json`, next to the binary).
29+
- `schemas/diagnose-output.v1.schema.json` — frozen historical schema for the 0.1.x and 0.2.x diagnose output. Kept for tooling that pinned to v1.
2930

30-
Both are validated in unit tests.
31+
The active schemas (envelope v1, diagnose v2) are validated in unit tests.
32+
33+
## Install provenance
34+
35+
`ghook --diagnose` reads an optional sidecar file named `.ghook-install.json` from the same directory as the running binary. When present, its `install_method` and `install_source_url` fields surface in the diagnose output so bug reports can identify how a given binary got installed (GitHub release, `cargo-binstall`, `cargo install`, etc.).
36+
37+
The Gobby installer writes this sidecar atomically every time it places a `ghook` binary. Manual installs (e.g. plain `cargo install gobby-hooks`) leave both fields as `null`. See `docs/guides/ghook-development-guide.md` for the contract.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://gobby.ai/schemas/ghook/diagnose-output.v2.schema.json",
4+
"title": "Gobby ghook --diagnose output",
5+
"description": "Output of `ghook --diagnose --cli=<c> --type=<t>`. Schema version 2 — adds install_method and install_source_url for install-provenance reporting. v1 fields are unchanged.",
6+
"type": "object",
7+
"required": [
8+
"schema_version",
9+
"ghook_version",
10+
"cli",
11+
"hook_type",
12+
"critical",
13+
"terminal_context_enabled",
14+
"daemon_url",
15+
"daemon_host",
16+
"daemon_port",
17+
"cli_recognized"
18+
],
19+
"additionalProperties": false,
20+
"properties": {
21+
"schema_version": {
22+
"type": "integer",
23+
"const": 2
24+
},
25+
"ghook_version": {
26+
"type": "string",
27+
"minLength": 1
28+
},
29+
"cli": {
30+
"type": "string",
31+
"minLength": 1
32+
},
33+
"hook_type": {
34+
"type": "string",
35+
"minLength": 1
36+
},
37+
"source": {
38+
"type": ["string", "null"]
39+
},
40+
"critical": {
41+
"type": "boolean"
42+
},
43+
"terminal_context_enabled": {
44+
"type": "boolean"
45+
},
46+
"daemon_url": {
47+
"type": "string",
48+
"minLength": 1
49+
},
50+
"daemon_host": {
51+
"type": "string",
52+
"minLength": 1
53+
},
54+
"daemon_port": {
55+
"type": "integer",
56+
"minimum": 1,
57+
"maximum": 65535
58+
},
59+
"project_root": {
60+
"type": ["string", "null"]
61+
},
62+
"project_id": {
63+
"type": ["string", "null"]
64+
},
65+
"terminal_context_preview": {
66+
"type": ["object", "null"]
67+
},
68+
"cli_recognized": {
69+
"type": "boolean"
70+
},
71+
"install_method": {
72+
"type": ["string", "null"],
73+
"description": "How ghook was installed, sourced from the install sidecar (.ghook-install.json) next to the binary. Conventional values: github-release, crates-binstall, cargo-install, manual, unknown. null when no sidecar is present (e.g. cargo install without a sidecar-writing installer)."
74+
},
75+
"install_source_url": {
76+
"type": ["string", "null"],
77+
"description": "URL the binary was fetched from (typically a GitHub release asset URL), sourced from the install sidecar. null when unknown or not applicable."
78+
}
79+
}
80+
}

crates/ghook/src/diagnose.rs

Lines changed: 123 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
//! `ghook --diagnose` — print what *would* happen for a given CLI/hook combo.
22
//!
33
//! Emits a JSON object validated against
4-
//! `schemas/diagnose-output.v1.schema.json`. No network I/O, no envelope
4+
//! `schemas/diagnose-output.v2.schema.json`. No network I/O, no envelope
55
//! write — this is a pure introspection surface so operators can confirm
66
//! configuration without spamming the inbox.
77
88
use crate::cli_config::CliConfig;
99
use gobby_core::{bootstrap, daemon_url, project};
10-
use serde::Serialize;
10+
use serde::{Deserialize, Serialize};
1111
use serde_json::Value;
12-
use std::path::PathBuf;
12+
use std::path::{Path, PathBuf};
1313

1414
#[derive(Debug, Serialize)]
1515
pub struct DiagnoseOutput {
@@ -27,11 +27,48 @@ pub struct DiagnoseOutput {
2727
pub project_id: Option<String>,
2828
pub terminal_context_preview: Option<Value>,
2929
pub cli_recognized: bool,
30+
pub install_method: Option<String>,
31+
pub install_source_url: Option<String>,
3032
}
3133

32-
pub const DIAGNOSE_SCHEMA_VERSION: u32 = 1;
34+
pub const DIAGNOSE_SCHEMA_VERSION: u32 = 2;
3335
pub const GHOOK_VERSION: &str = env!("CARGO_PKG_VERSION");
3436

37+
/// Sidecar filename written by the installer next to the `ghook` binary.
38+
/// See `docs/guides/ghook-development-guide.md` for the contract.
39+
pub const INSTALL_SIDECAR_FILENAME: &str = ".ghook-install.json";
40+
41+
#[derive(Debug, Deserialize)]
42+
struct InstallSidecar {
43+
install_method: Option<String>,
44+
install_source_url: Option<String>,
45+
}
46+
47+
/// Read the install-provenance sidecar from `dir/.ghook-install.json` and
48+
/// return `(install_method, install_source_url)`. Any failure (missing file,
49+
/// unreadable, malformed JSON) collapses to `(None, None)` — the sidecar is
50+
/// best-effort metadata, never load-bearing.
51+
pub fn read_install_provenance(dir: &Path) -> (Option<String>, Option<String>) {
52+
let path = dir.join(INSTALL_SIDECAR_FILENAME);
53+
let Ok(bytes) = std::fs::read(&path) else {
54+
return (None, None);
55+
};
56+
let Ok(sidecar) = serde_json::from_slice::<InstallSidecar>(&bytes) else {
57+
return (None, None);
58+
};
59+
(sidecar.install_method, sidecar.install_source_url)
60+
}
61+
62+
fn install_provenance_for_running_binary() -> (Option<String>, Option<String>) {
63+
let Ok(exe) = std::env::current_exe() else {
64+
return (None, None);
65+
};
66+
let Some(dir) = exe.parent() else {
67+
return (None, None);
68+
};
69+
read_install_provenance(dir)
70+
}
71+
3572
pub fn diagnose(cli: &str, hook_type: &str) -> DiagnoseOutput {
3673
let cfg = CliConfig::for_cli(cli);
3774
let cli_recognized = cfg.is_some();
@@ -59,6 +96,8 @@ pub fn diagnose(cli: &str, hook_type: &str) -> DiagnoseOutput {
5996
None => (None, false, false, None),
6097
};
6198

99+
let (install_method, install_source_url) = install_provenance_for_running_binary();
100+
62101
DiagnoseOutput {
63102
schema_version: DIAGNOSE_SCHEMA_VERSION,
64103
ghook_version: GHOOK_VERSION,
@@ -74,12 +113,15 @@ pub fn diagnose(cli: &str, hook_type: &str) -> DiagnoseOutput {
74113
project_id,
75114
terminal_context_preview,
76115
cli_recognized,
116+
install_method,
117+
install_source_url,
77118
}
78119
}
79120

80121
#[cfg(test)]
81122
mod tests {
82123
use super::*;
124+
use std::io::Write;
83125

84126
#[test]
85127
fn unknown_cli_marked_not_recognized() {
@@ -108,35 +150,94 @@ mod tests {
108150
assert!(d.terminal_context_enabled);
109151
}
110152

111-
#[test]
112-
fn diagnose_output_validates_against_v1_schema() {
113-
let schema_bytes = include_bytes!("../schemas/diagnose-output.v1.schema.json");
153+
fn compile_v2_schema() -> jsonschema::JSONSchema {
154+
let schema_bytes = include_bytes!("../schemas/diagnose-output.v2.schema.json");
114155
let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap();
115-
let compiled = jsonschema::JSONSchema::options()
156+
jsonschema::JSONSchema::options()
116157
.with_draft(jsonschema::Draft::Draft7)
117158
.compile(&schema)
118-
.expect("schema compiles");
119-
let out = diagnose("claude", "session-start");
120-
let v = serde_json::to_value(&out).unwrap();
121-
if let Err(errs) = compiled.validate(&v) {
159+
.expect("schema compiles")
160+
}
161+
162+
fn assert_validates(schema: &jsonschema::JSONSchema, value: &serde_json::Value) {
163+
if let Err(errs) = schema.validate(value) {
122164
let msgs: Vec<_> = errs.map(|e| format!("{e}")).collect();
123165
panic!("diagnose output failed schema validation: {msgs:?}");
124166
}
125167
}
126168

169+
#[test]
170+
fn diagnose_output_validates_against_v2_schema() {
171+
let compiled = compile_v2_schema();
172+
let out = diagnose("claude", "session-start");
173+
let v = serde_json::to_value(&out).unwrap();
174+
assert_validates(&compiled, &v);
175+
}
176+
127177
#[test]
128178
fn diagnose_output_for_unknown_cli_validates() {
129-
let schema_bytes = include_bytes!("../schemas/diagnose-output.v1.schema.json");
130-
let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap();
131-
let compiled = jsonschema::JSONSchema::options()
132-
.with_draft(jsonschema::Draft::Draft7)
133-
.compile(&schema)
134-
.expect("schema compiles");
179+
let compiled = compile_v2_schema();
135180
let out = diagnose("cursor", "session-start");
136181
let v = serde_json::to_value(&out).unwrap();
137-
if let Err(errs) = compiled.validate(&v) {
138-
let msgs: Vec<_> = errs.map(|e| format!("{e}")).collect();
139-
panic!("diagnose output failed schema validation: {msgs:?}");
140-
}
182+
assert_validates(&compiled, &v);
183+
}
184+
185+
#[test]
186+
fn schema_version_is_two() {
187+
let d = diagnose("claude", "session-start");
188+
assert_eq!(d.schema_version, 2);
189+
}
190+
191+
#[test]
192+
fn install_provenance_absent_when_no_sidecar() {
193+
let dir = tempfile::tempdir().unwrap();
194+
let (method, url) = read_install_provenance(dir.path());
195+
assert!(method.is_none());
196+
assert!(url.is_none());
197+
}
198+
199+
#[test]
200+
fn install_provenance_read_from_sidecar() {
201+
let dir = tempfile::tempdir().unwrap();
202+
let path = dir.path().join(INSTALL_SIDECAR_FILENAME);
203+
let mut f = std::fs::File::create(&path).unwrap();
204+
write!(
205+
f,
206+
r#"{{
207+
"install_method": "github-release",
208+
"install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.3.0/ghook-aarch64-apple-darwin.tar.gz",
209+
"installed_version": "0.3.0",
210+
"installed_at": "2026-04-22T18:30:00Z"
211+
}}"#
212+
)
213+
.unwrap();
214+
let (method, url) = read_install_provenance(dir.path());
215+
assert_eq!(method.as_deref(), Some("github-release"));
216+
assert_eq!(
217+
url.as_deref(),
218+
Some(
219+
"https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.3.0/ghook-aarch64-apple-darwin.tar.gz"
220+
)
221+
);
222+
}
223+
224+
#[test]
225+
fn install_provenance_partial_sidecar_returns_present_fields() {
226+
let dir = tempfile::tempdir().unwrap();
227+
let path = dir.path().join(INSTALL_SIDECAR_FILENAME);
228+
std::fs::write(&path, r#"{"install_method": "cargo-install"}"#).unwrap();
229+
let (method, url) = read_install_provenance(dir.path());
230+
assert_eq!(method.as_deref(), Some("cargo-install"));
231+
assert!(url.is_none());
232+
}
233+
234+
#[test]
235+
fn install_provenance_malformed_json_collapses_to_none() {
236+
let dir = tempfile::tempdir().unwrap();
237+
let path = dir.path().join(INSTALL_SIDECAR_FILENAME);
238+
std::fs::write(&path, "not json at all {{").unwrap();
239+
let (method, url) = read_install_provenance(dir.path());
240+
assert!(method.is_none());
241+
assert!(url.is_none());
141242
}
142243
}

0 commit comments

Comments
 (0)