* feat(runpod-migrate): add GraphQL / REST v1 -> REST v2 migration skill
Users migrating off REST v1 / GraphQL hand the job to a coding agent rather
than reading docs, so the skill is the surface that reaches them. It inventories
a codebase by API version, rewrites the call sites, and flags what breaks.
- SKILL.md: scope -> inventory -> brief breaking changes -> plan -> migrate ->
verify -> summarize. Summary separates "required" from "cleanup".
- scripts/rp_api_inventory.py: stdlib-only scanner. Buckets call sites by
generation (GraphQL / v1 / v1 field names / already-v2 / serverless job API /
SDK wrappers), suppresses hits that carry v2 context, and keeps prose and
rp-migrate:keep-v1 rollback paths out of the plan so --fail-on-legacy works
in CI.
- reference/: v1->v2 and GraphQL->v2 mapping tables, breaking changes split
into loud renames vs same-name-different-behavior vs removed capability,
v2 unlocks keyed to what the code already does, the RUNPOD_API_V1 rollback
flag, and a worked before/after migration.
- Catalog reads always request include=AVAILABILITY.
Mappings and error shapes were checked against the live v2 API; resources
created for verification were deleted. Two spec-vs-behavior gaps are documented
as observed: `timeout` comes back 0 rather than the documented 300000 default,
and a bad image is 500 {"error"} on v1 but 422 {title,status,detail} on v2.
* fix(runpod-migrate): unblock the inventory step and correct audit findings
Review pass over the skill: an independent accuracy audit against freshly
fetched specs plus read-only live calls, and execution of the commands the
docs tell users to run.
Blocking:
- The documented scanner invocation could not work. `python3
scripts/rp_api_inventory.py .` assumes the script is in the user's repo,
but it ships in the installed skill directory while the agent's working
directory is the customer's project. Step 1 failed with "No such file",
and every later step depends on it. Now resolved via CLAUDE_PLUGIN_ROOT,
the skill's own directory, or a search, with a self-check. Verified from a
simulated plugin install into a simulated customer repo, and asserted in
the inventory eval.
- The GPU pool lookup one-liner was a Python SyntaxError (escaped quotes in
an f-string expression), on the command that resolves a GPU type to its
serverless pool.
Corrections, each re-verified against the specs before applying:
- Template `category` was already an enum with the same values and default
in v1; it is not a v2 tightening.
- `minCudaVersion` and `volumeEncrypted` were listed as dropped from v1 pod
create; neither was ever in PodCreateInput.
- The 422 table omitted `gpu` from the endpoint-create required list — the
field most likely to be lost in a gpuTypeIds -> gpu.pools rewrite.
- globalNetworking was framed as a v2 unlock; the flag existed in v1, the
response object is what is new.
- Unknown-field rejection is `unevaluatedProperties: false` on the composed
Create/Update schemas, not `additionalProperties: false` everywhere.
- Rate-limit headers are omitted for rate-limit-exempt callers.
- The 400-vs-422 rule conflated schema and resource-level errors.
Added, both confirmed:
- Class 2: GraphQL `stockStatus` is High/Medium/Low/None but v2
`availability` is HIGH/MEDIUM/LOW/NONE. A rank table carried across stops
matching silently.
- Class 2: v2 `mounts` is far more restricted on PATCH than v1's
volumeInGb/volumeMountPath — kind fixed at create, volumeId immutable,
clearing rejected, partial entries rejected.
- `mounts.persistent` is deprecated and host-pinned, so a literal
volumeInGb translation inherits data-loss-on-host-failure.
Rejected one audit finding: the GraphQL network-volume mutations are real —
rphttp2, the v2 REST server, issues them — so the mapping row and scanner
signal stay. The public schema page simply omits them, as it omits
saveEndpoint and saveTemplate; graphql-to-v2.md now warns readers not to
treat that page as complete.
* fix(runpod-migrate): repair the verify gate and the gaps a cold run exposed
A fresh agent was given only the skill and the terse prompt "migrate my
codebase from runpod v1 to v2", ran the whole workflow on a fixture, and
reported back. Findings, verified and fixed.
--fail-on-legacy reported "4 files need migration" on a correctly migrated
tree. A gate that fails green builds gets deleted, and it teaches the agent
to stop trusting step 5. Two causes, both fixed:
- Comment detection only matched whole-line comments, so the skill's own
house style -- `"image": image, # was imageName` -- scored as a live v1
field. Comment detection is now quote-aware and handles trailing comments
(and does not mistake the `//` in `https://` for one).
- v2 context was matched per line, so `f"{BASE}/pods"` with `BASE` defined
eleven lines up read as a leftover v1 path. Base-URL constants are now
derived per file. The hardcoded name guesses (V2_BASE, v2_base, V2_URL)
are gone -- the migrated fixture had been passing only because it happened
to use one of them, which meant the corpus was written to match the
scanner rather than the other way round.
Shipped-code fixes:
- The GPU fallback snippet treated any non-201 as scarce capacity, so a 422
walked the entire preference list and raised "no GPU available" -- maximally
misleading during a migration, when 422 is the expected failure. Now fast-
fails on 422 and keeps the response body in the error.
Missing mappings, both silent failures:
- Billing `amount` -> `totalAmount`. The naive port is a KeyError, and it sits
one line from the far quieter /billing/endpoints trap.
- `dockerStartCmd` -> `args` joins an argv array into one string. Joining
["bash","-lc","python x.py"] naively runs python as the -lc script text.
No 422, no crash -- just the wrong command. Added as Class 2.
- `volumeInGb: 0` has no `mounts` translation; omit `mounts` entirely.
Workflow:
- Step 3 gains a third bucket, "decisions the user must make", so Class 3
capability loss has somewhere to land. Without it the cold run dropped
minRAMPerGPU and minVcpuCount with a code comment -- the exact silent drop
breaking-changes.md forbids.
- The scope instruction told the agent to ask when unclear, then supplied a
table resolving every phrasing, so it never fired. Now: infer, state, move
on; the real question is the step 3 fork.
- File order is dependency-blind (fewest call sites first), which put a
consumer ahead of the transport helper it imports. Shared helpers first.
- Commit hygiene forbade mixing behavior and version changes while the
opening pitch advertised one of those behavior changes as the payoff.
Reconciled: improvements land in the cleanup bucket.
- `rp-migrate: ignore` existed in the scanner but appeared nowhere in
SKILL.md, so the only documented escape hatch was `keep-v1` -- which
records a lie when used on a false positive. Both markers now documented
with the distinction, plus the residual cases the gate can still misread.
Placement: the 25-line benefits pitch opened the file but only pays off at
step 6; trimmed to four lines pointing at unlocks.md. The GPU fallback
rebuild -- the largest piece of new code a migration needs -- is now
signposted from step 4 where it is used, not only from step 2 where it lives.
* test(runpod-migrate): add scanner corpora and wire them into CI
The inventory scanner is the only executable in this repo, and its signal
table is 60+ interacting regexes with `unless` clauses — a one-line edit
breaks a distant case silently. Until now the only thing catching that was
me running fixtures by hand on one laptop, and those fixtures were not in
the repo.
Adds testdata/runpod-migrate/ (4 corpora, 68 KB) plus
hooks/check_migrate_scanner.py, run by CI alongside the other four hooks.
15 assertions, every one corresponding to a defect that actually shipped
during review:
- provision.js contains no "runpod" string, only GraphQL operation names.
A hand-written prefilter skipped it entirely.
- report.py has no URL, no import, no operation name — only a renamed
*response* field. Same prefilter bug, and the class of file a v2 rename
breaks silently in the callers of a wrapper.
- submit_job.py calls api.runpod.ai/v2 — the job API, one letter from the
control plane. Flagging it makes an agent rewrite working job submission.
- correct-v2/ is the load-bearing corpus: the scanner once reported "4 files
need migration" on a correctly migrated tree. A verify gate that fails on
correct code gets deleted, and teaches agents to distrust the verify step.
Deliberately at repo root, not under skills/runpod-migrate/: marketplace.json
installs that directory, so anything inside it ships to every user who
installs the plugin.
Mutation-tested rather than assumed — reverting the trailing-comment fix, the
per-file base-URL derivation, or the job-API bucket each turns the hook red.
live_verify.py stays out of the repo: it rents a real GPU and must never run
in CI.
* docs(runpod-migrate): teach the agent to check the spec itself
The skill handed the agent mapping tables and no way to question them. Both
OpenAPI documents are public and need no auth, but SKILL.md never said so —
the URLs appeared once, in a reference file, with no instruction to use them.
That matters more than a missing link. v2 is actively developed, so the
tables are a snapshot with a verification date; and the reference docs
already record two places where the running API contradicts its own spec.
An agent that hits a 422 naming a field it cannot place had no documented
recourse except guessing.
Adds a "Ground truth" section with commands verified by running them
verbatim:
- fetch either spec
- print a request body's fields with required ones starred, resolving allOf
composition (a naive read of the raw JSON misses it, which is how `gpu`
went missing from the documented endpoint-create required list)
- find which schemas mention a field name, for decoding a 422
And a precedence rule: observed live behavior > spec > these tables. Where
the API contradicts the spec, trust the API and say so in the summary, so
the user learns a documented default cannot be relied on. Drift in the
tables should be reported, not silently worked around.
Notes that GraphQL cannot be checked this way — introspection is disabled
and the public reference page is incomplete.
* docs(runpod-migrate): cover every resource with an inline before/after
Example coverage was uneven, and concentrated in the wrong file. The cold run
reported never opening worked-example.md at all -- "never opened unlocks.md or
worked-example.md and didn't miss them" -- so 12 of the skill's 30 code blocks
did no work in the one real trial. What did fire were examples sitting inline
in the mapping tables at the moment of need.
So these go where the agent already is: next to the table row they explain,
in rest-v1-to-v2.md and graphql-to-v2.md rather than in the narrative.
Newly covered, each a real gap:
- Network volumes had zero worked code despite being the flagship rename --
/networkvolumes -> /network-volumes is the example the ticket itself leads
with, and it carries three simultaneous changes (hyphenated path,
dataCenterId -> dataCenter, and the list envelope). Plus attaching one to a
pod, where `path` is now mandatory.
- CPU pods were one table mention. computeType + cpuFlavorIds + vcpuCount ->
a single `cpu` object, with the two CPU-only traps: mounts.persistent is
rejected outright, and vcpuCount must be a power of two inside the flavor's
advertised range.
- Pod lifecycle actions: four v1 paths collapsing into one body verb, with
reset having no equivalent and `actions` telling you what is legal now.
- Registries, standalone templates, and list-with-filters.
- A curl pair, so codebases that are not Python have an anchor. Examples were
13 Python to 1 JS.
- GraphQL: a full podFindAndDeployOnDemand conversion showing the four shape
changes a field-by-field rename misses -- comma-string ports to array, env
pair-list to map, dockerArgs to args, and string interpolation disappearing.
Every v2 body in the docs is now machine-checked against the live spec: 22
assertions covering required fields, unknown fields, the six enums, and the
nested config objects. All pass. The CPU example was checked against the live
catalog too -- cpu3c exists and vcpuCount 2 is inside its 2..32 range.
* docs(runpod-migrate): name the GraphQL volume mutations in the mapping table
A coverage audit over every v1 REST operation (37) and every customer-facing
GraphQL operation (23) found one real hole: createNetworkVolume,
updateNetworkVolume and deleteNetworkVolume existed only as a scanner signal
and as the prose "network volume mutations". An agent grepping the reference
for the operation name it found in the user's code got nothing back. Each is
now its own row with its v2 verb and path.
Also notes the /networkvolumes/{id}/update alias removal, which the pods,
endpoints and templates rows already called out.
The other 8 operations the audit flagged were a false alarm from the checker
normalizing {podId} while the docs use {id}; all are mapped.
* fix(runpod-migrate): report `ignore` and `keep-v1` as the distinct things they are
The scanner recorded only *whether* a line carried a suppression marker, not
which one, then rendered every marked hit as `keep-v1` / _(kept on purpose)_.
A repo whose remaining hits were all false positives was therefore described
as deliberately retaining v1 — the exact claim SKILL.md's marker table warns
against. Migrating a real codebase produced a report asserting "19 legacy call
sites kept on purpose" when it had none.
`ignore` was also documented as accepting `file` and `start`/`end` scope but
only ever matched at line scope: MARK_FILE/START/END were hardcoded to
`keep-v1`, and MARK_LINE's lookahead rejected `ignore file`, so it silently
did nothing.
- intentional_lines returns which marker applies, per line and per file.
- Hits carry `marker` instead of a boolean `intentional`.
- The verdict counts the two separately; rows label them differently.
- JSON `intentional_legacy` splits into `kept_on_purpose` and
`marked_false_positive`.
- keep-v1 wins a tie, since leaving a hit visible as legacy is the safer error.
Both still drop out of the plan and out of --fail-on-legacy, which was never
the broken part.
Adds a testdata/runpod-migrate/markers corpus covering both markers at line,
region and file scope, plus seven assertions including one that reads the
rendered report and fails if an ignore-marked row is labelled kept on purpose.
* docs: show how to point an agent at the worked examples
Plain-English prompts route fine, but golden paths carry no SKILL.md and are
never auto-loaded, and each skill's reference/*.md is progressive-disclosure —
so an agent planning a build has no reason to open either unless asked.
Adds an 'Ask for it by name' section to the repo README: six copyable prompts
covering planning a new service, migrating to REST v2, deploying existing code,
GPU choice, debugging, and cold starts, each paired with the specific paths and
reference docs it pulls in. Reduces the pattern to
[skill] + [read the examples first] + [task].
The plugin README's Usage list gains the same treatment, split into one-off
actions versus work you are designing.
* docs: simplify the example call-out to 'look at the examples'
The prompt table named a specific golden path per row, which made the reader
responsible for knowing which of 24 paths applied. Pointing at the examples in
general is enough — the agent picks. Keeps the one thing worth explaining: golden
paths have no SKILL.md and reference/*.md is progressive-disclosure, so neither
loads unless asked for.
* docs(runpod-migrate): correct what v2 /billing/endpoints actually returns
The reference said an unchanged /billing/endpoints call returns 'the wrong
numbers'. It does not — v2's /billing/endpoints is the Runpod public endpoints
billing route and reports that product's spend correctly.
The trap is narrower and worth stating precisely: the path survives the
migration but now answers a different question, so a caller that carries it
across gets an accurate total for a product it did not ask about — and 0 if it
runs no public endpoints, which reads as 'no serverless spend'.
Links the two reference tables to golden path 11 so the product is identifiable.
* feat: add /runpod:migrate slash command for the migration flow
* feat: bare /runpod:migrate defaults to full scope on current directory
* fix(runpod-migrate): correct four v2 claims the live spec contradicts
The reference tables were verified on 2026-08-10 and four claims no longer
hold. Three of them were Class-3 rows — the class SKILL.md tells the agent to
stop and ask the user about — so being wrong there does not surface as a 422.
It buys an interruption over a decision that does not exist, followed by a
deliberate workaround for a capability the user still has.
- `templateId` is accepted on pod and endpoint create *and* update. It is not a
422. What actually changed is the semantics: v2 resolves the template once at
request time and retains no link, so editing a template no longer rolls
changes out to existing resources. Moved to Class 2 as §13, since a quiet
behavior change is exactly that class.
- `allowedCudaVersions` / `minCudaVersion` moved under `gpu` rather than being
removed, nested there so they are unrepresentable on a CPU workload. Dropped
from Class 3, recorded as a rename in both mapping tables.
- CPU endpoints are writable via `cpu: [{id, vcpuCount}]`. Row removed.
- `/v2/billing/networkvolumes` is a 404; the path is `/v2/billing/network-volumes`.
The table warned about this exact hyphenation two rows earlier for the
resource path.
Two rows narrowed rather than removed, both of which overstated a removal:
- GPU fallback applies to pods only. An endpoint's `gpu.pools` is a list and v2
places workers on whichever listed pool has capacity, so the client-side loop
this skill teaches is rebuilding something v2 kept.
- `countryCodes` survives as a `/v2/catalog/gpus` read filter. It still cannot
constrain a create, so the row stands, but it can choose a target.
Also corrects the 422 table's required-field list: `image` is not
schema-required, because `templateId` can supply it.
The scanner's signal table carried the same three wrong claims as remediation
advice, so those rules move with the docs. All 22 scanner assertions still pass.
* ci: gate runpod-migrate's v2 claims against the spec
The four corrections in the previous commit were all found by diffing the docs
against the live spec, mechanically. Nothing in CI did that, which is why a
2026-08-10 verification pass could go stale unnoticed — catching it required
re-reading ~1,200 lines of tables by hand.
Two hooks, both stdlib-only:
- `check_migrate_tables.py` — every `METHOD /v2/...` claim in the skill's
markdown must exist in the spec with that method. Also checks method-less path
mentions, which the tables abbreviate as `…/v2/templates`. Paths the docs name
as counter-examples (`/v2/networkvolumes`, cited as the 404) need an entry in
EXPECTED_ABSENT with a reason, and the check fails if one of those starts
existing — an allowlist that silently rots is worse than no allowlist.
- `check_migrate_class3.py` — every identifier in the Class-3 "capability
removed" table is an assertion of absence, so existence alone falsifies the
row and no parsing of the prose column is needed. Request-body fields and
routes fail. Two softer outcomes are reported without failing: survives-as-a-
read-filter (the row is true, just incomplete) and GraphQL-only identifiers,
whose absence from a REST spec proves nothing and which are listed as
unchecked rather than counted as verified.
Both default to a vendored snapshot at `testdata/runpod-migrate/v2-openapi.json`
so the PR gate only fails on what a PR changed. `--live` runs the same checks
against api.runpod.io, wired to a weekly non-blocking `spec-drift` workflow:
upstream moving is real news, but not a reason to red-X an unrelated PR.
Current state: 45/49 path claims verified, 9/11 Class-3 rows verified absent,
1 read-filter note, 2 GraphQL-only rows left for a human.
* docs(runpod-migrate): give countryCodes a migration instead of a dead end
The Class-3 row and the scanner both stopped at "no v2 equivalent." That is
true about the *field* and misleading about the *task*: a country restriction
is rebuildable in v2, as a catalog lookup that resolves countries to data
centers, those IDs passed as `dataCenterIds` on create, and a check that the
resource landed where it was supposed to.
Presenting it as a removal is not a neutral omission. SKILL.md routes Class 3
to stop-and-ask, so a dead-end description hands the user a choice between
accepting a behavior change and keeping the call on v1 — when the actual answer
is a dozen lines of code. Keeping a v1 call they did not need to keep is a worse
outcome than the one the bucket exists to prevent.
- breaking-changes.md gains a worked recipe next to the GPU-fallback one.
- The recipe includes the post-create placement assert, because v2 documents
`dataCenterIds` as *preferred* and the spec does not say whether the scheduler
may override it. Unresolved, and called out as unresolved: if the requirement
is data residency, that distinction is the whole point, so the code verifies
`pod["dataCenterId"]` rather than trusting the request.
- SKILL.md reframes the stop-and-ask. The question is no longer "what should I
do here" but "was this a preference or a compliance requirement," which is the
part only the user can answer. Where a rebuild exists, show it while asking.
- The scanner message carries the recipe, not just the diagnosis, and
`countryCodes` moves out of the lumped "no equivalent" rule that hid it.
- Fixture and assertion added: the corpus never contained `countryCodes`, so
nothing would have caught this rule regressing to a dead end. Verified the
assertion fails when the advice is reverted.
* docs(runpod-migrate): dataCenterIds is enforced — verified against the live API
The previous commit hedged: v2 documents `dataCenterIds` as *preferred* data
centers, the spec does not say whether that is binding, and the recipe worked
around not knowing by asserting placement after create. Tested it instead.
Method: request an RTX 4090 — offered only in five EU data centers — while
naming `["US-KS-2","US-IL-1"]`, where it has no presence at all. That forces the
scheduler to choose between obeying the list and placing the pod, which a
happy-path test cannot do: a pod that lands where you asked proves nothing when
capacity was never scarce there.
Result: `400`, no pod. The control — the same body naming `EU-RO-1` — created
successfully and was terminated immediately. So the list is a hard restriction,
the scheduler will not place outside it to satisfy capacity, and it is a sound
basis for a data residency requirement. "Preferred" is misleading wording.
The post-create assert is dropped; it was insurance against an answer we now
have. Two things replace it:
- The 400 is documented, because the refusal presents as a capacity error and
never mentions data centers. Over-narrow the list and the message sends people
hunting for stock when the fix is widening the country list. That is the
second-likeliest failure in this migration after a malformed body.
- The stop-and-ask is sharpened. Since the restriction holds either way, the
preference-vs-compliance answer no longer changes the create — it changes the
*fallback*. A preference widens the list when capacity runs out; a compliance
requirement fails closed, which is the opposite of the GPU-fallback reflex the
same document teaches two sections earlier.
Also points compliance cases at `GET /v2/catalog/datacenters`, whose per-DC
`compliance` array (GDPR, ISO_IEC_27001, SOC_2_TYPE_2, HIPAA) is a sounder basis
for an allowed list than a country code. v1 gave users no way to say
"certified", so some of them said "in this country" and meant this.
* fix(runpod-migrate): address review findings at 089816f
Four of five findings from @runpod-Henrik's review, each reproduced before
fixing. The fifth is answered in the PR thread rather than changed.
1. `--fail-on-legacy` failed on correct v2 code. The `networkvolumes` and
`containerregistryauth` rules matched the bare token, unlike every other
path rule, so Go packages, Python modules and import paths all read as v1
call sites — 61 of 61 hits on one real repo. Both now require a leading `/`
and defer to V2_CONTEXT. Import and module-path lines are suppressed
separately, because `github.com/acme/internal/networkvolumes` carries the
slash that otherwise marks a real path.
2. We taught a call that 400s, in 17 places. `include=AVAILABILITY` requires
`product` and is invalid without it — 400 either way, no default, because
the same GPU can be scarce for POD and plentiful for SERVERLESS. Every call
site now names a product chosen for its context. This was the same failure
this PR set out to fix, one layer down: a claim the spec contradicts, in the
half of the URL nothing checked.
3. `.claude` was missing from SKIP_DIRS. Agent worktrees are full repo copies,
so findings were multiplied by however many exist — 10,763 reported against
1,821 real on runpod-flash. This ships as a Claude Code plugin, so its users
are exactly the people with that directory. Added, with `.worktrees`,
`.cache`, `.ruff_cache`, `.pnpm-store` and `bower_components`.
4. Nothing tested traversal, which is why 3 existed. Emptying SKIP_DIRS and
removing the filter outright both survived a mutation audit, because no
corpus contained a single skipped directory. New `skipped-dirs` corpus: one
real v2 file beside seven directories that each hold obvious v1 code,
asserting zero findings and a clean --fail-on-legacy. Both mutations now
die; the audit is 8/8.
5. The availability eval asserted the 400 form, so it would have re-certified
2 after the fix. Now asserts `product=` separately from the include.
Root cause of 2, fixed rather than papered over: check_migrate_tables.py only
verified paths. It now also verifies query parameters — that each one exists on
the path it is used with, and that required-together pairs appear together. The
pairs are derived from the spec's own prose ("Required with include=AVAILABILITY")
rather than hardcoded, so a new co-requirement is enforced as soon as v2
documents one. Verified by reverting a fixed line and watching the check fail.
7.9 KiB
Contributing
This repo is a plugin marketplace. It ships one plugin, runpod, whose skills
also install via skills.sh — both read the same
.claude-plugin/marketplace.json.
Layout
.claude-plugin/marketplace.json Claude Code / skills.sh manifest (lists the plugin + its skills)
.agents/plugins/marketplace.json Codex manifest
plugins/runpod/
.claude-plugin/plugin.json Claude Code plugin manifest
.codex-plugin/plugin.json Codex plugin manifest
gemini-extension.json Gemini manifest (contextFileName: README.md)
.mcp.json hosted Runpod MCP server config
README.md CHANGELOG.md
skills/<name>/SKILL.md the skills (+ reference/, evals/)
golden-paths/ worked end-to-end reference tasks (no SKILL.md)
hooks/ validation scripts
testdata/runpod-migrate/ fixture repos for the scanner regression check
Adding or changing a skill
- Edit or add
plugins/runpod/skills/<name>/SKILL.md(YAML frontmatter needsname+description; keep the body small and push detail intoreference/*.md). - When you add a skill:
- list its path in the
skillsarray of.claude-plugin/marketplace.json; - confirm the skill directory lives under the plugin's
skills/dir (required for Codex).
- list its path in the
- Add or update an
evals/*.eval.mdwhen you change routing or behavior. - To ship the change, cut a release (see Cutting a release below) — don't bump the manifests by hand.
Conventions
Rules:
- Spelling: write "Runpod" (capital R); the CLI command is
runpodctl(lowercase). - Naming: skills/commands are named as verbs; agents as role-nouns.
Reference facts (not rules):
- License: Apache-2.0.
- Auth: everything unifies on
RUNPOD_API_KEY; the hosted MCP is the exception (OAuth). Companion CLIs use their own creds.
Commit messages
Every commit to main must be a Conventional Commit
— the release bot reads these to decide the next version and to write the CHANGELOG. Format:
<type>(<optional scope>): <summary>
<optional body>
<optional footer, e.g. BREAKING CHANGE: …>
Type → what it does at release time:
| Type | Meaning | Version effect | In CHANGELOG? |
|---|---|---|---|
feat |
a new capability | minor bump (1.0.0 → 1.1.0) | ✅ Features |
fix |
a bug fix | patch bump (1.0.0 → 1.0.1) | ✅ Bug Fixes |
perf |
performance improvement | no bump | ✅ Performance |
refactor |
code change, no feat/fix | no bump | ✅ Refactors |
docs |
docs only | no bump | ✅ Documentation |
revert |
reverts a prior commit | no bump | ✅ Reverts |
chore |
tooling/deps/housekeeping | no bump | hidden |
style |
formatting only | no bump | hidden |
test |
tests only | no bump | hidden |
build |
build system | no bump | hidden |
ci |
CI config | no bump | hidden |
Breaking changes (any type): add ! after the type (feat!:) or a
BREAKING CHANGE: … footer → major bump (1.0.0 → 2.0.0).
- Scope (optional) narrows the area:
feat(runpodctl): …,fix(flash): …,docs(golden-paths): …. - A release fires only when there's at least one
feat/fix/breaking since the last release; a batch of puredocs/chorewon't cut a version on its own (they still ride along in the next release's CHANGELOG). Which types show vs. hide is set bychangelog-sectionsinrelease-please-config.json— flip anyhiddenthere if you want it surfaced. - Examples:
feat(runpod-mcp): add worker-log streaming note·fix(flash): correct empty-input rule·docs: clarify auth in the router·feat!: require RUNPOD_API_KEY for all lanes.
Cutting a release
Releases are automated with release-please — you never bump a version by hand. Write the Conventional Commits above and the bot does the rest.
release-please runs on every push to main (.github/workflows/release-please.yml) and maintains a standing release PR — think of it as a bot that keeps absorbing every releasable commit since the last release into one accumulating PR, continuously updating the pending version + CHANGELOG preview. You don't touch it until you're ready to ship; then you merge it, which tags the release vX.Y.Z and resets the cycle. That merge is the only manual step — a deliberate ship gate, not hand-editing.
One version for the whole plugin, kept in lockstep. release-please writes the same semver to every version-bearing file at once (config: release-please-config.json, current version tracked in .release-please-manifest.json + version.txt):
- the two
plugin.jsons,gemini-extension.json, top-levelmarketplace.json(via the JSONjsonpathupdater), - each skill's
SKILL.mdmetadata.version(via the# x-release-please-versionannotation on that line) — there is no independent per-skill versioning; a skill's version just mirrors the plugin version so a reader of any singleSKILL.mdsees which release it shipped in.
hooks/check_versions.py runs in CI as a drift guard and fails the build if any of these disagree. scripts/bump-version.sh does the same bump locally but is an emergency/local fallback only — normal releases go through release-please.
Release invariants (do not violate):
- Never add a
versionkey to a plugin entry inmarketplace.json'splugins[]— aplugin.jsonvs entry mismatch silently masks updates. - Never remove the
# x-release-please-versionannotation from aSKILL.mdversion line — release-please will stop bumping that skill.
Break-glass manual release (scripts/release.sh)
The automated path needs the release-please Action to be permitted to write on runpod/runpod-plugins-official (org setting). Until the org enables it, merging the release PR can't tag — so there's a manual path that cuts the release with your own gh credentials instead of the Action's token:
./scripts/release.sh 1.1.0 --dry-run # preview every step, change nothing
./scripts/release.sh 1.1.0 # bump all files + commit + push + tag vX.Y.Z + GitHub Release
./scripts/release.sh 1.1.0 --publish-only # just tag current HEAD + Release (bump already merged via PR)
Flags combine in any order. It prompts for confirmation before any push (skip with --yes in automation), warns if HEAD isn't origin/main, and is idempotent — safe to re-run after a partial failure; each step checks state first and a re-run resumes (it verifies any existing tag points at the intended commit rather than blindly re-tagging). Because it bumps .release-please-manifest.json too, release-please stays in sync.
When to use release.sh: only while the org has not yet permitted the release-please Action to write on runpod/skills. Once it is enabled, resume merging the standing release PR and do not run this script — it's break-glass, not the default.
Validate locally (optional — CI is authoritative)
Run these locally to catch failures early; CI runs the same hooks on every PR and is the authoritative gate.
python3 hooks/validate_marketplace.py # manifests + referenced paths resolve
python3 hooks/check_versions.py # all manifests declare the same version
python3 hooks/check_runpod_branding.py # "Runpod" casing
python3 hooks/check_links.py # relative Markdown links resolve
python3 hooks/check_migrate_scanner.py # runpod-migrate scanner vs. its corpora
python3 hooks/check_migrate_tables.py # runpod-migrate path claims vs. the spec
python3 hooks/check_migrate_class3.py # runpod-migrate Class-3 table vs. the spec
Then smoke-test the two install paths on your branch:
# Plugin (Claude Code): add the local dir as a marketplace, then install
/plugin marketplace add ./
/plugin install runpod@runpod
# skills.sh
npx skills add ./