Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
create-app-if-missing: true
spicepod: examples/spicepod.yaml
tags: |
source=spice-cloud-deploy-action
commit=${{ github.sha }}
source: spice-cloud-deploy-action
commit: ${{ github.sha }}
test-sql: SELECT 1
test-warmup-seconds: 120
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Initial release of the Spice Cloud Deploy Action.
- OAuth 2.0 client credentials authentication against the Spice Cloud Management API.
- Resolve apps by `app-id` or `app-name`, with optional `create-app-if-missing` flow.
- App tag merging via `tags` input.
- App tag merging via `tags` input — accepts a YAML block mapping (recommended) or JSON object.
- Optional Spicepod manifest push from `spicepod.yaml` before deploy.
- Bulk app secret upsert from a multi-line `secrets` input (values masked in logs).
- Deployment trigger with `branch`/`commit_sha`/`commit_message` auto-populated from the GitHub event.
Expand Down
57 changes: 33 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

# Spice Cloud Deploy Action

[![CI](https://github.com/spiceai/spice-cloud-deploy-action/actions/workflows/ci.yml/badge.svg)](https://github.com/spiceai/spice-cloud-deploy-action/actions/workflows/ci.yml)
[![GitHub release](https://img.shields.io/github/v/release/spiceai/spice-cloud-deploy-action?logo=github&color=1F8AC0)](https://github.com/spiceai/spice-cloud-deploy-action/releases)
[![CI](https://github.com/spicehq/spice-cloud-deploy-action/actions/workflows/ci.yml/badge.svg)](https://github.com/spicehq/spice-cloud-deploy-action/actions/workflows/ci.yml)
[![GitHub release](https://img.shields.io/github/v/release/spicehq/spice-cloud-deploy-action?logo=github&color=1F8AC0)](https://github.com/spicehq/spice-cloud-deploy-action/releases)
[![Marketplace](https://img.shields.io/badge/marketplace-spice--cloud--deploy-1F8AC0?logo=githubactions&logoColor=white)](https://github.com/marketplace/actions/spice-cloud-deploy)
[![License](https://img.shields.io/badge/license-Apache%202.0-1F8AC0)](LICENSE)

Expand Down Expand Up @@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: spiceai/spice-cloud-deploy-action@v1
- uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -49,13 +49,28 @@ The action returns once the deployment is `succeeded` (or fails the job if it's

1. Sign in to the [Spice.ai Portal](https://spice.ai).
2. Open **Profile → OAuth Clients** and click **Create**.
3. Grant the scopes you need. For a typical CI deploy:
`apps:read`, `apps:write`, `deployments:read`, `deployments:write`, `secrets:write`.
3. **Grant the scopes you need** (see the table below). The action will fail with `403 Forbidden` if a required scope is missing.
4. Copy the **client ID** and **client secret** — the secret is shown only once.
5. In your GitHub repo (or org), add two secrets: `SPICE_CLIENT_ID` and `SPICE_CLIENT_SECRET`.

The action exchanges the client credentials at `https://spice.ai/api/oauth/token` for a short-lived bearer token (cached for the run).

### Scope cheat sheet

Grant exactly the scopes for the features you use. The "All-in" row at the bottom is what you'd typically pick for a CI client that does everything this action supports.

| Use this action to… | Required scopes |
| --- | --- |
| Resolve an existing app and trigger a deployment | `apps:read`, `deployments:read`, `deployments:write` |
| Create the app on first run (`create-app-if-missing: true`) | + `apps:write` |
| Push a `spicepod.yaml` manifest to the app before deploying | + `apps:write` |
| Set or merge app `tags` | + `apps:write` |
| Upsert app `secrets` before deploying | + `secrets:write` |
| Run runtime smoke tests (`test-sql`, `test-nsql`, etc.) | _no extra scope_ — uses `apps:read`, already required |
| **All-in (recommended for a single CI client)** | **`apps:read` `apps:write` `deployments:read` `deployments:write` `secrets:write`** |

> Avoid the `*` wildcard scope in production — it grants `apps:delete`, `secrets:read` (decrypted via the portal), and `members:*`, which this action never needs.

## Inputs

| Input | Required | Default | Description |
Expand All @@ -67,7 +82,7 @@ The action exchanges the client credentials at `https://spice.ai/api/oauth/token
| `create-app-if-missing` | no | `false` | Create the app if it doesn't exist (requires `app-name` and `region`). |
| `region` | conditional | — | Spice Cloud region (e.g. `us-east-1`, `us-west-2`). Required for new apps. |
| `visibility` | no | `private` | `public` or `private` — only used on app creation. |
| `tags` | no | — | Multi-line `KEY=VALUE` pairs. Merged into existing app tags. |
| `tags` | no | — | YAML or JSON map of tag key/value pairs. Merged into existing app tags. |
| `spicepod` | no | `spicepod.yaml` | Path to the Spicepod manifest. Pushed to the app before deploy when present. |
| `working-directory` | no | `.` | Working directory used to resolve relative paths. |
| `image-tag` | no | — | Override the runtime image tag (e.g. `1.5.0-models`). |
Expand Down Expand Up @@ -113,7 +128,7 @@ The action exchanges the client credentials at `https://spice.ai/api/oauth/token
### Bootstrap an app on first run

```yaml
- uses: spiceai/spice-cloud-deploy-action@v1
- uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -122,15 +137,17 @@ The action exchanges the client credentials at `https://spice.ai/api/oauth/token
create-app-if-missing: true
visibility: private
tags: |
environment=production
team=data-platform
commit=${{ github.sha }}
environment: production
team: data-platform
commit: ${{ github.sha }}
```

> `tags` accepts either a YAML block mapping (shown above) or a JSON object string (e.g. `tags: '{"environment":"production","team":"data-platform"}'`). Tags are merged into the app's existing tags on every run.

### Upsert app secrets and run a SQL smoke test

```yaml
- uses: spiceai/spice-cloud-deploy-action@v1
- uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -144,7 +161,7 @@ The action exchanges the client credentials at `https://spice.ai/api/oauth/token
### Verify chat, search, and MCP after a successful deploy

```yaml
- uses: spiceai/spice-cloud-deploy-action@v1
- uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -170,7 +187,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: spiceai/spice-cloud-deploy-action@v1
- uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -184,7 +201,7 @@ jobs:

```yaml
- id: deploy
uses: spiceai/spice-cloud-deploy-action@v1
uses: spicehq/spice-cloud-deploy-action@v1
with:
client-id: ${{ secrets.SPICE_CLIENT_ID }}
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
Expand All @@ -210,22 +227,14 @@ jobs:
2. **Resolve or create the app.** If `app-id` is given, it's fetched directly. Otherwise the action looks up `app-name` via `GET /v1/apps`. With `create-app-if-missing: true`, a missing app is created in the requested `region`.
3. **Sync metadata.** Tags from the `tags` input are merged into the app's existing tags via `PUT /v1/apps/{id}`.
4. **Push the Spicepod.** When `spicepod.yaml` exists at `working-directory`, its contents are pushed to the app via `PUT /v1/apps/{id}` (`spicepod` field).
5. **Upsert secrets.** Each `KEY=VALUE` line is sent to `POST /v1/apps/{id}/secrets` (upsert).
5. **Upsert secrets.** Each `KEY=VALUE` line in `secrets` is sent to `POST /v1/apps/{id}/secrets` (upsert).
6. **Trigger the deployment.** `POST /v1/apps/{id}/deployments` with `branch`, `commit_sha`, `commit_message`, plus any `image-tag`/`channel`/`replicas` overrides.
7. **Poll until terminal.** `GET /v1/apps/{id}/deployments` is polled every `poll-interval-seconds` up to `timeout-seconds`.
8. **Smoke-test.** When the deployment succeeds, the action fetches the app's primary API key, instantiates a [`SpiceClient`](https://www.npmjs.com/package/@spiceai/spice) against the regional runtime URL (`https://<region>-prod-aws-data.spiceai.io`), waits for `isSpiceReady()`, and runs each configured probe.

The Action job step summary records the deployment metadata and a per-probe pass/fail table.

## Required scopes

| Action behavior | Minimum scopes |
| --- | --- |
| Resolve an app and trigger a deployment | `apps:read`, `deployments:read`, `deployments:write` |
| Create the app on first run | + `apps:write` |
| Push `spicepod.yaml` to the app | + `apps:write` |
| Upsert app secrets | + `secrets:write` |
| Run smoke tests (read API key) | + `apps:read` (already required) |
> Wondering which scopes to grant the OAuth client? See the [Scope cheat sheet](#scope-cheat-sheet) above.

## Compatibility

Expand Down
2 changes: 1 addition & 1 deletion __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe("runDeploy", () => {
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
const api = fakeApi({ listApps, updateApp, createDeployment });

await runDeploy(api, { ...baseInputs, tagsRaw: "environment=prod" });
await runDeploy(api, { ...baseInputs, tagsRaw: "environment: prod" });

expect(updateApp).toHaveBeenCalledWith(42, { tags: { existing: "1", environment: "prod" } });
});
Expand Down
90 changes: 73 additions & 17 deletions __tests__/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,89 @@ import { describe, expect, it } from "vitest";
import { parseTags } from "../src/tags.js";

describe("parseTags", () => {
it("returns undefined when input is empty", () => {
it("returns undefined for empty input", () => {
expect(parseTags(undefined)).toBeUndefined();
expect(parseTags("")).toBeUndefined();
expect(parseTags("\n# only comments\n")).toBeUndefined();
expect(parseTags("{}")).toBeUndefined();
});

it("parses KEY=VALUE pairs", () => {
expect(parseTags("environment=production\nteam=data\n")).toEqual({
environment: "production",
team: "data",
describe("YAML block-map form", () => {
it("parses key: value lines", () => {
expect(parseTags("environment: production\nteam: data\n")).toEqual({
environment: "production",
team: "data",
});
});
});

it("preserves '=' inside values", () => {
expect(parseTags("url=https://x.com?a=1")).toEqual({ url: "https://x.com?a=1" });
});
it("trims whitespace around keys and values", () => {
expect(parseTags(" environment : production ")).toEqual({
environment: "production",
});
});

it("rejects keys that don't start with a letter", () => {
expect(() => parseTags("1foo=bar")).toThrow(/start with a letter/);
});
it("strips matching single or double quotes around values", () => {
expect(parseTags("a: \"with spaces\"\nb: 'single quoted'")).toEqual({
a: "with spaces",
b: "single quoted",
});
});

it("preserves a single colon inside the value", () => {
expect(parseTags("url: https://example.com:8080/x")).toEqual({
url: "https://example.com:8080/x",
});
});

it("ignores blank lines and #-comment lines", () => {
expect(parseTags("\n# header\nfoo: 1\n\n # inline comment\nbar: 2")).toEqual({
foo: "1",
bar: "2",
});
});

it("rejects lines without a colon", () => {
expect(() => parseTags("not-a-map")).toThrow(/expected "key: value"/);
});

it("rejects keys that don't start with a letter", () => {
expect(() => parseTags("1foo: bar")).toThrow(/start with a letter/);
});

it("rejects duplicate keys", () => {
expect(() => parseTags("env=a\nenv=b")).toThrow(/duplicate tag key/);
it("rejects duplicate keys", () => {
expect(() => parseTags("env: a\nenv: b")).toThrow(/duplicate tag key/);
});

it("rejects values longer than 256 chars", () => {
const long = "x".repeat(257);
expect(() => parseTags(`big: ${long}`)).toThrow(/exceeds 256/);
});
});

it("rejects values longer than 256 chars", () => {
const long = "x".repeat(257);
expect(() => parseTags(`big=${long}`)).toThrow(/exceeds 256/);
describe("JSON object form", () => {
it("parses a JSON object", () => {
expect(parseTags('{"environment":"production","team":"data"}')).toEqual({
environment: "production",
team: "data",
});
});

it("rejects malformed JSON that begins with {", () => {
expect(() => parseTags("{ not json")).toThrow(/not valid JSON/);
});

it("rejects JSON arrays", () => {
// Arrays don't start with `{`, so the parser falls through to YAML parsing.
// This test ensures wrapping a literal `{` array-like in JSON fails clearly.
Comment thread
lukekim marked this conversation as resolved.
Outdated
expect(() => parseTags('{"tags":["a","b"]}')).toThrow(/must be a string/);
});

it("rejects JSON values that aren't strings", () => {
expect(() => parseTags('{"replicas":3}')).toThrow(/must be a string/);
});

it("rejects JSON keys with invalid characters", () => {
expect(() => parseTags('{"1bad":"x"}')).toThrow(/start with a letter/);
});
});
});
16 changes: 10 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,18 @@ inputs:
default: private
tags:
description: |
Newline-separated `KEY=VALUE` pairs to set as app tags.
Tags are merged with existing tags on each run. Lines starting with `#` are comments.
App tags as a YAML or JSON map. Merged with existing tags on each run.

Example:
YAML form (recommended):
tags: |
environment=production
team=data-platform
commit=${{ github.sha }}
environment: production
team: data-platform
commit: ${{ github.sha }}

JSON form:
tags: '{"environment":"production","team":"data-platform"}'

Lines beginning with `#` are treated as comments.
required: false
spicepod:
description: |
Expand Down
Loading