# Publishing to the Hub

> Publish your MCP servers, Skills, and Functions to the Aerostack Hub using the CLI, GitHub Actions, or GitLab CI.

Share your work with the Aerostack community by publishing to the Hub. You can publish via the **Admin dashboard**, the **CLI**, **GitHub Actions**, or **GitLab CI**.

---

## Prerequisites

Before you publish:

- [Install the Aerostack CLI](/cli)
- Log in with `aerostack login` (you need an **account key** from [app.aerostack.dev/account/keys](https://app.aerostack.dev/account/keys))

  **Account Key vs Project Key** -- Publishing requires an **account key** (prefix `ac_`), not a project key. Project keys are scoped to a single project deployment.

---

## Function structure

Every publishable function lives in its own directory with an `aerostack.json` config and at least one source file.

- my-function
  - aerostack.json
  - index.ts
  - README.md

### The `aerostack.json` config file

This file defines your function's identity, version, and capabilities. It is read by the CLI, CI pipelines, and the Hub.

```json title="aerostack.json"
{
  "name": "image-resizer",
  "version": "1.0.0",
  "description": "Resize and transform images on the edge using Cloudflare Workers.",
  "category": "media",
  "language": "typescript",
  "runtime": "cloudflare-worker",
  "license": "MIT",
  "tags": ["image", "media", "resize", "cdn"],
  "entrypoint": "index.ts",
  "routePath": "/api/image-resize",
  "routeExport": "imageResizerRoute",
  "npmDependencies": [],
  "envVars": ["IMAGE_MAX_WIDTH", "IMAGE_QUALITY"],
  "drizzleSchema": false
}
```

#### Field reference

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Slug-friendly name. Becomes the Hub URL: `hub.aerostack.dev/functions/you/image-resizer` |
| `version` | string | Yes | SemVer string, e.g. `1.0.0` |
| `description` | string | Yes | Short description shown in search results |
| `category` | string | Yes | One of: `auth`, `payments`, `media`, `email`, `ai`, `database`, `utility`, `analytics`, `notifications`, `storage` |
| `language` | string | Yes | `typescript` or `javascript` |
| `runtime` | string | Yes | `cloudflare-worker` (default) |
| `license` | string | Yes | SPDX identifier, e.g. `MIT`, `Apache-2.0` |
| `tags` | string[] | No | Search keywords, max 10 |
| `entrypoint` | string | Yes | Path to main source file relative to the function directory |
| `routePath` | string | No | Default HTTP route when installed, e.g. `/api/image-resize` |
| `routeExport` | string | No | Named export from your adapter, e.g. `imageResizerRoute` |
| `npmDependencies` | string[] | No | npm packages the consumer must install |
| `envVars` | string[] | No | Environment variables the consumer must set |
| `drizzleSchema` | boolean | No | Set `true` if you export a Drizzle table schema |

  **Category is case-sensitive.** Use all lowercase. The value must exactly match one of the supported categories, or publishing will be rejected.

---

## Writing your function

Your `index.ts` should export a Hono route handler. This is the convention the CLI bundles and the Hub displays.

```typescript title="index.ts"

imageResizerRoute.get('/resize', async (c) => {
  const url = c.req.query('url');
  const width = parseInt(c.req.query('width') ?? '800');

  if (!url) {
    return c.json({ error: 'url query param required' }, 400);
  }

  return c.json({
    original: url,
    resized: `${url}?width=${width}&format=webp`,
    width,
  });
});

imageResizerRoute.get('/health', (c) =>
  c.json({ module: 'image-resizer', ok: true })
);
```

  **Tip: separate logic from HTTP.** For complex functions, put pure business logic in `core.ts` and keep the HTTP adapter in `index.ts`. This makes your function easier to test and easier for consumers to use without Hono.

---

## Choose a publishing method

The CLI is the fastest way to publish. Push source code to create a draft, then publish by ID.

**Step 1 — Push to create a draft**

```bash
aerostack functions push ./my-function/index.ts
```

Each push creates a **new draft**. The command outputs the function **ID**, **slug**, and a link to the Admin. Save the ID.

Example output:

```
Pushing function 'image-resizer' to Aerostack...
Pushed successfully!
   Slug: image-resizer
   Status: draft
   Admin URL: https://app.aerostack.dev/functions/edit/<id>
```

**Step 2 — Publish the draft**

```bash
aerostack functions publish <id>
```

The function is now live on the Hub and can be installed with `aerostack functions install`.

**Step 3 — (Optional) Finalize in the Admin**

Before publishing, you can open the Admin URL from the push output to add a README, screenshots, and tags. Then click **Publish** from the Admin instead.

#### Updating an existing function via CLI

Each `aerostack functions push` creates a **new draft** -- it does not update an existing function by slug. To update an existing function:

- **Admin**: Open the function in [Admin](https://app.aerostack.dev) and edit directly.
- **CI**: Use the GitHub Actions or GitLab CI workflow (below). The CI endpoint upserts by slug, so it updates the same function.

Set up a GitHub Actions workflow so that every push to `main` (or every tagged release) automatically publishes your function to the Hub.

**Step 1 — Add your API key as a GitHub secret**

In your GitHub repo, go to **Settings** > **Secrets and variables** > **Actions** and create a secret named `AEROSTACK_API_KEY` with your `ac_secret_xxxx` key.

**Step 2 — Add the workflow file**

Create `.github/workflows/publish-aerostack.yml`:

```yaml title=".github/workflows/publish-aerostack.yml"
name: Publish to Aerostack Hub

on:
  push:
    branches:
      - main

jobs:
  publish:
    name: Publish Function
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Read aerostack.json
        id: config
        run: |
          echo "config=$(cat aerostack.json | tr -d '\n')" >> $GITHUB_OUTPUT
          ENTRYPOINT=$(cat aerostack.json | jq -r '.entrypoint // "index.ts"')
          echo "entrypoint=$ENTRYPOINT" >> $GITHUB_OUTPUT

      - name: Read source file
        id: source
        run: |
          ENTRYPOINT="${{ steps.config.outputs.entrypoint }}"
          CODE=$(cat "$ENTRYPOINT" | jq -Rs .)
          echo "code=$CODE" >> $GITHUB_OUTPUT

      - name: Publish to Aerostack
        run: |
          CONFIG='${{ steps.config.outputs.config }}'
          CODE=${{ steps.source.outputs.code }}

          PAYLOAD=$(jq -n \
            --argjson config "$CONFIG" \
            --argjson code "$CODE" \
            '$config + {code: $code, publish: true}')

          RESPONSE=$(curl -s -w "\n%{http_code}" \
            -X POST https://api.aerostack.dev/api/community/functions/ci/publish \
            -H "X-API-Key: ${{ secrets.AEROSTACK_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD")

          HTTP_CODE=$(echo "$RESPONSE" | tail -1)
          BODY=$(echo "$RESPONSE" | head -1)

          if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
            echo "Publish failed with HTTP $HTTP_CODE"
            echo "$BODY"
            exit 1
          fi

          echo "Published successfully!"
          echo "$BODY" | jq .
        env:
          AEROSTACK_API_KEY: ${{ secrets.AEROSTACK_API_KEY }}
```

```yaml title=".github/workflows/publish-aerostack.yml"
name: Publish to Aerostack Hub

on:
  release:
    types: [published]

jobs:
  publish:
    name: Publish on Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Update version from git tag
        run: |
          TAG="${{ github.ref_name }}"
          VERSION="${TAG#v}"
          echo "Publishing version: $VERSION"
          jq --arg v "$VERSION" '.version = $v' aerostack.json > tmp.json
          mv tmp.json aerostack.json

      - name: Read aerostack.json
        id: config
        run: |
          echo "config=$(cat aerostack.json | tr -d '\n')" >> $GITHUB_OUTPUT
          ENTRYPOINT=$(cat aerostack.json | jq -r '.entrypoint // "index.ts"')
          echo "entrypoint=$ENTRYPOINT" >> $GITHUB_OUTPUT

      - name: Read source file
        id: source
        run: |
          ENTRYPOINT="${{ steps.config.outputs.entrypoint }}"
          CODE=$(cat "$ENTRYPOINT" | jq -Rs .)
          echo "code=$CODE" >> $GITHUB_OUTPUT

      - name: Publish to Aerostack
        run: |
          CONFIG='${{ steps.config.outputs.config }}'
          CODE=${{ steps.source.outputs.code }}

          PAYLOAD=$(jq -n \
            --argjson config "$CONFIG" \
            --argjson code "$CODE" \
            '$config + {code: $code, publish: true}')

          RESPONSE=$(curl -s -w "\n%{http_code}" \
            -X POST https://api.aerostack.dev/api/community/functions/ci/publish \
            -H "X-API-Key: ${{ secrets.AEROSTACK_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD")

          HTTP_CODE=$(echo "$RESPONSE" | tail -1)
          BODY=$(echo "$RESPONSE" | head -1)

          if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
            echo "Publish failed with HTTP $HTTP_CODE"
            echo "$BODY"
            exit 1
          fi

          echo "Published version ${{ github.ref_name }}!"
          echo "$BODY" | jq .
        env:
          AEROSTACK_API_KEY: ${{ secrets.AEROSTACK_API_KEY }}
```

#### Monorepo with multiple functions

If your repo contains multiple function directories, scope the workflow trigger:

```yaml
on:
  push:
    branches: [main]
    paths:
      - 'functions/my-rate-limiter/**'
```

And adjust the working directory in each step to point to the correct function folder.

Use a GitLab CI pipeline to publish on every push to `main` or on a tagged release.

**Step 1 — Add your API key as a CI variable**

In your GitLab project, go to **Settings** > **CI/CD** > **Variables** and add `AEROSTACK_API_KEY` with your `ac_secret_xxxx` key. Enable **Mask variable** to hide it in logs.

**Step 2 — Add the pipeline file**

Create `.gitlab-ci.yml` at the root of your function repository:

```yaml title=".gitlab-ci.yml"
stages:
  - publish

publish-to-aerostack:
  stage: publish
  image: node:20-alpine
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      CONFIG=$(cat aerostack.json)
      ENTRYPOINT=$(echo "$CONFIG" | jq -r '.entrypoint // "index.ts"')
      CODE=$(cat "$ENTRYPOINT" | jq -Rs .)

      PAYLOAD=$(jq -n \
        --argjson config "$CONFIG" \
        --argjson code "$CODE" \
        '$config + {code: $code, publish: true}')

      echo "Publishing $(echo "$CONFIG" | jq -r '.name') v$(echo "$CONFIG" | jq -r '.version')..."

      RESPONSE=$(curl -s -w "\n%{http_code}" \
        -X POST "https://api.aerostack.dev/api/community/functions/ci/publish" \
        -H "X-API-Key: $AEROSTACK_API_KEY" \
        -H "Content-Type: application/json" \
        -d "$PAYLOAD")

      HTTP_CODE=$(echo "$RESPONSE" | tail -1)
      BODY=$(echo "$RESPONSE" | head -1)

      if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
        echo "Publish failed with HTTP $HTTP_CODE"
        echo "$BODY"
        exit 1
      fi

      echo "Published successfully!"
      echo "$BODY" | jq .
  only:
    - main
```

```yaml title=".gitlab-ci.yml"
stages:
  - publish

publish-to-aerostack:
  stage: publish
  image: node:20-alpine
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      VERSION="${CI_COMMIT_TAG#v}"
      echo "Publishing version: $VERSION"

      jq --arg v "$VERSION" '.version = $v' aerostack.json > tmp.json
      mv tmp.json aerostack.json

      CONFIG=$(cat aerostack.json)
      ENTRYPOINT=$(echo "$CONFIG" | jq -r '.entrypoint // "index.ts"')
      CODE=$(cat "$ENTRYPOINT" | jq -Rs .)

      PAYLOAD=$(jq -n \
        --argjson config "$CONFIG" \
        --argjson code "$CODE" \
        '$config + {code: $code, publish: true}')

      RESPONSE=$(curl -s -w "\n%{http_code}" \
        -X POST "https://api.aerostack.dev/api/community/functions/ci/publish" \
        -H "X-API-Key: $AEROSTACK_API_KEY" \
        -H "Content-Type: application/json" \
        -d "$PAYLOAD")

      HTTP_CODE=$(echo "$RESPONSE" | tail -1)
      BODY=$(echo "$RESPONSE" | head -1)

      if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
        echo "Publish failed with HTTP $HTTP_CODE"
        echo "$BODY"
        exit 1
      fi

      echo "Published $VERSION successfully!"
      echo "$BODY" | jq .
  only:
    - tags
```

#### Multi-function monorepo

Use the `changes` keyword to only trigger publishing for modified functions:

```yaml
publish-rate-limiter:
  stage: publish
  script:
    - cd functions/rate-limiter && # publish commands here
  only:
    changes:
      - functions/rate-limiter/**
    refs:
      - main
```

The Admin dashboard provides a browser-based editor for creating and publishing functions with no local setup required.

**Step 1 — Create a new function**

Log in to the [Admin dashboard](https://app.aerostack.dev) and go to **My Functions**. Click **Create** to start a new function.

**Step 2 — Write your code**

Use the built-in Monaco editor to write your function logic. Add a name, description, category, and tags.

**Step 3 — Publish**

Click **Publish** to make the function live on the Hub. You can also save as a draft first and publish later.

The Admin is also useful for finalizing drafts created via the CLI -- add a README, adjust tags, then click Publish.

---

## How CI publishing works

The CI endpoint (`POST /api/community/functions/ci/publish`) behaves differently from the CLI:

- **Upserts by slug** -- If a function with the same name already exists under your account, it updates the code and metadata. Otherwise, it creates a new function.
- **`publish: true`** makes the function live immediately. Set `publish: false` to land as a draft for review.
- **Version snapshots are permanent** -- Every published version is preserved. Consumers who installed an earlier version can still reference it.

  **Bump the version before each publish.** You cannot overwrite an existing published version. Update the `version` field in `aerostack.json` before publishing a new release.

---

## Common errors

| Error | Cause | Fix |
|---|---|---|
| `INVALID_CATEGORY` | Category not in allowed list | Check spelling, use lowercase |
| `MISSING_ENTRYPOINT` | Entrypoint file not found | Verify path is relative to the function directory |
| `EMPTY_CODE` | Entrypoint file is empty | Add at least one exported function |
| `DUPLICATE_SLUG` | Slug exists under a different account | Rename your function |
| `UNAUTHORIZED` | API key invalid or expired | Run `aerostack login` again |
| `VERSION_CONFLICT` | Version string already published | Bump version in `aerostack.json` |

---

## Version history

Every time you publish a new version, a code snapshot is stored. All past versions are preserved -- consumers who installed an earlier version can still reference its code.

To release an update, bump the `version` field in `aerostack.json` and publish again. There is no way to overwrite a past version.

---

## Reputation

Every time your function is installed (via `aerostack functions install` or the Hub), you earn **Community Reputation**. Higher reputation unlocks badges and better visibility in search results.
