From 621da777c8739dafe627fab08c2125ee8d6d8621 Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Wed, 18 Feb 2026 13:07:08 +0000 Subject: [PATCH] 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. --- .github/workflows/fantasy-sync.yml | 126 +++++++++++ internal/fantasy/.vendor-version | 1 + internal/fantasy/VENDORING.md | 117 ++++++++-- internal/fantasy/patches/.gitkeep | 0 scripts/sync-fantasy.sh | 339 +++++++++++++++++++++++++++++ 5 files changed, 566 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/fantasy-sync.yml create mode 100644 internal/fantasy/.vendor-version create mode 100644 internal/fantasy/patches/.gitkeep create mode 100755 scripts/sync-fantasy.sh diff --git a/.github/workflows/fantasy-sync.yml b/.github/workflows/fantasy-sync.yml new file mode 100644 index 000000000..bc7ed8bf9 --- /dev/null +++ b/.github/workflows/fantasy-sync.yml @@ -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<> "$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 < ./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 diff --git a/internal/fantasy/patches/.gitkeep b/internal/fantasy/patches/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/sync-fantasy.sh b/scripts/sync-fantasy.sh new file mode 100755 index 000000000..752df2faa --- /dev/null +++ b/scripts/sync-fantasy.sh @@ -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=" + 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 }" + 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 < [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