Skip to content

Commit 11b6006

Browse files
cjkennedclaude
andcommitted
feat(versioncheck): nudge users when the installed skill is behind
Stamps the expected skill version into the binary at build time (read from skills/airbyte-agent/SKILL.md) and compares it to the installed copy of SKILL.md at runtime. Prints a one-line stderr nudge — with both `npx skills add` and install.sh upgrade paths — only when the installed skill is older than what this CLI was built against. Silent otherwise. A new CI workflow blocks PRs that touch skills/airbyte-agent/** without strictly bumping metadata.version, so the signal stays honest over time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f71f629 commit 11b6006

10 files changed

Lines changed: 413 additions & 9 deletions

File tree

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
app-id: ${{ secrets.OCTAVIA_BOT_APP_ID }}
4040
private-key: ${{ secrets.OCTAVIA_BOT_PRIVATE_KEY }}
4141

42+
- name: Extract skill version
43+
run: echo "SKILL_VERSION=$(./scripts/skill-version.sh)" >> "$GITHUB_ENV"
44+
4245
- name: Run GoReleaser
4346
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
4447
with:
@@ -51,3 +54,6 @@ jobs:
5154
# Cross-repo token (App-minted): lets goreleaser commit
5255
# Formula/airbyte-agent.rb to airbytehq/homebrew-tap.
5356
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
57+
# Skill version stamped into the binary via cmd.ExpectedSkillVersion.
58+
# Sourced from skills/airbyte-agent/SKILL.md by the previous step.
59+
SKILL_VERSION: ${{ env.SKILL_VERSION }}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: skill-version-bump
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'skills/airbyte-agent/**'
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
check:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Compare skill version against base branch
20+
run: |
21+
set -euo pipefail
22+
head=$(./scripts/skill-version.sh)
23+
if ! base=$(git show "origin/${{ github.base_ref }}":skills/airbyte-agent/SKILL.md 2>/dev/null | ./scripts/skill-version.sh /dev/stdin 2>/dev/null); then
24+
echo "::notice::Base branch has no skill version yet — accepting head=$head as the bootstrap."
25+
exit 0
26+
fi
27+
if [ "$head" = "$base" ]; then
28+
echo "::error::Skill version not bumped ($base unchanged). Bump skills/airbyte-agent/SKILL.md metadata.version."
29+
exit 1
30+
fi
31+
greater=$(printf '%s\n%s\n' "$base" "$head" | sort -V | tail -1)
32+
if [ "$greater" != "$head" ]; then
33+
echo "::error::Skill version $head must be strictly greater than base $base."
34+
exit 1
35+
fi
36+
echo "Skill version bumped: $base → $head"

.goreleaser.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ builds:
1919
- -X github.com/airbytehq/airbyte-agent-cli/cmd.Version={{.Version}}
2020
- -X github.com/airbytehq/airbyte-agent-cli/cmd.Commit={{.Commit}}
2121
- -X github.com/airbytehq/airbyte-agent-cli/cmd.Date={{.Date}}
22+
- -X github.com/airbytehq/airbyte-agent-cli/cmd.ExpectedSkillVersion={{.Env.SKILL_VERSION}}
2223
goos:
2324
- linux
2425
- darwin

Makefile

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
2-
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
3-
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
1+
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
2+
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
3+
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
4+
SKILL_VERSION ?= $(shell ./scripts/skill-version.sh 2>/dev/null || echo "dev")
45

56
LDFLAGS = -X github.com/airbytehq/airbyte-agent-cli/cmd.Version=$(VERSION) \
67
-X github.com/airbytehq/airbyte-agent-cli/cmd.Commit=$(COMMIT) \
7-
-X github.com/airbytehq/airbyte-agent-cli/cmd.Date=$(DATE)
8+
-X github.com/airbytehq/airbyte-agent-cli/cmd.Date=$(DATE) \
9+
-X github.com/airbytehq/airbyte-agent-cli/cmd.ExpectedSkillVersion=$(SKILL_VERSION)
810

911
.PHONY: build generate test lint vet fmt check install clean
1012

cmd/version.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import (
77
)
88

99
var (
10-
Version = "dev"
11-
Commit = "none"
12-
Date = "unknown"
10+
Version = "dev"
11+
Commit = "none"
12+
Date = "unknown"
13+
ExpectedSkillVersion = "dev"
1314
)
1415

1516
var versionCmd = &cobra.Command{

internal/versioncheck/skill.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package versioncheck
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
const (
13+
skillDirEnv = "AIRBYTE_AGENT_SKILLS_DIR"
14+
defaultSkillsDir = ".claude/skills"
15+
skillName = "airbyte-agent"
16+
skillFileName = "SKILL.md"
17+
)
18+
19+
// CheckSkill prints a one-line stderr warning when the installed
20+
// airbyte-agent skill is older than the version this binary was built
21+
// against. Silent in every other case: when disabled, when the output
22+
// stream is not a terminal, when either version is a non-release tag
23+
// (e.g. "dev"), when the skill file is missing, or when the installed
24+
// version is equal to or newer than expected.
25+
//
26+
// Unlike Run, CheckSkill does no network I/O — the expected version is
27+
// stamped into the binary at build time and the installed version lives
28+
// on local disk.
29+
func CheckSkill(expectedSkillVersion string, enabled bool, isTTY bool, stderr io.Writer) {
30+
if !enabled || !isTTY {
31+
return
32+
}
33+
expected, ok := parseVersion(expectedSkillVersion)
34+
if !ok {
35+
return
36+
}
37+
installedRaw, ok := readInstalledSkillVersion()
38+
if !ok {
39+
return
40+
}
41+
installed, ok := parseVersion(installedRaw)
42+
if !ok {
43+
return
44+
}
45+
if compareVersions(installed, expected) >= 0 {
46+
return
47+
}
48+
fmt.Fprint(stderr, formatSkillNudge(installedRaw, expectedSkillVersion))
49+
}
50+
51+
// skillFrontmatterVersionRE pulls `metadata.version` out of the YAML
52+
// frontmatter without pulling in a YAML dependency. It expects the
53+
// indented `version:` to appear under a `metadata:` block. Quotes
54+
// around the value are optional.
55+
var skillFrontmatterVersionRE = regexp.MustCompile(`(?m)^metadata:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+version:[ \t]*["']?([^"'\n]+?)["']?[ \t]*$`)
56+
57+
func readInstalledSkillVersion() (string, bool) {
58+
path := installedSkillPath()
59+
if path == "" {
60+
return "", false
61+
}
62+
data, err := os.ReadFile(path)
63+
if err != nil {
64+
return "", false
65+
}
66+
fm, ok := frontmatterBlock(data)
67+
if !ok {
68+
return "", false
69+
}
70+
m := skillFrontmatterVersionRE.FindSubmatch(fm)
71+
if m == nil {
72+
return "", false
73+
}
74+
return strings.TrimSpace(string(m[1])), true
75+
}
76+
77+
// frontmatterBlock returns the bytes between the leading `---` fence and
78+
// the next `---` fence. Returns false when the file lacks frontmatter.
79+
func frontmatterBlock(data []byte) ([]byte, bool) {
80+
s := string(data)
81+
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
82+
return nil, false
83+
}
84+
rest := strings.TrimPrefix(strings.TrimPrefix(s, "---\r\n"), "---\n")
85+
end := strings.Index(rest, "\n---")
86+
if end < 0 {
87+
return nil, false
88+
}
89+
return []byte(rest[:end+1]), true
90+
}
91+
92+
func installedSkillPath() string {
93+
if dir := strings.TrimSpace(os.Getenv(skillDirEnv)); dir != "" {
94+
return filepath.Join(dir, skillName, skillFileName)
95+
}
96+
home, err := os.UserHomeDir()
97+
if err != nil {
98+
return ""
99+
}
100+
return filepath.Join(home, defaultSkillsDir, skillName, skillFileName)
101+
}
102+
103+
func formatSkillNudge(installed, expected string) string {
104+
var b strings.Builder
105+
fmt.Fprintf(&b, "Your airbyte-agent skill is %s — this CLI expects %s.\n", installed, expected)
106+
b.WriteString(" Reinstall with npx: npx skills add airbytehq/airbyte-agent-cli\n")
107+
b.WriteString(" Or reinstall via: curl -fsSL https://airbyte.ai/install.sh | sh\n")
108+
b.WriteString(" (silence: set \"version_check_enabled\": false in ~/.airbyte-agent/settings.json)\n")
109+
return b.String()
110+
}

0 commit comments

Comments
 (0)