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 ## 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 Fantasy LLM agent framework. We vendor it to enable direct modifications for PicoClaw-specific
features (progressive disclosure, custom streaming hooks, tool call repair, etc.). features (progressive disclosure, custom streaming hooks, tool call repair, etc.).
## How It Works
PicoClaw's `go.mod` contains a `replace` directive: 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 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. 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` ### Monitoring (CI)
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 ./...`
## 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 | ### Local Sync (Makefile targets)
|------|------|-------------|
| (none yet) | — | Initial vendor, no patches applied | | 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 ## 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