Skip to content

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.

3 min readUpdated 13 Sept 2026Reviewed 12 Sept 2026Published 12 Sept 2026/docs/guides/publishing-from-ci

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 lacks marketplace:publish, or the account is suspended for publishing.
  • 404 — no listing with that pluginId is owned by the account the token belongs to. Create it in Publishing first.
  • 409 — that version already exists. Bump the version in plugin.json, or send replace=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?