chore(fantasy): add automated sync workflow, vendor versioning, and patch support

GitHub Actions workflow for periodic Fantasy SDK synchronization with
upstream. Adds vendor version tracking, patches directory for local
modifications, and sync-fantasy.sh orchestration script.
This commit is contained in:
ZanzyTHEbar 2026-02-18 13:07:08 +00:00
parent db0ec104cd
commit 621da777c8
5 changed files with 566 additions and 17 deletions

126
.github/workflows/fantasy-sync.yml vendored Normal file
View file

@ -0,0 +1,126 @@
name: fantasy-sdk-sync-check
on:
schedule:
# Every Monday at 09:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
issues: write
contents: read
jobs:
check-upstream:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Read current vendored version
id: current
run: |
if [[ -f internal/fantasy/.vendor-version ]]; then
echo "version=$(cat internal/fantasy/.vendor-version | tr -d '[:space:]')" >> "$GITHUB_OUTPUT"
else
echo "version=unknown" >> "$GITHUB_OUTPUT"
fi
- name: Query latest upstream version
id: latest
run: |
LATEST=$(go list -m -versions charm.land/fantasy 2>/dev/null \
| awk '{$1=""; print}' | tr ' ' '\n' | grep -v '^\s*$' | tail -1)
echo "version=${LATEST}" >> "$GITHUB_OUTPUT"
- name: Compare versions
id: compare
run: |
CURRENT="${{ steps.current.outputs.version }}"
LATEST="${{ steps.latest.outputs.version }}"
echo "current=${CURRENT}"
echo "latest=${LATEST}"
if [[ "$CURRENT" == "$LATEST" ]]; then
echo "behind=false" >> "$GITHUB_OUTPUT"
else
echo "behind=true" >> "$GITHUB_OUTPUT"
fi
- name: Generate diff summary
if: steps.compare.outputs.behind == 'true'
id: diff
run: |
TARGET="${{ steps.latest.outputs.version }}"
go mod download "charm.land/fantasy@${TARGET}" >/dev/null 2>&1
CACHE_DIR="$(go env GOMODCACHE)/charm.land/fantasy@${TARGET}"
DIFF_STAT=$(git diff --no-index --stat -- internal/fantasy/ "${CACHE_DIR}" 2>/dev/null || true)
echo "stat<<EOF" >> "$GITHUB_OUTPUT"
echo "${DIFF_STAT}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Check for existing issue
if: steps.compare.outputs.behind == 'true'
id: dedup
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="P2: Fantasy SDK — upstream ${{ steps.latest.outputs.version }} available"
EXISTING=$(gh issue list --search "in:title \"${TITLE}\"" --state open --json number -q '.[0].number' 2>/dev/null || true)
if [[ -n "$EXISTING" ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Issue #${EXISTING} already exists."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub issue
if: steps.compare.outputs.behind == 'true' && steps.dedup.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT="${{ steps.current.outputs.version }}"
LATEST="${{ steps.latest.outputs.version }}"
DIFF_STAT="${{ steps.diff.outputs.stat }}"
TITLE="P2: Fantasy SDK — upstream ${LATEST} available (current: ${CURRENT})"
cat > /tmp/issue-body.md <<ISSUE_EOF
## Fantasy SDK upstream update available
| | Version |
|---|---------|
| **Vendored** | \`${CURRENT}\` |
| **Latest** | \`${LATEST}\` |
### Diff summary
\`\`\`
${DIFF_STAT}
\`\`\`
### How to sync
\`\`\`bash
make fantasy-diff FANTASY_VERSION=${LATEST} # preview changes
make fantasy-sync FANTASY_VERSION=${LATEST} # apply sync + re-apply patches
\`\`\`
After syncing:
1. Review the diff: \`git diff\`
2. Run full test suite: \`go test ./...\`
3. Commit: \`git add -A && git commit -m "build(fantasy): sync vendored SDK to ${LATEST}"\`
---
*Auto-generated by fantasy-sdk-sync-check workflow.*
ISSUE_EOF
gh issue create \
--title "${TITLE}" \
--body-file /tmp/issue-body.md \
--label enhancement \
--assignee "@me"

View file

@ -0,0 +1 @@
v0.8.1

View file

@ -2,12 +2,10 @@
## What This Is
This directory contains a vendored copy of `charm.land/fantasy` v0.8.1, the Charmbracelet
This directory contains a vendored copy of `charm.land/fantasy`, the Charmbracelet
Fantasy LLM agent framework. We vendor it to enable direct modifications for PicoClaw-specific
features (progressive disclosure, custom streaming hooks, tool call repair, etc.).
## How It Works
PicoClaw's `go.mod` contains a `replace` directive:
```
@ -17,25 +15,110 @@ replace charm.land/fantasy v0.8.1 => ./internal/fantasy
This redirects all `charm.land/fantasy` imports to this local copy. No import paths
need to change in either PicoClaw code or the fantasy source itself.
## Syncing Upstream Updates
## Automated Sync System
To pull in a new upstream version:
We maintain a two-layer automation system to keep this vendored copy in sync with upstream.
1. Check the upstream version: `go list -m -versions charm.land/fantasy`
2. Download it: `go mod download charm.land/fantasy@vX.Y.Z`
3. Copy to vendor: `cp -r $(go env GOMODCACHE)/charm.land/fantasy@vX.Y.Z/* internal/fantasy/`
4. Fix permissions: `chmod -R u+w internal/fantasy/`
5. Re-apply local patches (see below)
6. Update the replace directive version in `go.mod` if needed
7. Run `go mod tidy && go test ./...`
### Monitoring (CI)
## Local Patches
A GitHub Actions workflow (`.github/workflows/fantasy-sync.yml`) runs weekly and:
Document all local modifications here:
1. Queries the Go module proxy for the latest `charm.land/fantasy` version
2. Compares it against the version in `.vendor-version`
3. If a new version exists, generates a diff summary and creates a GitHub issue
4. Issues are deduplicated — only one open issue per upstream version
| Date | File | Description |
|------|------|-------------|
| (none yet) | — | Initial vendor, no patches applied |
### Local Sync (Makefile targets)
| Command | Description |
|---------|-------------|
| `make fantasy-check` | Print current vs latest version, exit 1 if behind |
| `make fantasy-diff` | Show diff against upstream without modifying anything |
| `make fantasy-diff FANTASY_VERSION=v0.9.0` | Diff against a specific version |
| `make fantasy-sync` | Full sync to latest: download, replace, re-apply patches, test |
| `make fantasy-sync FANTASY_VERSION=v0.9.0` | Sync to a specific version |
| `make fantasy-patch NAME=my-change` | Save local modifications as a numbered patch file |
All targets are thin wrappers around `scripts/sync-fantasy.sh`.
## Sync Workflow
When the CI creates an issue (or you notice a new version), follow these steps:
```bash
# 1. Preview what changed upstream
make fantasy-diff FANTASY_VERSION=vX.Y.Z
# 2. If the changes look good, run the full sync
make fantasy-sync FANTASY_VERSION=vX.Y.Z
# 3. Review the result
git diff
# 4. Run the full test suite
go test ./...
# 5. Commit
git add -A && git commit -m "build(fantasy): sync vendored SDK to vX.Y.Z"
```
The sync script will:
- Download the target version from the Go module cache
- Replace the vendored copy (preserving the `patches/` directory)
- Re-apply all local patches in order
- Update `.vendor-version`, `go.mod` replace directive, and this file
- Run `go build` and `go test` for validation
If any patch fails to apply, the script aborts with a clear error message showing
which patch conflicted. You'll need to resolve the conflict manually, then re-save
the patch with `make fantasy-patch`.
## Patch Management
Local modifications to the vendored SDK are tracked as numbered `.patch` files in the
`patches/` directory:
```
internal/fantasy/patches/
0001-progressive-disclosure-hooks.patch
0002-custom-streaming-callbacks.patch
...
```
### Creating a patch
Before making a local modification:
1. Make your changes to files in `internal/fantasy/`
2. Run `make fantasy-patch NAME=descriptive-name`
3. The script diffs your working tree against the pristine upstream version
4. A numbered patch file is generated in `patches/`
### How patches are re-applied during sync
During `make fantasy-sync`, the script:
1. Replaces the entire vendored directory with the new upstream version
2. Restores the `patches/` directory
3. Applies each `.patch` file in sequence using `git apply`
4. If a patch fails, the sync halts — fix the conflict and re-save the patch
### Resolving patch conflicts
If a patch fails during sync:
1. The script prints which patch conflicted
2. Manually apply the intent of the patch to the new upstream code
3. Delete the old patch: `rm internal/fantasy/patches/NNNN-name.patch`
4. Re-save: `make fantasy-patch NAME=name`
5. Re-run: `make fantasy-sync FANTASY_VERSION=vX.Y.Z`
## Files
| File | Purpose |
|------|---------|
| `.vendor-version` | Machine-readable current vendored version (`v0.8.1`) |
| `patches/` | Directory of local modification patches |
| `patches/.gitkeep` | Ensures the directory is tracked in git |
| `VENDORING.md` | This documentation file |
## Original License

View file

339
scripts/sync-fantasy.sh Executable file
View file

@ -0,0 +1,339 @@
#!/usr/bin/env bash
set -euo pipefail
# sync-fantasy.sh — Monitor and sync the vendored charm.land/fantasy SDK.
#
# Usage:
# ./scripts/sync-fantasy.sh --check # compare vendored vs latest
# ./scripts/sync-fantasy.sh --diff [VERSION] # show diff without modifying
# ./scripts/sync-fantasy.sh --sync [VERSION] # full sync + patch re-apply
# ./scripts/sync-fantasy.sh --save-patch NAME # capture working-tree diff as patch
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VENDOR_DIR="${REPO_ROOT}/internal/fantasy"
VERSION_FILE="${VENDOR_DIR}/.vendor-version"
PATCHES_DIR="${VENDOR_DIR}/patches"
VENDORING_MD="${VENDOR_DIR}/VENDORING.md"
GO_MOD="${REPO_ROOT}/go.mod"
MODULE="charm.land/fantasy"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { printf "${CYAN}[fantasy-sync]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[fantasy-sync]${NC} %s\n" "$*" >&2; }
err() { printf "${RED}[fantasy-sync]${NC} %s\n" "$*" >&2; }
ok() { printf "${GREEN}[fantasy-sync]${NC} %s\n" "$*"; }
current_version() {
if [[ -f "$VERSION_FILE" ]]; then
cat "$VERSION_FILE" | tr -d '[:space:]'
else
# Fall back to parsing VENDORING.md header
grep -oP 'v[\d]+\.[\d]+\.[\d]+' "$VENDORING_MD" | head -1 || echo "unknown"
fi
}
latest_version() {
# Query the Go module proxy for the latest version.
# go list -m -versions returns all tags; we pick the last one.
local versions
versions=$(go list -m -versions "${MODULE}" 2>/dev/null | awk '{$1=""; print}' | tr ' ' '\n' | grep -v '^\s*$' | tail -1)
if [[ -z "$versions" ]]; then
err "Could not query versions for ${MODULE}"
exit 1
fi
echo "$versions"
}
download_version() {
local ver="$1"
log "Downloading ${MODULE}@${ver} ..." >&2
go mod download "${MODULE}@${ver}" >/dev/null 2>&1
local cache_dir
cache_dir="$(go env GOMODCACHE)/${MODULE}@${ver}"
if [[ ! -d "$cache_dir" ]]; then
err "Downloaded module not found at ${cache_dir}"
exit 1
fi
echo "$cache_dir"
}
# Extract version major component for breaking-change detection.
major_of() { echo "$1" | grep -oP '^v\K[0-9]+'; }
# ---------------------------------------------------------------------------
# --check
# ---------------------------------------------------------------------------
cmd_check() {
local cur latest
cur="$(current_version)"
latest="$(latest_version)"
printf "${BOLD}Vendored:${NC} %s\n" "$cur"
printf "${BOLD}Latest:${NC} %s\n" "$latest"
if [[ "$cur" == "$latest" ]]; then
ok "Up to date."
exit 0
fi
local cur_major latest_major
cur_major="$(major_of "$cur")"
latest_major="$(major_of "$latest")"
if [[ "$cur_major" != "$latest_major" ]]; then
warn "MAJOR version change (${cur_major} -> ${latest_major}) — expect breaking API changes."
fi
warn "Behind upstream by $(echo "$latest" | sed "s/$cur//") — run: make fantasy-sync VERSION=${latest}"
exit 1
}
# ---------------------------------------------------------------------------
# --diff [VERSION]
# ---------------------------------------------------------------------------
cmd_diff() {
local target="${1:-$(latest_version)}"
local cur
cur="$(current_version)"
log "Diffing vendored (${cur}) against upstream (${target}) ..."
local upstream_dir
upstream_dir="$(download_version "$target")"
local tmp_upstream
tmp_upstream="$(mktemp -d)"
trap "rm -rf '${tmp_upstream}'" EXIT
# Copy upstream to writable temp (module cache is read-only).
cp -r "${upstream_dir}/." "${tmp_upstream}/"
# Exclude non-source noise from the diff.
local diff_opts=(
--no-index
--stat
--diff-filter=ACDMRT
--
"${VENDOR_DIR}"
"${tmp_upstream}"
)
echo ""
printf "${BOLD}=== Upstream changes (${cur} -> ${target}) ===${NC}\n"
git diff --no-index --stat -- "${VENDOR_DIR}" "${tmp_upstream}" 2>/dev/null || true
echo ""
printf "${BOLD}=== Full diff ===${NC}\n"
git diff --no-index --no-color -- "${VENDOR_DIR}" "${tmp_upstream}" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# --sync [VERSION]
# ---------------------------------------------------------------------------
cmd_sync() {
local target="${1:-$(latest_version)}"
local cur
cur="$(current_version)"
if [[ "$cur" == "$target" ]]; then
ok "Already at ${target}. Nothing to do."
exit 0
fi
local cur_major target_major
cur_major="$(major_of "$cur")"
target_major="$(major_of "$target")"
if [[ "$cur_major" != "$target_major" ]]; then
warn "MAJOR version change (${cur_major} -> ${target_major}) — breaking API changes likely."
read -r -p "Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log "Aborted."; exit 0; }
fi
log "Syncing ${MODULE}: ${cur} -> ${target}"
# 1. Download upstream
local upstream_dir
upstream_dir="$(download_version "$target")"
# 2. Show what we're about to change
log "Changes from upstream:"
git diff --no-index --stat -- "${VENDOR_DIR}" "${upstream_dir}" 2>/dev/null || true
echo ""
# 3. Capture local modifications before overwriting.
# Compare current vendored copy against the CACHED version of the OLD upstream
# to see what local patches exist that aren't tracked yet.
local old_upstream_dir
old_upstream_dir="$(go env GOMODCACHE)/${MODULE}@${cur}" 2>/dev/null || true
if [[ -d "$old_upstream_dir" ]]; then
local untracked_diff
untracked_diff="$(git diff --no-index --stat -- "$old_upstream_dir" "${VENDOR_DIR}" 2>/dev/null || true)"
if [[ -n "$untracked_diff" ]]; then
warn "Detected LOCAL modifications not captured as patches:"
echo "$untracked_diff"
warn "These will be LOST unless you save them first: make fantasy-patch NAME=<description>"
read -r -p "Continue anyway? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log "Aborted. Save patches first."; exit 1; }
fi
fi
# 4. Replace vendored copy
log "Replacing vendored copy ..."
# Preserve our patches directory and metadata.
local tmp_patches
tmp_patches="$(mktemp -d)"
if [[ -d "$PATCHES_DIR" ]]; then
cp -r "${PATCHES_DIR}" "${tmp_patches}/patches"
fi
local tmp_version=""
[[ -f "$VERSION_FILE" ]] && tmp_version="$(cat "$VERSION_FILE")"
rm -rf "${VENDOR_DIR}"
cp -r "${upstream_dir}" "${VENDOR_DIR}"
chmod -R u+w "${VENDOR_DIR}"
# Restore patches directory and metadata.
if [[ -d "${tmp_patches}/patches" ]]; then
cp -r "${tmp_patches}/patches" "${PATCHES_DIR}"
else
mkdir -p "${PATCHES_DIR}"
fi
rm -rf "${tmp_patches}"
# 5. Re-apply local patches
local patch_count=0
local patch_failed=0
if compgen -G "${PATCHES_DIR}/*.patch" >/dev/null 2>&1; then
log "Re-applying local patches ..."
for patch_file in "${PATCHES_DIR}"/*.patch; do
local pname
pname="$(basename "$patch_file")"
if git apply --directory="internal/fantasy" --check "$patch_file" 2>/dev/null; then
git apply --directory="internal/fantasy" "$patch_file"
ok " Applied: ${pname}"
patch_count=$((patch_count + 1))
else
err " FAILED: ${pname}"
err " Conflict — resolve manually. Reject file may be in internal/fantasy/"
patch_failed=$((patch_failed + 1))
fi
done
fi
if [[ $patch_failed -gt 0 ]]; then
err "${patch_failed} patch(es) failed to apply. Fix conflicts, then run:"
err " go build ./... && go test ./internal/fantasy/... ./pkg/fantasy/..."
# Still update version file so re-running sync doesn't re-download.
echo "${target}" > "${VERSION_FILE}"
exit 1
fi
# 6. Update metadata
echo "${target}" > "${VERSION_FILE}"
# Update go.mod replace directive
sed -i "s|replace ${MODULE} v[^ ]* => ./internal/fantasy|replace ${MODULE} ${target} => ./internal/fantasy|" "${GO_MOD}"
# Update go.mod require version
sed -i "s|${MODULE} v[^ ]*|${MODULE} ${target}|" "${GO_MOD}"
# Update VENDORING.md header
if [[ -f "$VENDORING_MD" ]]; then
sed -i "s|${MODULE} v[0-9.]*|${MODULE} ${target}|g" "${VENDORING_MD}"
fi
# 7. Tidy and validate
log "Running go mod tidy ..."
(cd "${REPO_ROOT}" && go mod tidy)
log "Building ..."
(cd "${REPO_ROOT}" && go build ./...)
log "Testing fantasy packages ..."
(cd "${REPO_ROOT}" && go test ./internal/fantasy/... ./pkg/fantasy/... 2>&1) || {
warn "Some tests failed — review output above."
}
echo ""
ok "Sync complete: ${cur} -> ${target}"
[[ $patch_count -gt 0 ]] && ok "${patch_count} local patch(es) re-applied."
ok "Next steps:"
ok " 1. Review changes: git diff"
ok " 2. Run full tests: go test ./..."
ok " 3. Commit: git add -A && git commit -m 'build(fantasy): sync vendored SDK to ${target}'"
}
# ---------------------------------------------------------------------------
# --save-patch NAME
# ---------------------------------------------------------------------------
cmd_save_patch() {
local name="${1:?Usage: sync-fantasy.sh --save-patch <name>}"
local cur
cur="$(current_version)"
mkdir -p "${PATCHES_DIR}"
# We diff the module-cache copy of the current version against our vendored copy.
local pristine_dir
pristine_dir="$(go env GOMODCACHE)/${MODULE}@${cur}"
if [[ ! -d "$pristine_dir" ]]; then
log "Downloading pristine ${cur} for comparison ..."
go mod download "${MODULE}@${cur}" >/dev/null 2>&1
pristine_dir="$(go env GOMODCACHE)/${MODULE}@${cur}"
fi
# Count existing patches to determine next sequence number.
local next_num
next_num=$(printf "%04d" "$(( $(ls -1 "${PATCHES_DIR}"/*.patch 2>/dev/null | wc -l) + 1 ))")
local patch_file="${PATCHES_DIR}/${next_num}-${name}.patch"
# Generate the diff, stripping the absolute paths to make it relative.
git diff --no-index -- "${pristine_dir}" "${VENDOR_DIR}" 2>/dev/null \
| sed "s|a${pristine_dir}/|a/|g; s|b${VENDOR_DIR}/|b/|g" \
> "${patch_file}" || true
if [[ ! -s "$patch_file" ]]; then
rm -f "$patch_file"
warn "No local modifications detected against pristine ${cur}."
exit 0
fi
local stat
stat="$(diffstat -s "$patch_file" 2>/dev/null || wc -l < "$patch_file")"
ok "Saved patch: ${patch_file}"
ok "Stats: ${stat}"
}
# ---------------------------------------------------------------------------
# Main dispatch
# ---------------------------------------------------------------------------
usage() {
cat <<USAGE
Usage: $(basename "$0") <command> [args]
Commands:
--check Compare vendored vs latest upstream version
--diff [VERSION] Show diff without modifying (default: latest)
--sync [VERSION] Full sync: download, replace, re-apply patches, test
--save-patch NAME Capture local modifications as a numbered patch file
Environment:
MODULE=${MODULE}
VENDOR_DIR=${VENDOR_DIR}
USAGE
exit 1
}
case "${1:-}" in
--check) cmd_check ;;
--diff) cmd_diff "${2:-}" ;;
--sync) cmd_sync "${2:-}" ;;
--save-patch) cmd_save_patch "${2:-}" ;;
*) usage ;;
esac