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
107 changes: 36 additions & 71 deletions .github/workflows/docs-drift.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
name: Docs drift

on:
schedule:
- cron: '17 4 * * *'
pull_request:
paths:
- 'cmd/**.go'
- 'config/**.go'
- 'daemon/**.go'
- 'internal/**.go'
- 'orchestration/**.go'
- 'docs/docs-drift-map.yml'
- 'scripts/detect-docs-drift.sh'
- 'scripts/git-hooks/**'
- 'web/docs/docs/**'
- '.github/workflows/docs-drift.yml'
workflow_dispatch:

env:
ENABLE_COPILOT_ASSIGN: "true"

permissions:
contents: write
contents: read
pull-requests: write
Comment thread
brkastner marked this conversation as resolved.
issues: write

jobs:
detect:
Expand All @@ -44,11 +42,13 @@ jobs:
run: |
set -euo pipefail
REPORT=$(bash scripts/detect-docs-drift.sh)
echo "report<<EOF" >> "$GITHUB_OUTPUT"
echo "$REPORT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
{
echo "report<<EOF"
echo "$REPORT"
echo "EOF"
} >> "$GITHUB_OUTPUT"

comment-on-pr:
pr-check:
if: github.event_name == 'pull_request'
needs: detect
runs-on: ubuntu-latest
Expand All @@ -61,6 +61,20 @@ jobs:
const report = JSON.parse(process.env.REPORT || '{"drift":[]}');
if (!report.drift.length) return;

const trailerPattern = /^Docs-Drift-Skip:\s*(.+)$/m;
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const bypassCommit = commits.find((commit) => trailerPattern.test(commit.commit.message));
if (bypassCommit) {
const trailer = bypassCommit.commit.message.match(trailerPattern)?.[0] || "Docs-Drift-Skip";
core.notice(`docs drift bypassed via trailer (${trailer})`);
return;
}

const marker = "<!-- docs-drift-report -->";
const body = `${marker}
## docs drift detected
Expand All @@ -69,7 +83,9 @@ jobs:

\`\`\`json
${JSON.stringify(report, null, 2)}
\`\`\``;
\`\`\`

bypass: see scripts/git-hooks/README.md`;

const comments = await github.paginate(github.rest.issues.listComments, {
issue_number: context.issue.number,
Expand Down Expand Up @@ -97,68 +113,17 @@ jobs:
});
}

open-or-update-pr:
if: github.event_name != 'pull_request'
needs: detect
core.setFailed("docs drift detected");

hook-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Open or update tracking PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPORT: ${{ needs.detect.outputs.report }}
run: |
set -euo pipefail
if [ "$(jq '.drift | length' <<<"$REPORT")" = "0" ]; then
echo "no drift detected"
exit 0
fi

BRANCH=bot/docs-drift
TODAY=$(date -u +%Y-%m-%d)
git config user.name "kasmos-docs-bot"
git config user.email "docs-bot@users.noreply.github.com"

if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git fetch origin "$BRANCH:$BRANCH"
git checkout "$BRANCH"
else
git checkout -B "$BRANCH"
fi

git commit --allow-empty -m "docs: refresh from code drift ($TODAY)"
git push origin "$BRANCH"

BODY=$(cat <<EOF
## docs drift report

\`\`\`json
$(jq . <<<"$REPORT")
\`\`\`

@copilot please draft doc updates matching the changes above.
EOF
)

gh label create docs-drift --description "Tracks documentation drift" --color "0e8a16" 2>/dev/null || true

assignee_args=()
if [ "$ENABLE_COPILOT_ASSIGN" = "true" ] && gh api "/repos/${{ github.repository }}/assignees?per_page=100" 2>/dev/null | jq -e '.[] | select(.login == "copilot")' >/dev/null; then
assignee_args=(--assignee copilot)
fi

if [ "$(gh pr view "$BRANCH" --json state -q .state 2>/dev/null || true)" = "OPEN" ]; then
gh pr edit "$BRANCH" --body "$BODY" --add-label docs-drift
if [ "${#assignee_args[@]}" -gt 0 ]; then
gh pr edit "$BRANCH" --add-assignee copilot
fi
else
gh pr create --head "$BRANCH" --base main \
--title "docs: refresh from code drift ($TODAY)" \
--body "$BODY" \
--label docs-drift \
"${assignee_args[@]}"
fi
- name: Run hook unit scenarios
run: bash scripts/git-hooks/test/run.sh

- name: Run hook smoke harness
run: bash scripts/git-hooks/test/smoke.sh
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Key points:
- **Arrow-key navigation in overlays**: use ↑↓ for navigation, not j/k vim bindings. Letter keys should always type into search/filter when present.
- Signals are gateway-backed first. `.kasmos/signals/` still exists for compatibility, but do not document filesystem sentinels as the primary lifecycle path.
- **Daemon runs via systemd.** The kasmos daemon and DB server run as `systemctl --user` services (`kasmos` and `kasmosdb`). Always use `systemctl --user restart kasmos` (not `kas daemon start`). The CLI commands (`kas daemon start/stop`) exist for development and CI only.
- **Docs drift is blocked at push-time** by `scripts/git-hooks/pre-push`. Run `just hooks` once after cloning. CI enforces the same check as a required status, so `--no-verify` lands you in a failing PR.

## MCP-First Tooling

Expand Down
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ db-service-enable: db-service-install
# Install and start both user services
services-enable: kasmosd-enable db-service-enable

# Install the docs-drift pre-push git hook
hooks *args:
bash scripts/git-hooks/install.sh {{args}}

# run with no args
bin:
kas
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: run build test test-fast test-full test-race bench-tests clean
.PHONY: run build test test-fast test-full test-race bench-tests clean hooks

run: build
./kas $(ARGS)
Expand All @@ -20,5 +20,8 @@ test-race:
bench-tests:
./scripts/bench_tests.sh

hooks:
bash scripts/git-hooks/install.sh

clean:
rm -f kas kasmos
26 changes: 26 additions & 0 deletions cmd/kas/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func runCheck(cmd *cobra.Command, args []string) error {
defer probeCancel()
mcpProbeErr := probeSharedMCPFunc(probeCtx)
renderMCPEndpoint(cmd, mcpProbeErr)
if result.GitHooks != nil {
renderGitHooks(cmd, result.GitHooks)
}

// Detect long-lived stdio mcp subprocesses (threshold: 60 s).
mcpProcs, _ := check.ListLongLivedMCPProcesses(60)
Expand Down Expand Up @@ -177,6 +180,9 @@ func collectRemediationHints(result *check.AuditResult, mcpProcs []check.MCPProc
add("re-run `kas scaffold sync` to update config files with the current binary path, or reinstall service units")
}
}
if result.GitHooks != nil && !result.GitHooks.Configured {
add("run 'just hooks' to install the docs-drift pre-push hook")
}

// Long-lived stdio mcp process hint.
if len(mcpProcs) > 0 {
Expand Down Expand Up @@ -208,6 +214,26 @@ func renderMCPEndpoint(cmd *cobra.Command, probeErr error) {
fmt.Fprintf(out, " ✗ %s unreachable (%s)\n", mcpclient.SharedEndpointURL, probeErr)
}

func renderGitHooks(cmd *cobra.Command, status *check.HookStatus) {
out := cmd.OutOrStdout()
fmt.Fprintf(out, "\npre-push hook:\n")
if status.Configured {
fmt.Fprintf(out, " ✓ core.hooksPath=%s\n", configuredHooksPathDisplay(status))
return
}
fmt.Fprintf(out, " ✗ pre-push hook not installed (core.hooksPath=%q)\n", status.ActualPath)
}

func configuredHooksPathDisplay(status *check.HookStatus) string {
if status != nil && status.ActualPath != "" {
return status.ActualPath
}
if status != nil {
return status.ExpectedPath
}
return ""
}

// renderBinaryPath prints a dedicated binary-path section before the health summary.
func renderBinaryPath(cmd *cobra.Command, bp *check.BinaryPathResult) {
out := cmd.OutOrStdout()
Expand Down
57 changes: 57 additions & 0 deletions cmd/kas/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,63 @@ func TestCheckCmd_BinaryPathHealthyNoMismatch(t *testing.T) {
assert.NotContains(t, out, "/nonexistent/stale/kas")
}

func TestCheckCmd_PrePushHookHealthy(t *testing.T) {
prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "scripts/git-hooks", nil })
t.Cleanup(func() { check.SetGitConfigFnForTest(prev) })

out := captureCheckOutput(t, func(home, project string) {
require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(project, "scripts", "git-hooks"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(project, "scripts", "git-hooks", "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755))
})

assert.Contains(t, out, "pre-push hook:")
assert.Contains(t, out, "✓ core.hooksPath=scripts/git-hooks")
}

func TestCheckCmd_PrePushHookHealthyAbsolutePath(t *testing.T) {
var hookPath string
prev := check.SetGitConfigFnForTest(func(string) (string, error) { return hookPath, nil })
t.Cleanup(func() { check.SetGitConfigFnForTest(prev) })

out := captureCheckOutput(t, func(home, project string) {
hookPath = filepath.Join(project, "scripts", "git-hooks")
require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644))
require.NoError(t, os.MkdirAll(hookPath, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(hookPath, "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755))
})

assert.Contains(t, out, "pre-push hook:")
assert.Contains(t, out, "✓ core.hooksPath="+hookPath)
assert.NotContains(t, out, "pre-push hook not installed")
}

func TestCheckCmd_PrePushHookMissing(t *testing.T) {
prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "", nil })
t.Cleanup(func() { check.SetGitConfigFnForTest(prev) })

out := captureCheckOutput(t, func(home, project string) {
require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(project, "scripts", "git-hooks"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(project, "scripts", "git-hooks", "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755))
})

assert.Contains(t, out, "pre-push hook not installed")
assert.Contains(t, out, "run 'just hooks' to install the docs-drift pre-push hook")
}

func TestCheckCmd_PrePushHookSkippedOutsideKasmos(t *testing.T) {
prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "", nil })
t.Cleanup(func() { check.SetGitConfigFnForTest(prev) })

out := captureCheckOutput(t, nil) // no setup -> no docs-drift-map.yml

assert.NotContains(t, out, "pre-push hook:")
}

// TestCheckCmd_ShowsCopyGlyph verifies that a non-symlink directory in a harness dir
// shows the ≈ glyph.
func TestCheckCmd_ShowsCopyGlyph(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions internal/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type AuditResult struct {
Project []ProjectSkillEntry
InProject bool // whether cwd is a kas project
BinaryPath *BinaryPathResult // always populated
GitHooks *HookStatus
}

// Audit runs all three audit layers and returns a complete result.
Expand Down Expand Up @@ -96,6 +97,10 @@ func Audit(home, projectDir string, registry *harness.Registry) *AuditResult {

// Binary path audit — always populated regardless of project detection.
result.BinaryPath = AuditBinaryPaths(home, projectDir, runtime.GOOS)
gh := CheckPrePushHook(projectDir)
if !gh.Skipped {
result.GitHooks = &gh
}

return result
}
Expand Down Expand Up @@ -138,5 +143,11 @@ func (r *AuditResult) Summary() (int, int) {
ok += bpOK
total += bpTotal
}
if r.GitHooks != nil {
total++
if r.GitHooks.Configured {
ok++
}
}
return ok, total
}
Loading
Loading