CI-driven push

CI-driven push

Since Tabularium 0.9.0, plugin authors can publish releases directly from their CI by presenting a publisher token. It's the same conceptual model as cargo publish or npm publish: scoped tokens, owner-only writes, first push auto-claims the slug. The webhook flow continues working unchanged — both routes funnel through the same ingest pipeline.

When to pick push over webhook

SituationPushWebhook
Plugin lives on a public forge with admin access
Org-owned repo where you can't grant the OAuth webhook scope
Registry not reachable from the forge
You only want the registry to ingest on green CI
You don't want to maintain a CI step at all

A single plugin can use both. The most recent ingest wins.

Token model

A publisher token carries one or more scopes. Each scope is <action>:<target> where:

  • action is publish, yank, or manage-owners (reserved for a future release).
  • target is * (wildcard) or a plugin slug.
ScopeWhat it lets the token do
publish:*Create new plugins via auto-claim + update any plugin you own
publish:<slug>Update releases on this one slug
yank:<slug>Yank a release on this slug (ships in v0.10.0)
yank:*Yank releases on any plugin you own (ships in v0.10.0)

Tokens are user-bound — you can only publish to slugs you own. Scope checks catch the rest.

Mint a token

curl -X POST https://registry.example.com/api/auth/me/tokens \
  -H "Authorization: Bearer $TABULARIUM_SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "GitHub Actions",
    "scopes": ["publish:firestore"],
    "expiresAt": null
  }'
bash

The response includes the plaintext token exactly once:

{
  "token": "tpub_KJjksJ29Hf...etc",
  "row": {
    "id": "01J...",
    "name": "GitHub Actions",
    "prefix": "tpub_KJj",
    "scopes": ["publish:firestore"],
    "expiresAt": null,
    "createdAt": 1730000000000
  }
}
json

Store it as a CI secret (TABULARIUM_TOKEN is the convention). The registry only persists a sha256 hash — there's no way to recover the plaintext later.

To list / revoke:

curl https://registry.example.com/api/auth/me/tokens                 # GET list
curl -X DELETE https://registry.example.com/api/auth/me/tokens/01J... # revoke
bash

Push a release

curl -X POST https://registry.example.com/api/publish/firestore \
  -H "Authorization: Bearer $TABULARIUM_TOKEN" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "manifest": $(jq -Rs . < .tabularium),
  "manifestSource": "tabularium.yaml",
  "version": "1.4.0",
  "assets": [
    {
      "name": "firestore-plugin-linux-x64.zip",
      "url": "https://github.com/u/firestore-tabularis/releases/download/v1.4.0/firestore-plugin-linux-x64.zip"
    }
  ],
  "repoUrl": "https://github.com/u/firestore-tabularis"
}
EOF
bash

Successful response:

{
  "slug": "firestore",
  "version": "1.4.0",
  "claimed": false
}
json

claimed: true on the first push to a new slug.

Required vs optional fields

FieldRequired whenNotes
manifestalwaysRaw YAML or JSON text (≤ 64 KiB)
manifestSourcealways"tabularium.yaml" or "tabularium.json"
versionalwayssemver 1.4.0 or v1.4.0
assetsalwaysArray of {name, url, sha256?, size?}, max 32
repoUrlfirst push onlyForge anchor for the auto-claim
attestationoptionalSigstore bundle (passthrough, not verified at ingest)

CI example — GitHub Actions

Add a publish job to your release workflow:

name: Release
on:
  push:
    tags: ['v*']

jobs:
  build:
    # … build platform binaries, upload to GitHub release as today …

  publish:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Push release to Tabularium
        env:
          TABULARIUM_TOKEN: ${{ secrets.TABULARIUM_TOKEN }}
        run: |
          set -euo pipefail
          VERSION="${GITHUB_REF_NAME#v}"
          REPO="https://github.com/${GITHUB_REPOSITORY}"
          jq -n \
            --rawfile manifest .tabularium \
            --arg version "$VERSION" \
            --arg repo "$REPO" \
            --argjson assets "$(gh release view "v$VERSION" --json assets | jq '[.assets[] | {name, url}]')" '
              {
                manifest: $manifest,
                manifestSource: "tabularium.yaml",
                version: $version,
                assets: $assets,
                repoUrl: $repo
              }
            ' | \
            curl -fsS -X POST -H "Authorization: Bearer $TABULARIUM_TOKEN" \
                 -H "Content-Type: application/json" \
                 --data @- \
                 "https://registry.example.com/api/publish/${REPO##*/}"
yaml

The same shape works for Forgejo Actions and GitLab CI — just swap the secret name and the gh release view call for the corresponding API.

Failure codes

StatusAudit actionMeaning
401(none — too noisy)Token missing, expired, or revoked
403plugin.publish_deniedToken scopes don't permit the action
400(none)First-push without repoUrl, or bad repoUrl format
409plugin.publish_conflictrepoUrl already claimed by another slug, OR same version
422plugin.publish_invalidManifest fails schema validation
502plugin.publish_asset_failAsset URL fetch / hash failed

The errors array in the 422 response lists every schema violation so the CI step can print it back to the author.

What's coming in v0.10.0

  • @tabularium/cli publish — wraps the above curl with manifest sniffing, asset URL discovery from the forge, and friendly error messages.
  • POST /api/publish/:slug/yank — pull a release out of the default list.
  • /settings/tokens page in the registry frontend (today the token management is API-only).