From 27518b7dbb25509ebe008455f356a2d65ba6a746 Mon Sep 17 00:00:00 2001 From: David Tran Date: Tue, 12 May 2026 15:45:45 +0700 Subject: [PATCH 1/2] feat: auto-pin SHA + testable validation scripts - Refactor inline Python from validate.yml into scripts/validate_schema.py and scripts/validate_reachability.py (both callable as CLI tools) - Add tests/ with 16 unit tests covering schema validation and reachability checks (403, 404, auth header injection) - Add .github/workflows/test.yml to run pytest on scripts/tests/ changes - Update CONTRIBUTING.md: document SHA-based versioning (no version field in plugin.json), add auto-pin setup guide with pin-sha.yml template, label/PAT/branch-protection checklist - Update README.md to reference auto-pinning and CONTRIBUTING guide --- .github/workflows/test.yml | 20 +++ .github/workflows/validate.yml | 68 +--------- CONTRIBUTING.md | 118 ++++++++++++++++-- README.md | 5 +- .../validate_reachability.cpython-312.pyc | Bin 0 -> 3767 bytes .../validate_schema.cpython-312.pyc | Bin 0 -> 2312 bytes scripts/validate_reachability.py | 58 +++++++++ scripts/validate_schema.py | 42 +++++++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 162 bytes ..._reachability.cpython-312-pytest-9.0.3.pyc | Bin 0 -> 11475 bytes ...lidate_schema.cpython-312-pytest-9.0.3.pyc | Bin 0 -> 11101 bytes tests/test_validate_reachability.py | 104 +++++++++++++++ tests/test_validate_schema.py | 87 +++++++++++++ 14 files changed, 428 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 scripts/__pycache__/validate_reachability.cpython-312.pyc create mode 100644 scripts/__pycache__/validate_schema.cpython-312.pyc create mode 100644 scripts/validate_reachability.py create mode 100644 scripts/validate_schema.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_validate_schema.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/test_validate_reachability.py create mode 100644 tests/test_validate_schema.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2445975 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test validation scripts + +on: + push: + paths: + - 'scripts/**' + - 'tests/**' + - '.github/workflows/test.yml' + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install pytest + - run: pytest tests/ -v diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7185589..622e506 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,73 +15,9 @@ jobs: - uses: actions/checkout@v4 - name: Validate marketplace.json schema - run: | - python3 -c " - import json, sys - with open('.claude-plugin/marketplace.json') as f: - m = json.load(f) - assert 'name' in m, 'missing name' - assert 'plugins' in m, 'missing plugins' - for p in m['plugins']: - assert 'name' in p, f'plugin missing name: {p}' - assert 'source' in p, f'plugin missing source: {p[\"name\"]}' - src = p['source'] - if isinstance(src, dict) and src.get('source') == 'git-subdir': - assert 'url' in src, f'git-subdir missing url: {p[\"name\"]}' - assert 'path' in src, f'git-subdir missing path: {p[\"name\"]}' - print(f'OK: {len(m[\"plugins\"])} plugin(s) validated') - " + run: python3 scripts/validate_schema.py .claude-plugin/marketplace.json - name: Check plugin sources are reachable env: GH_TOKEN: ${{ github.token }} - run: | - python3 -c " - import json, os, re, sys, urllib.request - with open('.claude-plugin/marketplace.json') as f: - m = json.load(f) - token = os.environ.get('GH_TOKEN', '') - api_headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} - if token: - api_headers['Authorization'] = f'Bearer {token}' - errors = [] - for p in m['plugins']: - src = p['source'] - if not isinstance(src, dict) or src.get('source') != 'git-subdir': - continue - url = src['url'] - ref = src.get('ref', 'main') - name = p['name'] - match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url) - if not match: - errors.append(f'{name}: non-GitHub URL not supported yet: {url}') - continue - owner, repo = match.group(1), match.group(2) - # Try branch first, then tag - checked = False - for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]: - api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}' - req = urllib.request.Request(api_url, headers=api_headers) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - if resp.status == 200: - print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})') - checked = True - break - except urllib.error.HTTPError as e: - if e.code != 404: - errors.append(f'{name}: API error {e.code} for {kind} {ref}') - checked = True - break - except Exception as e: - errors.append(f'{name}: request failed: {e}') - checked = True - break - if not checked: - errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}') - if errors: - print('ERRORS:', file=sys.stderr) - for e in errors: - print(f' {e}', file=sys.stderr) - sys.exit(1) - " + run: python3 scripts/validate_reachability.py .claude-plugin/marketplace.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a09b6d9..c03c907 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ In your project repo, create `dist//` with: ``` dist// ├── .claude-plugin/ -│ └── plugin.json # name, version, userConfig, mcpServers, skills, agents, commands +│ └── plugin.json # name, userConfig, mcpServers, skills, agents, commands (no version field — see below) ├── .mcp.json # MCP server config using ${user_config.*} refs ├── skills/ # SKILL.md files ├── agents/ # agent .md files (optional) @@ -23,6 +23,8 @@ Follow the [Claude Code plugin spec](https://code.claude.com/docs/en/plugins-ref For HTTP MCP servers requiring authentication, use `userConfig` with `"sensitive": true` — **never hardcode API keys**. +**Do not set `version` in `plugin.json`.** Claude Code uses the pinned SHA as the version identifier. Every time the SHA in `marketplace.json` is updated, users automatically receive the new content. If you set an explicit version string, you would need to bump it manually on every change — the auto-pin workflow would not be enough. + ### 2. Open a PR here Add an entry to `.claude-plugin/marketplace.json`: @@ -34,24 +36,126 @@ Add an entry to `.claude-plugin/marketplace.json`: "source": "git-subdir", "url": "https://github.com/Viindoo/.git", "path": "dist/", - "ref": "main", + "ref": "master", "sha": "" }, "description": "One-line description (max 120 chars)" } ``` -Pin `sha` to the exact commit where your plugin landed on `main`. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable. +Pin `sha` to the exact commit where your plugin landed. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable. + +### 3. Set up auto-pinning in your plugin repo + +After registering the plugin, wire up the auto-pin workflow so that every merge to `master` that touches `dist//` automatically opens a SHA-update PR here. + +**Required setup (one-time per plugin repo):** + +1. **Create a fine-grained PAT** targeting `Viindoo/claude-plugins` with permissions: + - `Contents: Read and write` + - `Pull requests: Read and write` + +2. **Add the PAT as a secret** in your plugin repo: + `Settings → Secrets → Actions → New repository secret` + Name: `CLAUDE_PLUGINS_PAT` + +3. **Create the `auto-pin` label** in `Viindoo/claude-plugins`: + ```bash + gh label create auto-pin --repo Viindoo/claude-plugins --color 0075ca --description "Automated SHA pin update" + ``` + +4. **Add the workflow** `.github/workflows/pin-sha.yml` to your plugin repo (see template below). + +**Template `pin-sha.yml`** (replace `` and ``): + +```yaml +name: Pin SHA in claude-plugins + +on: + push: + branches: [master] + paths: + - 'dist//**' + +jobs: + pin-sha: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get SHA + id: info + run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - uses: actions/checkout@v4 + with: + repository: Viindoo/claude-plugins + token: ${{ secrets.CLAUDE_PLUGINS_PAT }} + path: claude-plugins + + - name: Check if already pinned + id: check + run: | + CURRENT=$(python3 -c " + import json + m = json.load(open('claude-plugins/.claude-plugin/marketplace.json')) + for p in m['plugins']: + if p['name'] == '': + print(p['source'].get('sha', '')) + ") + [ "$CURRENT" = "${{ steps.info.outputs.sha }}" ] && echo "skip=true" >> $GITHUB_OUTPUT || echo "skip=false" >> $GITHUB_OUTPUT + + - name: Update marketplace.json + if: steps.check.outputs.skip == 'false' + run: | + python3 -c " + import json + path = 'claude-plugins/.claude-plugin/marketplace.json' + with open(path) as f: m = json.load(f) + for p in m['plugins']: + if p['name'] == '': + p['source']['sha'] = '${{ steps.info.outputs.sha }}' + with open(path, 'w') as f: json.dump(m, f, indent=2); f.write('\n') + " + + - name: Create PR with auto-merge + if: steps.check.outputs.skip == 'false' + working-directory: claude-plugins + env: + GH_TOKEN: ${{ secrets.CLAUDE_PLUGINS_PAT }} + run: | + SHA="${{ steps.info.outputs.sha }}" + SHA7="${SHA:0:7}" + BRANCH="auto-pin/-${SHA7}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add .claude-plugin/marketplace.json + git commit -m "pin to ${SHA7}" + git push origin "$BRANCH" + PR_URL=$(gh pr create \ + --repo Viindoo/claude-plugins \ + --title "pin to ${SHA7}" \ + --body "Auto-pin: plugin content changed in [${SHA7}](https://github.com/Viindoo//commit/${SHA})." \ + --label "auto-pin") + gh pr merge --auto --squash "$PR_URL" +``` + +**Enable auto-merge in `Viindoo/claude-plugins`** (one-time repo setting): +`Settings → General → Pull Requests → Allow auto-merge` ✓ + +**Enable branch protection on `master`** (required for auto-merge to work): +`Settings → Branches → Add rule → master → Require status checks → validate` -### 3. After your PR merges +### 4. After the first PR merges -Bump the `sha` here whenever you ship a new plugin version. Open a follow-up PR with the new SHA + updated `ref` if needed. +Subsequent plugin updates are fully automatic — the `pin-sha.yml` workflow handles everything. No manual SHA updates needed. ## Anti-drift CI `.github/workflows/validate.yml` runs nightly and on every change to `marketplace.json`: -- Validates JSON schema -- Checks each `git-subdir` source URL + ref is still reachable via `git ls-remote` +- Validates JSON schema (`scripts/validate_schema.py`) +- Checks each `git-subdir` source URL + ref is still reachable via GitHub API (`scripts/validate_reachability.py`) If CI fails: the plugin source has moved or been deleted. Update `marketplace.json` to the new location. diff --git a/README.md b/README.md index bc60edf..8d14ea8 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ Or in Claude Code interactive session: Each Viindoo project hosts its own plugin source under `dist//`. To add a new plugin to this marketplace: -1. Create `dist//` in your project repo with `.claude-plugin/plugin.json` +1. Create `dist//` in your project repo with `.claude-plugin/plugin.json` (no `version` field — SHA is used as the version identifier) 2. Open a PR here adding an entry to `.claude-plugin/marketplace.json` 3. Pin `sha` to the exact commit of your plugin source after it merges +4. Set up `.github/workflows/pin-sha.yml` in your plugin repo — subsequent updates are then fully automatic + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full setup guide including the auto-pin workflow template. See [Anti-drift CI](.github/workflows/validate.yml) — nightly validation checks each plugin source is still reachable and schema-valid. diff --git a/scripts/__pycache__/validate_reachability.cpython-312.pyc b/scripts/__pycache__/validate_reachability.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c1e7c2312559a13b051ff2ef5daa3652d359f2c GIT binary patch literal 3767 zcma(UU2qe}c~5uJova_rviv7vd|*Q)z*2w)9LJ_H!8Rt?{6la6&!IibC(Dw!J3)Lo zwVr7oTV(V*xo+r$NI$M;AwQnUwu*(Jglxwh=#m zM6>8V0M29f{&It(Of3HpT(kT*-^2#y|FU|NB2Un9kNDFUS&yLMoCnV|{@wqEA$e0D zpXrZ%c^PCVmCzU?hUuOjI%WEuAsNO`DXOZ?q>ka5d3wYgPw(K&QqW@ZXJ|-623kL| zWEh>6IcU>q5*wX?Y#U~cf{gX&7HGu&0LW421pqQO-3IL>8yQx0neug3b(^>;UH0kh zGcB;2{=hP$%BYEu&O(PFlY0)26S_>f89TPXZBUn~6{gmh*ThN?mh0SOE{E<=YIJL? zs&sB*dKs=_kIrG=Y11|eOpNM|SdB?jg0RA*dCs}lJkPi?PFR_7Lm#Zt?If~;7FZ2y zOnlISEAxE-&f&KtQ=aj_THT2)sncC}_Uj(ZVarsgkL&wc?@z40WSNQS6*_Z$!(-3A zcJL!I8cfa-)V=V9>-DA#T&0zIWsEPKn;6_^^pt?w_;{zmh$sJqFPn7y&cV&ZZ`gRv zVaA67uqorV8#Vo8RZ}40_}|@$4;Y}kWlN5l==y;?Zo^tEaC=GnHMXP=^Fl1edIB_p zoj{?kSSWB7Q241y`u2PbS*U{U@T>WO`eIv8^TC#F~J7FyqsGG2C~b9&n3M z{h=T5W$Uv!aTH%PA{sC%=?VWxJkR2IW_&;M;R>EHaqQj8Yr9t{b(?7kwyqId_xyJv zsQbSEPCO-l(Uf@wti>p}qYrW22M10bd84xr0d0ejO2ws!pi0R^_;LbG`nMbaU zv^tcOCC!kD*i<|nl@bcFDao`P5w$X`p;<{^gp!Qtv>ZpYEDj=OM354QNeCmN)-
q z*>E9<35cvwIhjr&8_rBzx`^0yqY!(1%{s*56GNE|i&E1HvZiDyp(3l{60#pWdGcr{ z@eVPOBovXo^KDW#xJH6ih)D_R5TXVVHG-^aa#&2rl{jR`W-u#=N+DX2BQp8YlF8*L zlN(IlOlYjhD+v)xB{@NCWJrt*ix4rx61JMdc^fcfvjkf&t4oAfNg+y9$fd$RYdky~ z9!idgVHFogb0V6IG^=SLVI(^e7t&B{UN7KgA+07;QX;J494X;YD6B+eDTUF?LR^A^ zDh|k^5E&9KN^waY4W&kr)i6?i4eKT!h|0UT5XZoZ-IawC{@kS%r)RQfyeB8TdpJuy zph21M8>f5n(D(PUxoxboShuHe^xQ4?`LXV0)>+)LWA4;kccJkoSvFUG zi{p!~^0BU>%bgv4?_8EDy32Fi_;8k9DX*Jp{7Ai1-ZV%5mi>&K=avGms>^c0=G zqPrruWjvOpF)|q)kLJXg(@X9qye{|Ts>esO%!vFlT==4tZ zj`z--oariT?pjb5#Df3u9cM2QSiNC-cxrfd)2|b=;-|x(49{=+O(LsitwnE5mM!vu zxvu%$^IPUZg@#x1oeQTHcQ0;PXfM2aY-!{1@xJVVT%cI{^1L{ICf|{V`O^!pEWWvT zs8D?-+r8qgoeoWf?s@~ayn(rz{EG!|V9C2b`}(TI?W$g>+Aw`#>cZWs?YFA7&%K`a z7OJ-2scKtbiZvUi-OLn?yr4y|7L-2)1&V=;H&=@(5~vDr+V5o<(l=)+6r4+@)dcyu(9oq zr@iRkm}9^8R6SZ{G1Wt*7Z>G3bvH}@dEcgPp7~2dsN2VW;o>mK%pmS`GWjso*v{j}j~qYIiaRDB^vL8N5%~o&ZNd|73Zn{g zuJxpl(JSOFWZ%8Mwce$SSB)4!?va53M8`=}6y)e-ncS)P4pMR3#yv!QTTmdQTX47Jl|N~J)V|~n6**6qp5(^4 zT*ZWA+2zgco^F|Hndz8%b;;E**0s#Ku!O8M6ZtrHHTESJ{98p$wqw~@k=viUl#Ap7 kvz4FvKk3!`SZs0%=;EZ2$lO literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/validate_schema.cpython-312.pyc b/scripts/__pycache__/validate_schema.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e21265e26ae0a206472956330771b0d2d32bec4 GIT binary patch literal 2312 zcma)7O>EOv9Dgr%Vkfcl(X@r6h#43X(P*Q>prs%>MybR$K+prDVv0OJlRC{;ZKqHx z6`j~2(IVA7k%}SU#3pv=ZsD{8$6W$#r_|%Xw8O?>QUwPjPJ1t@Qwpu%MS1V}|9-#! z_p^WJc@MzD@vkEpbpr4=d9Vkwwt3m<0N^G-0KpU(ZLV+>BFFb&)FD%d8s7(i`kw>H zv<#CCeYT3``T#apM347jWFJPlkaKe^0$~ik1(54Dg%Q4un#j{H%meHX84zGiFd1k* zy*VS+w%Ncqwy?z#TQ+jkExXmBhHvioTbA8J;2TUI^)whB*>elrCU)NAak-2BP_Mlo z;`Jdg%3%*cGV*5p&ArUEV)G$Lej769LSD?IWgp_+%mdCnf19$I5KO@-xCitC4PJFX za0zD}g0gUpx&*GmsP8#3byixdaKo+(&lLW@^fmfMZ=DrdxjqD@p-wQ3k)-Y2`dWat z2h$GRdHA>Pdjd>T2wrzgLN!>Rl6If2lL2zrYzPolAjr@bNO~PvphiH!fhdv*}L_%&EQ zTW}_M8?(M5m+c2)$(_VV3AqbS?Ekhcyzy-A4e}Be=HJ+Y$8F9l-=YD@;ye9ft@R6k z)}k|FS~fbS%5R8$pNzg5}b(c~z2) zZEqbn#RG!jNho@^mj4zhszv41l(BtX&{BrwSQniW^)Vy7$vHU~5TcAlXLEAKqLW${ z&#A14ESH$WB4l|K9GRwz8A-Niq)56&C1gG7QdwLp3tq8c+H%RNnpHIm<}6B6C5>Df zp)p>M#Ky8|Ii{;(raP0!O5J*1%*3!OF)1bHk=(tOhi);Clc!{2x~%D1tfwcYNve{= z+etB{AW@g&nlvV-#h%=h#o98p6I;TsdY}dI#hd|8-Fy+1Bj&_YE--t#blMbu_^JqB zQNYvwjQ7tB*BRbC|K0UE>zn0Ee66!*iH+5nKm|T#!YyI%5__QT?PS^8C>E% zBM_h8WJ}{k>Z!NGlz#kl*(=-~xqtEA#h<=j+GS={4iYg)^-jp1x{g)DkD$)LhJlUbys!xVYJ#++j(j^FuWQDzQ}3>aBVYZUVVZC zE=FE!UYwe8=gFhgqod9tnz7h;JS{4jc>Fo}>KII36BD@GjF50FEvnhx`rMZTf=cpkvnOc;Xfeo!qo}eRu2H0D;iIz@CxH89-Qqj_1~DT zz$Z+2h2>4ALfv8K*}K7cuQ^@&xUc5xuWG*;4~=E^i#i)9QnPG{HG^gE3Xh9+b8K#` zGBG#4$nTyRUSW8wp{P`(JIVRvG8_FfxUG0%g$tUWn-ivF?z zHfX~=D$S0S#>|P*_>wzP5$jw>acE<^cKt=Wi$ilE^Sn7VcX=_myB63}+tXL`^e@v# faH;9Yt9Ib&s=0BIIUM#QK6G-BdNk-9VwwK|6q^lZ literal 0 HcmV?d00001 diff --git a/scripts/validate_reachability.py b/scripts/validate_reachability.py new file mode 100644 index 0000000..23e9838 --- /dev/null +++ b/scripts/validate_reachability.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import json, os, re, sys, urllib.error, urllib.request + + +def check(path): + with open(path) as f: + m = json.load(f) + token = os.environ.get('GH_TOKEN', '') + headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} + if token: + headers['Authorization'] = f'Bearer {token}' + errors = [] + for p in m['plugins']: + src = p['source'] + if not isinstance(src, dict) or src.get('source') != 'git-subdir': + continue + url = src['url'] + ref = src.get('ref', 'main') + name = p['name'] + match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url) + if not match: + errors.append(f'{name}: non-GitHub URL not supported yet: {url}') + continue + owner, repo = match.group(1), match.group(2) + checked = False + for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]: + api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}' + req = urllib.request.Request(api_url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status == 200: + print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})') + checked = True + break + except urllib.error.HTTPError as e: + if e.code != 404: + errors.append(f'{name}: API error {e.code} for {kind} {ref}') + checked = True + break + except Exception as e: + errors.append(f'{name}: request failed: {e}') + checked = True + break + if not checked: + errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}') + return errors + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + errors = check(sys.argv[1]) + if errors: + print('ERRORS:', file=sys.stderr) + for e in errors: + print(f' {e}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/validate_schema.py b/scripts/validate_schema.py new file mode 100644 index 0000000..0497935 --- /dev/null +++ b/scripts/validate_schema.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import json, sys + + +def validate(path): + with open(path) as f: + m = json.load(f) + errors = [] + if 'name' not in m: + errors.append('missing top-level name') + if 'plugins' not in m: + errors.append('missing top-level plugins') + return errors + for p in m['plugins']: + if 'name' not in p: + errors.append(f'plugin missing name: {p}') + continue + if 'source' not in p: + errors.append(f'plugin missing source: {p["name"]}') + continue + src = p['source'] + if isinstance(src, dict) and src.get('source') == 'git-subdir': + if 'url' not in src: + errors.append(f'git-subdir missing url: {p["name"]}') + if 'path' not in src: + errors.append(f'git-subdir missing path: {p["name"]}') + return errors + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + errors = validate(sys.argv[1]) + if errors: + print('ERRORS:', file=sys.stderr) + for e in errors: + print(f' {e}', file=sys.stderr) + sys.exit(1) + with open(sys.argv[1]) as f: + m = json.load(f) + print(f'OK: {len(m["plugins"])} plugin(s) validated') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..747617dc72efcaa4a2016e8a5689e8256b4159c8 GIT binary patch literal 162 zcmX@j%ge<81Y9qevOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a@5br&rQ`YDN4-K z%}dWu)-5Sb%+pWLEYVNSNi0oC)h);=P0!3L)=exe$uG#v(=SOaE-BWJkI&4@EQycT jE2zB1VUwGmQks)$SHuc5kr9ZCL5z>gjEsy$%s>_Za?vO9 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4d94e77a383f0bc1a5f6ef74d0e27a0caad57de GIT binary patch literal 11475 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 Kd-(+Y0RIIDJ@`@p literal 0 HcmV?d00001 diff --git a/tests/test_validate_reachability.py b/tests/test_validate_reachability.py new file mode 100644 index 0000000..ba94836 --- /dev/null +++ b/tests/test_validate_reachability.py @@ -0,0 +1,104 @@ +import io, json, os, sys, tempfile +from unittest.mock import MagicMock, patch +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) +from validate_reachability import check + + +def _write(tmp_path, data): + p = tmp_path / 'marketplace.json' + p.write_text(json.dumps(data)) + return str(p) + + +def _marketplace(ref='master'): + return { + 'name': 'test', + 'plugins': [ + { + 'name': 'my-plugin', + 'source': { + 'source': 'git-subdir', + 'url': 'https://github.com/Viindoo/my-repo.git', + 'path': 'dist/my-plugin', + 'ref': ref, + }, + } + ], + } + + +def _mock_resp(status=200): + resp = MagicMock() + resp.status = status + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def test_ok_on_200(tmp_path, capsys): + with patch('urllib.request.urlopen', return_value=_mock_resp(200)): + errors = check(_write(tmp_path, _marketplace())) + assert errors == [] + assert 'OK: my-plugin' in capsys.readouterr().out + + +def test_error_on_403(tmp_path): + import urllib.error + http_403 = urllib.error.HTTPError(url='', code=403, msg='Forbidden', hdrs={}, fp=None) + with patch('urllib.request.urlopen', side_effect=http_403): + errors = check(_write(tmp_path, _marketplace())) + assert any('403' in e for e in errors) + + +def test_ref_not_found_when_both_404(tmp_path): + import urllib.error + http_404 = urllib.error.HTTPError(url='', code=404, msg='Not Found', hdrs={}, fp=None) + with patch('urllib.request.urlopen', side_effect=http_404): + errors = check(_write(tmp_path, _marketplace())) + assert any('not found' in e for e in errors) + + +def test_non_github_url(tmp_path): + d = _marketplace() + d['plugins'][0]['source']['url'] = 'https://gitlab.com/some/repo.git' + errors = check(_write(tmp_path, d)) + assert any('not supported' in e for e in errors) + + +def test_non_git_subdir_skipped(tmp_path): + d = {'name': 'test', 'plugins': [{'name': 'p', 'source': 'local'}]} + with patch('urllib.request.urlopen') as mock_open: + errors = check(_write(tmp_path, d)) + mock_open.assert_not_called() + assert errors == [] + + +def test_gh_token_injected_as_auth_header(tmp_path): + captured_req = {} + + def fake_urlopen(req, timeout=None): + captured_req['headers'] = req.headers + return _mock_resp(200) + + with patch.dict(os.environ, {'GH_TOKEN': 'test-token-xyz'}): + with patch('urllib.request.urlopen', side_effect=fake_urlopen): + check(_write(tmp_path, _marketplace())) + + assert captured_req['headers'].get('Authorization') == 'Bearer test-token-xyz' + + +def test_no_token_omits_auth_header(tmp_path): + captured_req = {} + + def fake_urlopen(req, timeout=None): + captured_req['headers'] = req.headers + return _mock_resp(200) + + env = {k: v for k, v in os.environ.items() if k != 'GH_TOKEN'} + with patch.dict(os.environ, env, clear=True): + with patch('urllib.request.urlopen', side_effect=fake_urlopen): + check(_write(tmp_path, _marketplace())) + + assert 'Authorization' not in captured_req['headers'] diff --git a/tests/test_validate_schema.py b/tests/test_validate_schema.py new file mode 100644 index 0000000..87de584 --- /dev/null +++ b/tests/test_validate_schema.py @@ -0,0 +1,87 @@ +import json, os, sys, tempfile +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) +from validate_schema import validate + + +def _write(tmp_path, data): + p = tmp_path / 'marketplace.json' + p.write_text(json.dumps(data)) + return str(p) + + +def _valid(): + return { + 'name': 'test-marketplace', + 'plugins': [ + { + 'name': 'my-plugin', + 'source': { + 'source': 'git-subdir', + 'url': 'https://github.com/Viindoo/my-repo.git', + 'path': 'dist/my-plugin', + }, + 'description': 'A test plugin', + } + ], + } + + +def test_valid_passes(tmp_path): + assert validate(_write(tmp_path, _valid())) == [] + + +def test_missing_top_level_name(tmp_path): + d = _valid() + del d['name'] + errors = validate(_write(tmp_path, d)) + assert any('name' in e for e in errors) + + +def test_missing_plugins(tmp_path): + d = _valid() + del d['plugins'] + errors = validate(_write(tmp_path, d)) + assert any('plugins' in e for e in errors) + + +def test_plugin_missing_name(tmp_path): + d = _valid() + del d['plugins'][0]['name'] + errors = validate(_write(tmp_path, d)) + assert any('missing name' in e for e in errors) + + +def test_plugin_missing_source(tmp_path): + d = _valid() + del d['plugins'][0]['source'] + errors = validate(_write(tmp_path, d)) + assert any('missing source' in e for e in errors) + + +def test_git_subdir_missing_url(tmp_path): + d = _valid() + del d['plugins'][0]['source']['url'] + errors = validate(_write(tmp_path, d)) + assert any('missing url' in e for e in errors) + + +def test_git_subdir_missing_path(tmp_path): + d = _valid() + del d['plugins'][0]['source']['path'] + errors = validate(_write(tmp_path, d)) + assert any('missing path' in e for e in errors) + + +def test_non_git_subdir_source_skips_url_check(tmp_path): + d = _valid() + d['plugins'][0]['source'] = 'local' + errors = validate(_write(tmp_path, d)) + assert errors == [] + + +def test_sha_field_optional(tmp_path): + d = _valid() + d['plugins'][0]['source']['sha'] = 'abc1234' + assert validate(_write(tmp_path, d)) == [] From 2ca0ea1841e89b8d7672efbaf35436140a590484 Mon Sep 17 00:00:00 2001 From: David Tran Date: Tue, 12 May 2026 15:45:53 +0700 Subject: [PATCH 2/2] chore: ignore __pycache__ directories --- .gitignore | 1 + .../validate_reachability.cpython-312.pyc | Bin 3767 -> 0 bytes .../__pycache__/validate_schema.cpython-312.pyc | Bin 2312 -> 0 bytes tests/__pycache__/__init__.cpython-312.pyc | Bin 162 -> 0 bytes ...te_reachability.cpython-312-pytest-9.0.3.pyc | Bin 11475 -> 0 bytes ...validate_schema.cpython-312-pytest-9.0.3.pyc | Bin 11101 -> 0 bytes 6 files changed, 1 insertion(+) create mode 100644 .gitignore delete mode 100644 scripts/__pycache__/validate_reachability.cpython-312.pyc delete mode 100644 scripts/__pycache__/validate_schema.cpython-312.pyc delete mode 100644 tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_validate_schema.cpython-312-pytest-9.0.3.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/__pycache__/validate_reachability.cpython-312.pyc b/scripts/__pycache__/validate_reachability.cpython-312.pyc deleted file mode 100644 index 6c1e7c2312559a13b051ff2ef5daa3652d359f2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3767 zcma(UU2qe}c~5uJova_rviv7vd|*Q)z*2w)9LJ_H!8Rt?{6la6&!IibC(Dw!J3)Lo zwVr7oTV(V*xo+r$NI$M;AwQnUwu*(Jglxwh=#m zM6>8V0M29f{&It(Of3HpT(kT*-^2#y|FU|NB2Un9kNDFUS&yLMoCnV|{@wqEA$e0D zpXrZ%c^PCVmCzU?hUuOjI%WEuAsNO`DXOZ?q>ka5d3wYgPw(K&QqW@ZXJ|-623kL| zWEh>6IcU>q5*wX?Y#U~cf{gX&7HGu&0LW421pqQO-3IL>8yQx0neug3b(^>;UH0kh zGcB;2{=hP$%BYEu&O(PFlY0)26S_>f89TPXZBUn~6{gmh*ThN?mh0SOE{E<=YIJL? zs&sB*dKs=_kIrG=Y11|eOpNM|SdB?jg0RA*dCs}lJkPi?PFR_7Lm#Zt?If~;7FZ2y zOnlISEAxE-&f&KtQ=aj_THT2)sncC}_Uj(ZVarsgkL&wc?@z40WSNQS6*_Z$!(-3A zcJL!I8cfa-)V=V9>-DA#T&0zIWsEPKn;6_^^pt?w_;{zmh$sJqFPn7y&cV&ZZ`gRv zVaA67uqorV8#Vo8RZ}40_}|@$4;Y}kWlN5l==y;?Zo^tEaC=GnHMXP=^Fl1edIB_p zoj{?kSSWB7Q241y`u2PbS*U{U@T>WO`eIv8^TC#F~J7FyqsGG2C~b9&n3M z{h=T5W$Uv!aTH%PA{sC%=?VWxJkR2IW_&;M;R>EHaqQj8Yr9t{b(?7kwyqId_xyJv zsQbSEPCO-l(Uf@wti>p}qYrW22M10bd84xr0d0ejO2ws!pi0R^_;LbG`nMbaU zv^tcOCC!kD*i<|nl@bcFDao`P5w$X`p;<{^gp!Qtv>ZpYEDj=OM354QNeCmN)-
q z*>E9<35cvwIhjr&8_rBzx`^0yqY!(1%{s*56GNE|i&E1HvZiDyp(3l{60#pWdGcr{ z@eVPOBovXo^KDW#xJH6ih)D_R5TXVVHG-^aa#&2rl{jR`W-u#=N+DX2BQp8YlF8*L zlN(IlOlYjhD+v)xB{@NCWJrt*ix4rx61JMdc^fcfvjkf&t4oAfNg+y9$fd$RYdky~ z9!idgVHFogb0V6IG^=SLVI(^e7t&B{UN7KgA+07;QX;J494X;YD6B+eDTUF?LR^A^ zDh|k^5E&9KN^waY4W&kr)i6?i4eKT!h|0UT5XZoZ-IawC{@kS%r)RQfyeB8TdpJuy zph21M8>f5n(D(PUxoxboShuHe^xQ4?`LXV0)>+)LWA4;kccJkoSvFUG zi{p!~^0BU>%bgv4?_8EDy32Fi_;8k9DX*Jp{7Ai1-ZV%5mi>&K=avGms>^c0=G zqPrruWjvOpF)|q)kLJXg(@X9qye{|Ts>esO%!vFlT==4tZ zj`z--oariT?pjb5#Df3u9cM2QSiNC-cxrfd)2|b=;-|x(49{=+O(LsitwnE5mM!vu zxvu%$^IPUZg@#x1oeQTHcQ0;PXfM2aY-!{1@xJVVT%cI{^1L{ICf|{V`O^!pEWWvT zs8D?-+r8qgoeoWf?s@~ayn(rz{EG!|V9C2b`}(TI?W$g>+Aw`#>cZWs?YFA7&%K`a z7OJ-2scKtbiZvUi-OLn?yr4y|7L-2)1&V=;H&=@(5~vDr+V5o<(l=)+6r4+@)dcyu(9oq zr@iRkm}9^8R6SZ{G1Wt*7Z>G3bvH}@dEcgPp7~2dsN2VW;o>mK%pmS`GWjso*v{j}j~qYIiaRDB^vL8N5%~o&ZNd|73Zn{g zuJxpl(JSOFWZ%8Mwce$SSB)4!?va53M8`=}6y)e-ncS)P4pMR3#yv!QTTmdQTX47Jl|N~J)V|~n6**6qp5(^4 zT*ZWA+2zgco^F|Hndz8%b;;E**0s#Ku!O8M6ZtrHHTESJ{98p$wqw~@k=viUl#Ap7 kvz4FvKk3!`SZs0%=;EZ2$lO diff --git a/scripts/__pycache__/validate_schema.cpython-312.pyc b/scripts/__pycache__/validate_schema.cpython-312.pyc deleted file mode 100644 index 6e21265e26ae0a206472956330771b0d2d32bec4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2312 zcma)7O>EOv9Dgr%Vkfcl(X@r6h#43X(P*Q>prs%>MybR$K+prDVv0OJlRC{;ZKqHx z6`j~2(IVA7k%}SU#3pv=ZsD{8$6W$#r_|%Xw8O?>QUwPjPJ1t@Qwpu%MS1V}|9-#! z_p^WJc@MzD@vkEpbpr4=d9Vkwwt3m<0N^G-0KpU(ZLV+>BFFb&)FD%d8s7(i`kw>H zv<#CCeYT3``T#apM347jWFJPlkaKe^0$~ik1(54Dg%Q4un#j{H%meHX84zGiFd1k* zy*VS+w%Ncqwy?z#TQ+jkExXmBhHvioTbA8J;2TUI^)whB*>elrCU)NAak-2BP_Mlo z;`Jdg%3%*cGV*5p&ArUEV)G$Lej769LSD?IWgp_+%mdCnf19$I5KO@-xCitC4PJFX za0zD}g0gUpx&*GmsP8#3byixdaKo+(&lLW@^fmfMZ=DrdxjqD@p-wQ3k)-Y2`dWat z2h$GRdHA>Pdjd>T2wrzgLN!>Rl6If2lL2zrYzPolAjr@bNO~PvphiH!fhdv*}L_%&EQ zTW}_M8?(M5m+c2)$(_VV3AqbS?Ekhcyzy-A4e}Be=HJ+Y$8F9l-=YD@;ye9ft@R6k z)}k|FS~fbS%5R8$pNzg5}b(c~z2) zZEqbn#RG!jNho@^mj4zhszv41l(BtX&{BrwSQniW^)Vy7$vHU~5TcAlXLEAKqLW${ z&#A14ESH$WB4l|K9GRwz8A-Niq)56&C1gG7QdwLp3tq8c+H%RNnpHIm<}6B6C5>Df zp)p>M#Ky8|Ii{;(raP0!O5J*1%*3!OF)1bHk=(tOhi);Clc!{2x~%D1tfwcYNve{= z+etB{AW@g&nlvV-#h%=h#o98p6I;TsdY}dI#hd|8-Fy+1Bj&_YE--t#blMbu_^JqB zQNYvwjQ7tB*BRbC|K0UE>zn0Ee66!*iH+5nKm|T#!YyI%5__QT?PS^8C>E% zBM_h8WJ}{k>Z!NGlz#kl*(=-~xqtEA#h<=j+GS={4iYg)^-jp1x{g)DkD$)LhJlUbys!xVYJ#++j(j^FuWQDzQ}3>aBVYZUVVZC zE=FE!UYwe8=gFhgqod9tnz7h;JS{4jc>Fo}>KII36BD@GjF50FEvnhx`rMZTf=cpkvnOc;Xfeo!qo}eRu2H0D;iIz@CxH89-Qqj_1~DT zz$Z+2h2>4ALfv8K*}K7cuQ^@&xUc5xuWG*;4~=E^i#i)9QnPG{HG^gE3Xh9+b8K#` zGBG#4$nTyRUSW8wp{P`(JIVRvG8_FfxUG0%g$tUWn-ivF?z zHfX~=D$S0S#>|P*_>wzP5$jw>acE<^cKt=Wi$ilE^Sn7VcX=_myB63}+tXL`^e@v# faH;9Yt9Ib&s=0BIIUM#QK6G-BdNk-9VwwK|6q^lZ diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 747617dc72efcaa4a2016e8a5689e8256b4159c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 162 zcmX@j%ge<81Y9qevOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a@5br&rQ`YDN4-K z%}dWu)-5Sb%+pWLEYVNSNi0oC)h);=P0!3L)=exe$uG#v(=SOaE-BWJkI&4@EQycT jE2zB1VUwGmQks)$SHuc5kr9ZCL5z>gjEsy$%s>_Za?vO9 diff --git a/tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_validate_reachability.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index c4d94e77a383f0bc1a5f6ef74d0e27a0caad57de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11475 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 Kd-(+Y0RIIDJ@`@p