1111import json
1212import os
1313import shutil
14+ import subprocess
1415import sys
1516from pathlib import Path
1617
17- PLUGINS_FILE = Path (".github/plugins.json" )
18- SOURCE = "skills" # the directory under jfrog-skills we vendor into each plugin
19- UPSTREAM_DIR = Path ("upstream" ) # sibling checkout of jfrog-skills@version (set by the workflow)
20- PLUGIN_DIR = Path ("plugin" ) # sibling checkout of the target plugin repo (set by the workflow)
21- USAGE = "usage: jfrog-sync-plugins.py matrix|copy"
18+ PLUGINS_FILE : Path = Path (".github/plugins.json" )
19+ SOURCE : str = "skills" # directory under jfrog-skills we vendor into each plugin
20+ UPSTREAM_DIR : Path = Path ("upstream" ) # sibling checkout of jfrog-skills@version (set by the workflow)
21+ PLUGIN_DIR : Path = Path ("plugin" ) # sibling checkout of the target plugin repo (set by the workflow)
22+ USAGE : str = "usage: jfrog-sync-plugins.py matrix|copy"
2223
2324
2425def fail (msg : str ) -> None :
@@ -29,25 +30,18 @@ def fail(msg: str) -> None:
2930def create_matrix () -> None :
3031 """Emit a GitHub Actions matrix from .github/plugins.json.
3132
32- Writes `matrix=<compact-json>` to $GITHUB_OUTPUT (the per-step output
33- file the runner provides automatically) and prints the matrix to
34- stdout for the run log.
33+ Writes `matrix=<compact-json>` to $GITHUB_OUTPUT and prints it to stdout.
3534
3635 Example input (.github/plugins.json):
37- {
38- "plugins": [
39- { "name": "claude-plugin", "dest_prefix": "" },
40- { "name": "cursor-plugin", "dest_prefix": "plugins/jfrog" }
41- ]
42- }
36+ { "plugins": [{ "name": "claude-plugin", "dest_prefix": "" }] }
4337
4438 Example output (appended to $GITHUB_OUTPUT):
45- matrix={"include":[{"name":"claude-plugin","dest_prefix":""},{"name":"cursor-plugin","dest_prefix":"plugins/jfrog"} ]}
39+ matrix={"include":[{"name":"claude-plugin","dest_prefix":""}]}
4640 """
47- data = json .loads (PLUGINS_FILE .read_text ())
48- matrix = {"include" : data ["plugins" ]}
41+ data : dict = json .loads (PLUGINS_FILE .read_text ())
42+ matrix : dict = {"include" : data ["plugins" ]}
4943
50- output_file = os .environ .get ("GITHUB_OUTPUT" )
44+ output_file : str | None = os .environ .get ("GITHUB_OUTPUT" )
5145 if output_file :
5246 with open (output_file , "a" , encoding = "utf-8" ) as f :
5347 f .write (f"matrix={ json .dumps (matrix , separators = (',' , ':' ))} \n " )
@@ -56,39 +50,77 @@ def create_matrix() -> None:
5650
5751
5852def copy_skills_folder () -> None :
59- """Copy this repo's `skills/` into a plugin checkout at DEST_PREFIX.
60-
61- Removes any existing destination first so the result matches upstream
62- exactly (no stale files left behind).
53+ """Copy skills/ into the plugin at DEST_PREFIX, then bump each configured
54+ version file in lock-step — but only if the copy actually produced changes.
6355
6456 Required env:
65- DEST_PREFIX Prefix inside the plugin, may be empty.
66-
67- Example (cursor-plugin layout, DEST_PREFIX=plugins/jfrog):
68- copies upstream/skills -> plugin/plugins/jfrog/skills
57+ DEST_PREFIX Prefix inside the plugin, may be empty.
58+ e.g. "plugins/jfrog" -> skills/ lands at plugins/jfrog/skills/.
6959
70- Example (claude-plugin layout, DEST_PREFIX=""):
71- copies upstream/skills -> plugin/skills
60+ Optional env:
61+ VERSION_BUMPS JSON array of {file, path} entries.
62+ Each entry points to a JSON file inside the plugin and a
63+ dotted path to the semver field to patch-bump.
7264 """
73- # strip("/") tolerates "plugins/jfrog ", "/plugins/jfrog/", and "" identically.
74- dest_prefix = os .environ .get ("DEST_PREFIX " , "" ). strip ( "/ " )
65+ dest_prefix : str = os . environ . get ( "DEST_PREFIX " , "" ). strip ( "/" )
66+ bumps : list [ dict ] = json . loads ( os .environ .get ("VERSION_BUMPS " , "[]" ) or "[] " )
7567
76- src_path = UPSTREAM_DIR / SOURCE
77- if not src_path .exists ():
78- fail (f"upstream missing { SOURCE } / at { src_path } " )
68+ src : Path = UPSTREAM_DIR / SOURCE
69+ if not src .exists ():
70+ fail (f"upstream missing { SOURCE } / at { src } " )
7971
80- dest = PLUGIN_DIR / dest_prefix / SOURCE if dest_prefix else PLUGIN_DIR / SOURCE
72+ dest : Path = PLUGIN_DIR / dest_prefix / SOURCE if dest_prefix else PLUGIN_DIR / SOURCE
8173 if dest .exists ():
8274 shutil .rmtree (dest )
8375 dest .parent .mkdir (parents = True , exist_ok = True )
84- shutil .copytree (src_path , dest )
85- print (f"Copied { src_path } -> { dest .relative_to (PLUGIN_DIR )} " )
76+ shutil .copytree (src , dest )
77+ print (f"Copied { src } -> { dest .relative_to (PLUGIN_DIR )} " )
78+
79+ if not bumps :
80+ return
81+ if not has_changes (PLUGIN_DIR ):
82+ print ("No skill changes detected — skipping version bumps." )
83+ return
84+ for bump in bumps :
85+ bump_patch_version (PLUGIN_DIR / bump ["file" ], bump ["path" ])
86+
87+
88+ def has_changes (repo_dir : Path ) -> bool :
89+ """Return True if `git status --porcelain` reports any working-tree changes."""
90+ result = subprocess .run (
91+ ["git" , "status" , "--porcelain" ],
92+ cwd = repo_dir ,
93+ capture_output = True ,
94+ text = True ,
95+ check = True ,
96+ )
97+ return bool (result .stdout .strip ())
98+
99+
100+ def bump_patch_version (file_path : Path , dotted_path : str ) -> None :
101+ data : dict = json .loads (file_path .read_text ())
102+
103+ # Convert numeric parts to int indices up front, e.g.
104+ # "plugins.0.version" -> ["plugins", 0, "version"].
105+ keys : list = [int (p ) if p .isdigit () else p for p in dotted_path .split ("." )]
106+ parent = data
107+ for key in keys [:- 1 ]:
108+ parent = parent [key ]
109+ leaf_key = keys [- 1 ]
110+
111+ current : str = parent [leaf_key ]
112+ major , minor , patch = current .split ("." )
113+ new_version : str = f"{ major } .{ minor } .{ int (patch ) + 1 } "
114+ parent [leaf_key ] = new_version
115+
116+ file_path .write_text (json .dumps (data , indent = 2 ) + "\n " )
117+ print (f"Bumped { file_path .relative_to (PLUGIN_DIR )} { dotted_path } : { current } -> { new_version } " )
86118
87119
88120def main () -> None :
89121 # sys.argv[0] is the script path; sys.argv[1] is the first real argument.
90122 # We expect exactly one argument: either "matrix" or "copy".
91- command = sys .argv [1 ] if len (sys .argv ) == 2 else ""
123+ command : str = sys .argv [1 ] if len (sys .argv ) == 2 else ""
92124
93125 if command == "matrix" :
94126 create_matrix ()
0 commit comments