Otomasi Release Plugin Claude Code di GitHub Actions

Foto oleh Getsuhas08 via Wikimedia Commons (CC BY-SA 3.0)
Pasang CLI-nya lalu jalankan claude plugin validate pada direktori plugin dengan --strict, yang memperlakukan warning sebagai error dan keluar dengan 1 saat menemukannya. Exit code-nya 0 bila lolos, 1 bila gagal, dan 2 bila proses validasinya sendiri gagal, misalnya karena path tidak terbaca. Tambahkan --json bila Anda ingin laporannya sebagai satu object berisi success, strict, target, manifest, dan contents per file.
Field commands, agents, workflows, dan outputStyles menggantikan direktori bawaannya, bukan menambahnya, jadi mendeklarasikan satu path kustom membuat direktori bawaan berhenti dipindai. Hanya field skills yang menambah ke bawaannya. Path yang mengarah ke luar plugin root juga ditolak dengan error path escapes plugin directory, dan plugin tetap dimuat tanpa komponen itu.
Bisa, tapi urutannya penting. claude plugin tag memvalidasi plugin, memeriksa bahwa plugin.json dan marketplace entry sepakat soal versi, mensyaratkan working tree bersih di bawah direktori plugin, dan menolak bila tag-nya sudah ada. Jadi job-nya harus menaikkan versi di kedua file, commit, baru menandai dengan --push.
Claude Code memakai versi plugin sebagai cache key untuk update. Ketika plugin.json menetapkan version eksplisit, pengguna menerima release hanya saat string itu berubah, jadi mendorong commit tanpa menaikkan field tersebut tidak berefek. Menghilangkan version dari plugin.json dan dari marketplace entry mengalihkan resolusi ke commit SHA sumbernya.
Hanya sebagian. Plugin CLI yang terdokumentasi memeriksa syntax dan schema, bukan behaviour, jadi behaviour gate harus dibangun dari run headless claude -p dengan --plugin-dir, --json-schema, dan jq -e pada field structured_output. Perlakukan itu sebagai smoke test berbasis sampel dengan ambang kegagalan, karena satu prompt terhadap satu model adalah bukti awal, bukan bukti mutlak.

Foto oleh Getsuhas08 via Wikimedia Commons (CC BY-SA 3.0)
Ringkasan Utama
Pipeline release untuk plugin Claude Code butuh tiga gate: claude plugin validate dengan --strict supaya warning yang masih ditoleransi loader ikut menggagalkan build, satu run headless claude -p untuk membuktikan skill-nya masih terpanggil, dan claude plugin tag di working tree yang bersih. Pengguna baru menerima release ketika field version di plugin.json berubah.
Tag sudah terkirim, marketplace sudah di-refresh, dan dua dari tiga skill plugin itu ternyata tidak ada. Proses install tidak mencetak error apa pun, dan claude plugin list menampilkan plugin dalam keadaan enabled di versi baru. Saya menambahkan skill ketiga di dalam direktori extras lalu mendeklarasikan direktori itu di manifest, dan field commands menggantikan scan commands bawaan bukan menambahnya, sehingga dua skill yang sudah ada di commands berhenti dimuat begitu yang baru datang.
Itulah jenis bug yang membuat CI untuk plugin ada gunanya: bukan crash, tapi bundle yang berhasil dimuat namun mengerjakan lebih sedikit dari yang dijanjikan. Ini pipeline GitHub Actions yang sekarang saya jalankan di repository plugin. Apa saja yang dicakup claude plugin validate, cara mengubah laporannya menjadi gate, di mana posisi version bump dan claude plugin tag dalam job graph, dan apa yang sebenarnya harus berubah sebelum plugin update seorang pengguna menawarkan sesuatu. Semua nama field dan flag di bawah dicek terhadap plugins reference Claude Code.
Field name adalah satu-satunya yang wajib di .claude-plugin/plugin.json, dan justru itu kegagalan yang tidak akan pernah lolos ke produksi: manifest tanpa field itu ditolak dengan validation error yang menyebut expected string, received undefined. Selebihnya opsional, termasuk version, description, field path komponen, dan dependencies. Sifat opsional itulah masalahnya, karena field yang boleh tidak ada juga boleh salah tanpa ada yang wajib memprotesnya.
// .claude-plugin/plugin.json — the only file that belongs in that folder.
// name is the sole required field. Everything below it is optional, which
// is exactly why a wrong value here fails quietly rather than loudly.
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "receipt-tools",
"displayName": "Receipt Tools",
"version": "2.3.1",
"description": "ESC/POS receipt layout skills and a print-queue hook",
"author": { "name": "Matthews Wong", "url": "https://www.matthewswong.com" },
"repository": "https://github.com/example-org/receipt-tools",
"license": "MIT",
"keywords": ["escpos", "receipts", "pos"],
"skills": ["./domain/skills/"],
"commands": ["./commands/", "./extras/"],
"hooks": "./hooks/hooks.json",
"dependencies": [{ "name": "secrets-vault", "version": "~2.1.0" }]
}Dua aturan menentukan sebuah kesalahan jadi berisik atau senyap. Claude Code mengabaikan field tingkat atas yang tidak dikenalinya, jadi key salah tulis hanya menjadi warning dari claude plugin validate dan sama sekali tidak terlihat saat runtime, dan plugin yang temuannya hanya berupa field tak dikenal tetap lulus validasi dan tetap dimuat. Tipe yang salah biasanya lebih berisik: untuk sebagian besar field plugin langsung gagal dimuat, walaupun nilai experimental atau metadata yang bukan object hanya diabaikan dengan warning. Jadi manifest di atas itulah yang diperiksa CI, dan --strict yang membuat warning punya arti.
Lima cara sebuah repository plugin mengirim bundle yang terpasang bersih tapi mengerjakan lebih sedikit dari klaim manifest-nya. Tidak satu pun menggagalkan install, dan itu sebabnya masing-masing butuh step sendiri di CI, bukan reviewer yang teliti.
| Yang Anda lihat | Penyebab | Baris yang bertanggung jawab |
|---|---|---|
| Satu skill hilang saat Anda menambah skill lain | Field commands menggantikan scan bawaan, bukan menambahnya | commands diisi hanya direktori extras |
| Plugin dimuat tanpa satu pun komponen | Komponen ditaruh di dalam folder .claude-plugin | Hanya plugin.json yang boleh berada di situ |
| Satu komponen hilang, sisanya normal | Path komponen mengarah ke luar plugin root sehingga ditolak | agents mengarah ke folder shared satu level di atas |
| Hook terdaftar tapi tidak pernah jalan | Script ada di bundle tanpa execute bit | chmod +x tidak pernah ikut di-commit |
| Satu field diabaikan sepenuhnya | Key beda satu karakter dari nama field asli, jadi hanya warning | mcpServer ditulis bukan mcpServers |
// Wrong: commands REPLACES the default commands/ scan. The two skills
// already sitting in commands/ stop being loaded, and nothing errors.
"commands": ["./extras/"]
// Right: name the default explicitly to keep it, then add your own.
"commands": ["./commands/", "./extras/"]
// skills is the exception — it ADDS to the default skills/ scan, so this
// loads both ./skills/ and ./domain/skills/ with no second entry needed.
"skills": ["./domain/skills/"]
// Wrong: a path outside the plugin root is refused with
// "path escapes plugin directory" — and the plugin still loads, minus that
// one component. Nothing in the install output mentions the omission.
"agents": ["../shared/agents/"]Field path pantas dihafal, karena perilakunya tidak seragam. commands, agents, workflows, dan outputStyles menggantikan direktori bawaannya; skills menambah ke direktori bawaan. Asimetri itu adalah baris termahal di schema plugin, dan itu terdokumentasi, jadi salah di situ harganya satu release, bukan satu laporan bug.
Tiga job, dan cara membelahnya lebih penting daripada isinya. Job bundle adalah bagian murah dan deterministik: schema, path, permission, kesesuaian versi, dan job ini tidak butuh credential model karena tidak ada satu pun langkah yang memanggil model. Job behaviour adalah bagian yang berbiaya dan bisa flaky, jadi ia bergantung pada bundle dan tidak pernah jalan setelah bundle gagal. Job release hanya jalan lewat manual dispatch, karena memotong sebuah versi seharusnya keputusan, bukan efek samping dari merge.
# .github/workflows/plugin-release.yml
name: plugin-release
on:
pull_request:
workflow_dispatch:
inputs:
bump:
description: patch, minor or major
required: true
default: patch
permissions:
contents: write # only the tag push needs this; the checks need nothing
concurrency:
group: plugin-release # two release runs racing is how a tag gets moved
cancel-in-progress: false
env:
# validate --json needs 2.1.259 or later, so pin the CLI rather than let
# the report shape change under the workflow on an unrelated day
CLI_VERSION: "2.1.259"
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm install -g @anthropic-ai/claude-code@$CLI_VERSION
# Exit 0 passes, 1 fails, 2 means the validate run itself failed on an
# unreadable path and wrote nothing to stdout. pipefail is what stops
# the pipe from swallowing all three.
- name: validate --strict
run: |
set -o pipefail
claude plugin validate . --strict --json | tee validate.json
test "$(jq -r .success validate.json)" = "true"
# Every path the manifest names must exist in the checkout. One that
# does not registers no component and reports nothing at install time.
- name: declared paths exist
run: |
FIELDS='[.skills, .commands, .agents, .hooks] | flatten
| map(select(type == "string")) | .[]'
for p in $(jq -r "$FIELDS" .claude-plugin/plugin.json); do
test -e "$p" || { echo "manifest names a missing path: $p"; exit 1; }
done
# Hook commands are shell scripts. Shipped without the execute bit, the
# plugin loads, the hook registers, and it never once fires. The
# documented command form quotes the variable inside the string, so
# strip quotes before testing the path.
- name: hook scripts are executable
run: |
HOOKS='.hooks | to_entries[] | .value[] | .hooks[]
| select(.type == "command") | .command'
for s in $(jq -r "$HOOKS" hooks/hooks.json | tr -d '"' \
| sed 's|${CLAUDE_PLUGIN_ROOT}/||'); do
test -x "$s" || { echo "hook not executable: $s"; exit 1; }
done
# Prints, does not assert. This is the log you read when a component
# goes missing and every other step is green.
- name: what registered
run: claude --plugin-dir . plugin list --json
# Validates the bundle and checks plugin.json against the marketplace
# entry version, without creating anything. Safe on a pull request.
- name: tag dry run
run: claude plugin tag --dry-run
behaviour:
needs: bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm install -g @anthropic-ai/claude-code@$CLI_VERSION
- run: bash .github/scripts/behaviour-gate.sh
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
release:
needs: [bundle, behaviour]
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the tag command reads the tags already on the repo
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm install -g @anthropic-ai/claude-code@$CLI_VERSION
# Bumps plugin.json AND the marketplace entry. The tag command refuses
# when the two disagree, which is the failure you want here.
- name: bump
run: node .github/scripts/bump-version.mjs "${{ inputs.bump }}"
# Commit first: the tag command requires a clean tree under the plugin.
- name: commit, tag, push
run: |
VERSION=$(jq -r .version .claude-plugin/plugin.json)
git config user.name "github-actions"
git config user.email "[email protected]"
git commit -am "release: receipt-tools v$VERSION"
git push origin HEAD:main
claude plugin tag --push -m "receipt-tools %s"Dua detail di sana adalah hal yang dulu saya lakukan dengan salah. Concurrency group ada karena dua release run yang tumpang tindih adalah cara sebuah tag berakhir di commit yang salah, dan cancel-in-progress tetap false karena release yang dibatalkan bisa sudah mendorong commit versinya tanpa mendorong tag-nya, sehingga branch utama mengklaim sebuah versi yang tidak punya tag. Lalu versi CLI yang dipin bukan kehati-hatian kosong: laporan --json pada validate butuh v2.1.259 atau lebih baru, jadi workflow yang memasang CLI terbaru menyerahkan kontrak parsing-nya pada apa pun yang rilis pagi itu.
Perintah validate keluar dengan 0 saat validasi lolos, 1 saat gagal, dan 2 saat proses validasinya sendiri yang gagal, misalnya karena path tidak bisa dibaca. Kasus ketiga itu yang menggigit, karena pada exit 2 perintah ini tidak menulis apa pun ke stdout dan mengirim pesannya ke stderr, jadi step yang mem-pipe output ke jq mendapat file kosong, bukan verdict false. Set pipefail sebelum pipe dan exit code-nya selamat; tanpa pipefail step membaca exit status tee, yang selalu nol.
Dengan --json laporannya datang sebagai satu object: success yang mengulang exit code, strict yang menyatakan apakah warning diperlakukan sebagai error, target yang menyebut path hasil resolusi, manifest yang memuat hasil manifest itu sendiri, dan contents yang membawa errors, warnings, dan notes per file. File itu saya simpan sebagai build artefact dan tidak saya parse melampaui success, karena temuan per file berguna ketika sebuah run gagal dan jadi beban ketika workflow bergantung pada bentuknya.
Flag --strict bukan opsional di CI. Tanpanya, manifest yang temuannya hanya field tak dikenal tetap lulus validasi dan tetap dimuat, jadi mcpServer yang ditulis untuk mcpServers berangkat sebagai warning yang tidak dibaca siapa pun. Validate menandai key yang beda satu atau dua karakter dari nama asli dengan saran perbaikan, dan itu tepat jenis typo yang diubah --strict menjadi exit code 1.

Plugin CLI yang terdokumentasi berhenti sebelum behaviour. Subcommand-nya adalah init, install, uninstall, prune, enable, disable, update, list, details, validate, dan tag, dan validate satu-satunya pemeriksa di antaranya: yang diperiksa adalah syntax dan schema. Tidak ada di daftar itu yang menjalankan plugin lalu menilai hasilnya, jadi gate untuk memastikan skill-nya masih terpanggil adalah sesuatu yang Anda bangun dari headless CLI dan jq.
#!/usr/bin/env bash
# .github/scripts/behaviour-gate.sh
# A report you read is not a gate. A gate exits non-zero.
set -euo pipefail
SCHEMA='{
"type": "object",
"properties": {
"skill_used": { "type": "string" },
"columns": { "type": "integer" }
},
"required": ["skill_used", "columns"]
}'
PROMPT="Lay out a 58 mm receipt for two items. Report the skill you used
and the column count you assumed."
# --bare skips auto-discovery of hooks, skills, plugins, MCP servers and
# CLAUDE.md, so the run cannot go green because of something in the runner
# image or a stray ~/.claude. Nothing loads unless you name it — and naming
# the plugin is what --plugin-dir does. Confirm that pairing on your own
# runner before you trust it; the docs describe the two flags separately.
out=$(claude -p "$PROMPT" \
--bare \
--plugin-dir . \
--allowedTools "Read,Grep" \
--max-turns 6 \
--output-format json \
--json-schema "$SCHEMA")
# .structured_output holds the schema-conforming answer; .result holds prose.
# jq -e exits 1 when the expression is false. That is the entire gate.
echo "$out" | jq -e '.structured_output.skill_used == "receipt-tools:layout"'
echo "$out" | jq -e '.structured_output.columns == 32'
# A client-side estimate, but it tells you the gate got more expensive
# before your invoice does.
echo "$out" | jq -r '"gate cost estimate: " + (.total_cost_usd | tostring)'Jujurlah soal apa gate ini sebenarnya. Ia mengambil satu sampel prompt terhadap satu model, jadi lolos itu bukti dan bukan bukti mutlak, dan satu run merah sama mungkinnya variance seperti regression. Satu kegagalan saya perlakukan sebagai re-run dan dua berturut-turut sebagai blokir, dan ambang itu saya pilih, bukan saya ukur. Perangkap lainnya bersifat mekanis: --max-turns keluar dengan error ketika batasnya tercapai, jadi run yang cuma mengambil jalan lebih panjang gagal dengan cara yang persis sama seperti run yang mengerjakan hal salah. Baca dulu log-nya sebelum mempercayai verdict-nya.
Perintah claude plugin tag menurunkan nama tag dari manifest dan dari marketplace entry yang menaunginya, dan sebelum membuat apa pun ia memvalidasi isi plugin, memeriksa bahwa plugin.json dan marketplace entry sepakat soal versi, mensyaratkan working tree yang bersih di bawah direktori plugin, dan menolak jika tag-nya sudah ada. Masing-masing dari empat prasyarat itu menentukan satu baris di job release.
# The order is not a style choice. claude plugin tag validates the plugin,
# checks that plugin.json and the marketplace entry agree on the version,
# requires a clean working tree under the plugin directory, and refuses when
# the tag already exists.
$ node .github/scripts/bump-version.mjs minor # writes both files
$ git commit -am "release: receipt-tools v2.4.0" # clean tree, or tag refuses
$ claude plugin tag --dry-run # prints what it would tag, creates nothing
$ claude plugin tag --push -m "receipt-tools %s"
Created tag receipt-tools--v2.4.0
Pushed to origin
# If the push fails, the tag still exists locally and the command exits with
# an error. A blind job retry then hits the tag-already-exists refusal and
# reports a broken release when the only thing that broke was the network.
$ git tag -d receipt-tools--v2.4.0 && claude plugin tag --pushKegagalan yang perlu diantisipasi adalah push-nya. Kalau push tag gagal, tag-nya tetap ada secara lokal dan perintahnya keluar dengan error, jadi di hosted runner tag itu ikut hilang bersama workspace, tapi di self-hosted runner retry buta akan menabrak penolakan tag sudah ada dan melaporkan release rusak padahal yang rusak cuma jaringan. Hapus tag lokalnya sebelum mencoba lagi, atau pakai --force dengan sadar. Tag yang dipindahkan paksa memang mendapat cache directory baru di install berikutnya, karena nama cache-nya membawa commit SHA dua belas karakter, dan itu membatasi kerusakan tanpa memaafkannya.
Versi adalah cache key. Claude Code menghitung versi plugin saat ini lalu melewati update ketika angkanya sama dengan yang terpasang, jadi dengan version eksplisit di plugin.json seorang pengguna menerima release hanya ketika string itu berubah. Dorong commit sebanyak apa pun dan plugin update akan melaporkan bahwa mereka sudah di versi terbaru. Itu properti yang Anda inginkan dari plugin yang dipublikasikan, dan sekaligus properti yang membuat bump yang terlupa terlihat persis seperti pipeline yang rusak.
Alternatifnya adalah pilihan yang sengaja. Hilangkan version dari plugin.json dan dari marketplace entry, maka versinya diambil dari commit SHA sumbernya, sehingga pengguna update setiap kali commit hasil resolusi berubah. Itu cocok untuk plugin internal yang sedang aktif dikembangkan dan salah untuk plugin yang dipublikasikan, karena setiap merge menjadi sebuah release. Marketplace juga tidak melakukan polling: claude plugin marketplace update yang menyegarkannya agar plugin baru dan perubahan versi terbaca, dan marketplace yang ditambahkan dengan branch atau tag terpin akan update ke commit terbaru dari ref itu, bukan ke branch default.
Prefix tag itulah yang membuat satu repository marketplace bisa menampung beberapa plugin dengan jalur versi yang mandiri: tag-nya adalah nama plugin, lalu dua tanda hubung, lalu v dan semver-nya. Karena pemisahnya dibaca sebagai prefix match atas nama lengkap plugin, plugin yang namanya sendiri mengandung tanda hubung tetap resolve ke tag-nya sendiri dan bukan ke tag saudaranya.

Test sebuah library mengeksekusi artefaknya. Artefak sebuah plugin adalah instruksi, yaitu prosa skill, deskripsi agent, dan perkabelan hook, dan pipeline di atas hampir tidak mengeksekusi satu pun dari itu. Yang dibuktikan run hijau adalah bahwa bundle-nya ter-parse, path yang dideklarasikan ada, komponennya terdaftar, hook script-nya bisa dijalankan, dan versinya siap dirilis. Yang tidak dibuktikannya adalah bahwa agent membaca skill itu seperti yang Anda maksud, dan justru itu satu-satunya properti yang disadari pengguna.
Tiga hal yang saya lakukan untuk jurang itu, dan tidak satu pun menutupnya. Behaviour gate diperlakukan sebagai smoke test dengan ambang, bukan sebagai bukti. Permukaan always-on dijaga tetap kecil, karena claude plugin details mencetak token yang ditambahkan sebuah plugin ke setiap session terlepas dari ada tidaknya komponen yang jalan, dan permukaan always-on yang lebih kecil menyisakan lebih sedikit tempat bagi regression untuk bersembunyi. Lalu release note ditulis sebagai behaviour diff, bukan file diff, karena itu satu-satunya artefak yang bisa dibandingkan pengguna dengan apa yang mereka amati. Saya masih mengirim regression pada pilihan kata yang tidak tertangkap gate mana pun di sini; gate yang bisa menangkapnya adalah seorang manusia yang membaca transcript.
Aturan yang akan saya bawa ke repository plugin mana pun: pasang gate pada apa yang bisa dijawab loader, dan katakan terus terang bahwa sisanya tidak terotomasi. Schema, path yang dideklarasikan, registrasi komponen, dan kesesuaian versi semuanya bisa diputuskan, dan --strict plus pipefail plus working tree yang bersih mengubahnya menjadi exit code. Behaviour tidak bisa diputuskan di dalam workflow, jadi berilah ia gate berbasis sampel dan release note yang jujur, bukan tanda centang hijau yang artinya lain.
Sumber