Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
ignore:
# Pin to v1.x — v2+ is ESM-only and breaks the current CJS bundle. Track
# the ESM migration before lifting this.
- dependency-name: "@actions/core"
update-types: ["version-update:semver-major"]
groups:
production:
dependency-type: production
Expand Down
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 on lines +76 to +78
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
14 changes: 10 additions & 4 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": {
"ignore": ["dist", "lib", "node_modules", "coverage"]
"includes": ["**", "!dist", "!lib", "!node_modules", "!coverage"]
},
"formatter": {
"enabled": true,
Expand All @@ -24,7 +24,7 @@
},
"overrides": [
{
"include": ["__tests__/**"],
"includes": ["__tests__/**"],
"linter": {
"rules": {
"style": {
Expand All @@ -41,5 +41,11 @@
"trailingCommas": "all"
}
},
"organizeImports": { "enabled": true }
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
Loading
Loading