Publishing from CI
Publish plugin versions from a release pipeline with an API token: the endpoint, the three ways to send an archive, and how queued versus live is reported.
An API token lets a pipeline attach a version to a listing you own, without a browser and without a session. This is the endpoint the marketplace was built to expose.
Mint a token
Open Account → Tokens & sessions and create one. Choose the marketplace:publish scope, name it after the pipeline that will use it, and set an expiry.
The token is shown once. Only a sha256 is stored, so it cannot be recovered — if you lose it, revoke it and mint another. An expiry is not required, but it is the only control that works without anyone remembering to act.
Check it before you build
curl -fsS -H "Authorization: Bearer $DEV3D_TOKEN" \
https://dev3d.net/api/v1/me
The reply says which scopes the token carries, whether it can publish, and whether this account is trusted. Asking first turns "your token lacks marketplace:publish" from a failure at the end of a long build into one at the start.
Publish
Three ways to supply the archive. All of them take pluginId — the listing this version belongs to, which must be owned by the account the token belongs to.
A local archive
curl -fsS -X POST https://dev3d.net/api/v1/plugins/publish \
-H "Authorization: Bearer $DEV3D_TOKEN" \
-F pluginId=yourname.your-plugin \
-F changelog="Fixed the thing that was broken" \
-F bundle=@yourname.your-plugin-1.2.0.tar.gz
A GitHub release, fetched by the marketplace
curl -fsS -X POST https://dev3d.net/api/v1/plugins/publish \
-H "Authorization: Bearer $DEV3D_TOKEN" \
-H 'content-type: application/json' \
-d '{
"pluginId": "yourname.your-plugin",
"downloadUrl": "https://github.com/you/your-plugin/archive/refs/tags/v1.2.0.tar.gz",
"changelog": "Fixed the thing"
}'
The archive never passes through the runner. The URL must be https, and the response is size-capped while it streams.
Inline bytes
{
"pluginId": "yourname.your-plugin",
"bundleBase64": "H4sIAAAAAAAA..."
}
For a small plugin, or a client that cannot do multipart.
What comes back
A 201 means the version was stored, and the reply says whether it is live or queued:
{
"ok": true,
"pluginId": "yourname.your-plugin",
"version": "1.2.0",
"sha256": "…",
"sizeBytes": 14203,
"fileCount": 7,
"reviewStatus": "pending",
"message": "Queued for review. It is not in the catalog until an administrator approves it."
}
Queued is not a failure. Unless an administrator has marked your account trusted, every submission is reviewed before it becomes installable. A pipeline should report that state rather than treating it as an error — the version exists and the checksum is recorded; it is simply not in the catalog yet.
GitHub Actions
name: Release plugin
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build the bundle
run: tar --exclude=.git --exclude=node_modules -czf plugin.tar.gz .
- name: Publish to dev3d.net
env:
DEV3D_TOKEN: ${{ secrets.DEV3D_TOKEN }}
run: |
curl -fsS -X POST https://dev3d.net/api/v1/plugins/publish \
-H "Authorization: Bearer $DEV3D_TOKEN" \
-F pluginId=yourname.your-plugin \
-F changelog="Release ${{ github.ref_name }}" \
-F bundle=@plugin.tar.gz
Pack the directory's contents rather than the directory itself, so plugin.json sits at the archive root. (A single wrapping directory is also accepted, so tar -czf plugin.tar.gz your-plugin/ works too.)
Failure modes worth knowing
401— the token is not valid, has been revoked, or has expired. The response never says which, because a CI log is not a place to enumerate credentials.403— the token lacksmarketplace:publish, or the account is suspended for publishing.404— no listing with thatpluginIdis owned by the account the token belongs to. Create it in Publishing first.409— that version already exists. Bump the version inplugin.json, or sendreplace=true.429— rate limited. Sixty publishes an hour per token.
Revoking
Revoking a token on the Security page takes effect immediately: the next request from that pipeline fails authentication. The revoked row is kept so the audit trail still resolves, and so "when was this revoked" stays answerable.
Linked from
Did this page answer your question?