CI/CD for Functions

Run your Function deploys from CI so every push to your default branch ships the latest handler. The CLI authenticates non-interactively from a PRIMITIVE_API_KEY, so the same functions:deploy command you run locally works inside a workflow.

For the local build-and-deploy loop, start with First Function; for the full command and endpoint reference, see Functions.

Store the API Key

Add the API key as a repository (or environment) secret so it is masked in logs:

gh secret set PRIMITIVE_API_KEY --body "$PRIMITIVE_API_KEY"

The CLI reads PRIMITIVE_API_KEY from the environment, so no primitive login is needed in CI. Scope the secret to a protected GitHub Environment when you want deploys gated by required reviewers.

Recommended: Managed Build, Idempotent by Name

functions:deploy --source uploads your project directory and lets Primitive install dependencies and bundle for the Workers runtime server-side. Deploying by --source is idempotent by name: it creates the Function if the name is new, or redeploys it if the name already exists. That makes it safe to run on every push with no Function id to track and no local build step.

name: deploy-function


on:
  push:
    branches: [main]


concurrency:
  group: deploy-function
  cancel-in-progress: false


jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4


      - uses: actions/setup-node@v4
        with:
          node-version: 22


      - name: Deploy Function
        env:
          PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }}
        run: |
          npx @primitivedotdev/cli@1 functions:deploy \
            --name triage-agent \
            --source . \
            --wait

--wait blocks until the deploy reaches a terminal state and exits non-zero on failure, so a red job means a failed deploy. The concurrency group keeps two overlapping merges from racing redeploys of the same Function. Pinning @1 keeps you on non-breaking CLI updates; drop the tag to a full version (@1.0.1) when you want byte-stable runs.

Seed Secrets in the Same Deploy

Function secrets are scoped per Function and are never returned after creation. Drive them from GitHub secrets with --secret-from-env, which reads the named variable from the workflow environment and writes it before the deploy fires:

- name: Deploy Function
        env:
          PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }}
          EXTERNAL_API_KEY: ${{ secrets.EXTERNAL_API_KEY }}
        run: |
          npx @primitivedotdev/cli@1 functions:deploy \
            --name triage-agent \
            --source . \
            --secret-from-env EXTERNAL_API_KEY \
            --wait

--secret-from-env is repeatable. Read the value from env.EXTERNAL_API_KEY inside the handler. PRIMITIVE_API_KEY and PRIMITIVE_WEBHOOK_SECRET are managed reserved names that Primitive injects automatically — never set them as Function secrets.

Alternative: Deploy Your Own Bundle

When you want to control bundling in CI, build the single ESM file yourself and deploy it with --file. Because --file create targets a name and Function names are immutable, recurring CI must redeploy by id rather than re-create. Create the Function once locally, capture the returned id, and store it as a repository variable:

primitive functions:deploy --name triage-agent --file ./dist/handler.js
gh variable set PRIMITIVE_FUNCTION_ID --body "<function-id>"

Then redeploy by id on every push:

name: redeploy-function


on:
  push:
    branches: [main]


concurrency:
  group: redeploy-function
  cancel-in-progress: false


jobs:
  redeploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4


      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm


      - run: npm ci
      - run: npm run build


      - name: Redeploy Function
        env:
          PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }}
        run: |
          npx @primitivedotdev/cli@1 functions:redeploy \
            --id "${{ vars.PRIMITIVE_FUNCTION_ID }}" \
            --file ./dist/handler.js \
            --source-map-file ./dist/handler.js.map \
            --wait

Include --source-map-file when your build emits a map (./dist/handler.js.map) and you want source-mapped logs for the new bundle. Omit it when you deploy without a map.

Without the CLI

Any runner that can run curl and jq can redeploy by calling the REST API directly. Use this when you do not want a Node.js toolchain in the deploy job:

npx @primitivedotdev/cli@1 functions:redeploy \
  --id "$PRIMITIVE_FUNCTION_ID" \
  --file ./dist/handler.js \
  --source-map-file ./dist/handler.js.map

Verify the Deploy

With --wait, the deploy step already blocks until the new bundle reaches a terminal state and exits non-zero on failure, so a green job confirms the deploy landed.

For a deeper check, fire a test invocation through the inbound path and read the resulting logs. This needs the Function id, so it fits the --file path where you already keep the id in PRIMITIVE_FUNCTION_ID:

- name: Smoke test
        env:
          PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }}
        run: |
          npx @primitivedotdev/cli@1 functions:test-function \
            --id "${{ vars.PRIMITIVE_FUNCTION_ID }}"
          npx @primitivedotdev/cli@1 functions:list-function-logs \
            --id "${{ vars.PRIMITIVE_FUNCTION_ID }}"

The test travels through MX ingestion, parsing, endpoint routing, and Function execution, so a green step confirms the new bundle runs end to end.

Notes

  • Keep the API key in a GitHub secret so it is masked in logs; never echo it or pass it on a command line that gets printed.
  • Use a protected GitHub Environment with required reviewers when a deploy should be gated.
  • Prefer --source for CI: it is idempotent by name, needs no id, and builds server-side. Reach for --file only when you must own the bundling step.

Related Pages

  • First Function: the local scaffold-to-deploy walkthrough.
  • Functions: full handler, deploy, logs, secrets, and limits reference.
  • CLI: install and authentication, including non-interactive PRIMITIVE_API_KEY use.