Skip to content

Commit f84b3ab

Browse files
authored
Prepare repo for v1.0.0 release (#9)
* Prepare repo for v1.0.0 release Bundles the work from #7 and #8 into a single commit so trunk lands release-ready in one merge. CI / build - Migrate biome.json to the Biome 2.x schema (`files.includes` with negation patterns, `overrides[*].includes`, `assist.actions.source.organizeImports`). - Reorder a stale import in src/deploy.ts that the v2 organizer flagged. - Pin @actions/core to ^1.11.1 — 3.x is ESM-only and breaks the current CJS bundle. Add a Dependabot ignore for major bumps until the project is migrated to ESM. Action UX - `tags` input now accepts a YAML block mapping (the canonical workflow form) or a JSON object string, instead of the prior multi-line KEY=VALUE format. Tag keys still merge into the app's existing tags on every run. - Update action.yml description, README, and example workflows to the new tag form. Docs - Correct the GitHub slug from `spiceai/spice-cloud-deploy-action` to `spicehq/spice-cloud-deploy-action` everywhere it appeared (README badges + examples, package.json metadata, examples/), so a copy/pasted `uses:` line resolves to the published action at v1. - Replace the duplicated tail-of-document "Required scopes" table with a single "Scope cheat sheet" right under the OAuth client setup steps, including an "All-in (recommended for a single CI client)" row that spells out exactly which scopes to grant. Tests - New `parseTags` cases cover the YAML form, JSON form, quoted values, duplicates, and validation errors. - Total: 70 unit tests, all green. * fix: address PR review comments - parseBlockMap duplicate check now uses Object.hasOwn() so prototype- chain property names like `toString` and `constructor` aren't falsely rejected as duplicates. - Drop ':' from TAG_KEY_PATTERN. The block-map parser splits on the first ':', so a tag key containing ':' (e.g. `foo:bar`) couldn't be expressed in YAML form anyway. Aligning the JSON form keeps validation consistent across both input styles. Also rewords the validation error message to match the trimmed character set. - Rename the misleading "rejects JSON arrays" test to make clear it rejects non-string JSON values; add a separate case for a root-level JSON array (which falls through to the YAML parser); add a regression test for the prototype-chain dupe-check fix.
1 parent eea7728 commit f84b3ab

12 files changed

Lines changed: 274 additions & 118 deletions

File tree

.github/workflows/test-action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
create-app-if-missing: true
3131
spicepod: examples/spicepod.yaml
3232
tags: |
33-
source=spice-cloud-deploy-action
34-
commit=${{ github.sha }}
33+
source: spice-cloud-deploy-action
34+
commit: ${{ github.sha }}
3535
test-sql: SELECT 1
3636
test-warmup-seconds: 120

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1212
- Initial release of the Spice Cloud Deploy Action.
1313
- OAuth 2.0 client credentials authentication against the Spice Cloud Management API.
1414
- Resolve apps by `app-id` or `app-name`, with optional `create-app-if-missing` flow.
15-
- App tag merging via `tags` input.
15+
- App tag merging via `tags` input — accepts a YAML block mapping (recommended) or JSON object.
1616
- Optional Spicepod manifest push from `spicepod.yaml` before deploy.
1717
- Bulk app secret upsert from a multi-line `secrets` input (values masked in logs).
1818
- Deployment trigger with `branch`/`commit_sha`/`commit_message` auto-populated from the GitHub event.

README.md

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
# Spice Cloud Deploy Action
44

5-
[![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)
6-
[![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)
5+
[![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)
6+
[![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)
77
[![Marketplace](https://img.shields.io/badge/marketplace-spice--cloud--deploy-1F8AC0?logo=githubactions&logoColor=white)](https://github.com/marketplace/actions/spice-cloud-deploy)
88
[![License](https://img.shields.io/badge/license-Apache%202.0-1F8AC0)](LICENSE)
99

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

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

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

58+
### Scope cheat sheet
59+
60+
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.
61+
62+
| Use this action to… | Required scopes |
63+
| --- | --- |
64+
| Resolve an existing app and trigger a deployment | `apps:read`, `deployments:read`, `deployments:write` |
65+
| Create the app on first run (`create-app-if-missing: true`) | + `apps:write` |
66+
| Push a `spicepod.yaml` manifest to the app before deploying | + `apps:write` |
67+
| Set or merge app `tags` | + `apps:write` |
68+
| Upsert app `secrets` before deploying | + `secrets:write` |
69+
| Run runtime smoke tests (`test-sql`, `test-nsql`, etc.) | _no extra scope_ — uses `apps:read`, already required |
70+
| **All-in (recommended for a single CI client)** | **`apps:read` `apps:write` `deployments:read` `deployments:write` `secrets:write`** |
71+
72+
> Avoid the `*` wildcard scope in production — it grants `apps:delete`, `secrets:read` (decrypted via the portal), and `members:*`, which this action never needs.
73+
5974
## Inputs
6075

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

115130
```yaml
116-
- uses: spiceai/spice-cloud-deploy-action@v1
131+
- uses: spicehq/spice-cloud-deploy-action@v1
117132
with:
118133
client-id: ${{ secrets.SPICE_CLIENT_ID }}
119134
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
@@ -122,15 +137,17 @@ The action exchanges the client credentials at `https://spice.ai/api/oauth/token
122137
create-app-if-missing: true
123138
visibility: private
124139
tags: |
125-
environment=production
126-
team=data-platform
127-
commit=${{ github.sha }}
140+
environment: production
141+
team: data-platform
142+
commit: ${{ github.sha }}
128143
```
129144

145+
> `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.
146+
130147
### Upsert app secrets and run a SQL smoke test
131148

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

146163
```yaml
147-
- uses: spiceai/spice-cloud-deploy-action@v1
164+
- uses: spicehq/spice-cloud-deploy-action@v1
148165
with:
149166
client-id: ${{ secrets.SPICE_CLIENT_ID }}
150167
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
@@ -170,7 +187,7 @@ jobs:
170187
runs-on: ubuntu-latest
171188
steps:
172189
- uses: actions/checkout@v4
173-
- uses: spiceai/spice-cloud-deploy-action@v1
190+
- uses: spicehq/spice-cloud-deploy-action@v1
174191
with:
175192
client-id: ${{ secrets.SPICE_CLIENT_ID }}
176193
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
@@ -184,7 +201,7 @@ jobs:
184201

185202
```yaml
186203
- id: deploy
187-
uses: spiceai/spice-cloud-deploy-action@v1
204+
uses: spicehq/spice-cloud-deploy-action@v1
188205
with:
189206
client-id: ${{ secrets.SPICE_CLIENT_ID }}
190207
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
@@ -210,22 +227,14 @@ jobs:
210227
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`.
211228
3. **Sync metadata.** Tags from the `tags` input are merged into the app's existing tags via `PUT /v1/apps/{id}`.
212229
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).
213-
5. **Upsert secrets.** Each `KEY=VALUE` line is sent to `POST /v1/apps/{id}/secrets` (upsert).
230+
5. **Upsert secrets.** Each `KEY=VALUE` line in `secrets` is sent to `POST /v1/apps/{id}/secrets` (upsert).
214231
6. **Trigger the deployment.** `POST /v1/apps/{id}/deployments` with `branch`, `commit_sha`, `commit_message`, plus any `image-tag`/`channel`/`replicas` overrides.
215232
7. **Poll until terminal.** `GET /v1/apps/{id}/deployments` is polled every `poll-interval-seconds` up to `timeout-seconds`.
216233
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.
217234

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

220-
## Required scopes
221-
222-
| Action behavior | Minimum scopes |
223-
| --- | --- |
224-
| Resolve an app and trigger a deployment | `apps:read`, `deployments:read`, `deployments:write` |
225-
| Create the app on first run | + `apps:write` |
226-
| Push `spicepod.yaml` to the app | + `apps:write` |
227-
| Upsert app secrets | + `secrets:write` |
228-
| Run smoke tests (read API key) | + `apps:read` (already required) |
237+
> Wondering which scopes to grant the OAuth client? See the [Scope cheat sheet](#scope-cheat-sheet) above.
229238
230239
## Compatibility
231240

__tests__/deploy.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ describe("runDeploy", () => {
197197
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
198198
const api = fakeApi({ listApps, updateApp, createDeployment });
199199

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

202202
expect(updateApp).toHaveBeenCalledWith(42, { tags: { existing: "1", environment: "prod" } });
203203
});

__tests__/tags.test.ts

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,107 @@ import { describe, expect, it } from "vitest";
22
import { parseTags } from "../src/tags.js";
33

44
describe("parseTags", () => {
5-
it("returns undefined when input is empty", () => {
5+
it("returns undefined for empty input", () => {
66
expect(parseTags(undefined)).toBeUndefined();
77
expect(parseTags("")).toBeUndefined();
88
expect(parseTags("\n# only comments\n")).toBeUndefined();
9+
expect(parseTags("{}")).toBeUndefined();
910
});
1011

11-
it("parses KEY=VALUE pairs", () => {
12-
expect(parseTags("environment=production\nteam=data\n")).toEqual({
13-
environment: "production",
14-
team: "data",
12+
describe("YAML block-map form", () => {
13+
it("parses key: value lines", () => {
14+
expect(parseTags("environment: production\nteam: data\n")).toEqual({
15+
environment: "production",
16+
team: "data",
17+
});
1518
});
16-
});
1719

18-
it("preserves '=' inside values", () => {
19-
expect(parseTags("url=https://x.com?a=1")).toEqual({ url: "https://x.com?a=1" });
20-
});
20+
it("trims whitespace around keys and values", () => {
21+
expect(parseTags(" environment : production ")).toEqual({
22+
environment: "production",
23+
});
24+
});
2125

22-
it("rejects keys that don't start with a letter", () => {
23-
expect(() => parseTags("1foo=bar")).toThrow(/start with a letter/);
24-
});
26+
it("strips matching single or double quotes around values", () => {
27+
expect(parseTags("a: \"with spaces\"\nb: 'single quoted'")).toEqual({
28+
a: "with spaces",
29+
b: "single quoted",
30+
});
31+
});
32+
33+
it("preserves a single colon inside the value", () => {
34+
expect(parseTags("url: https://example.com:8080/x")).toEqual({
35+
url: "https://example.com:8080/x",
36+
});
37+
});
38+
39+
it("ignores blank lines and #-comment lines", () => {
40+
expect(parseTags("\n# header\nfoo: 1\n\n # inline comment\nbar: 2")).toEqual({
41+
foo: "1",
42+
bar: "2",
43+
});
44+
});
45+
46+
it("rejects lines without a colon", () => {
47+
expect(() => parseTags("not-a-map")).toThrow(/expected "key: value"/);
48+
});
49+
50+
it("rejects keys that don't start with a letter", () => {
51+
expect(() => parseTags("1foo: bar")).toThrow(/start with a letter/);
52+
});
53+
54+
it("rejects duplicate keys", () => {
55+
expect(() => parseTags("env: a\nenv: b")).toThrow(/duplicate tag key/);
56+
});
2557

26-
it("rejects duplicate keys", () => {
27-
expect(() => parseTags("env=a\nenv=b")).toThrow(/duplicate tag key/);
58+
it("does not treat Object.prototype property names as duplicates", () => {
59+
// Regression: `if (key in out)` would falsely flag built-in property
60+
// names like `toString` or `constructor` as duplicates on first use.
61+
expect(parseTags("toString: bar\nconstructor: baz")).toEqual({
62+
toString: "bar",
63+
constructor: "baz",
64+
});
65+
});
66+
67+
it("rejects values longer than 256 chars", () => {
68+
const long = "x".repeat(257);
69+
expect(() => parseTags(`big: ${long}`)).toThrow(/exceeds 256/);
70+
});
2871
});
2972

30-
it("rejects values longer than 256 chars", () => {
31-
const long = "x".repeat(257);
32-
expect(() => parseTags(`big=${long}`)).toThrow(/exceeds 256/);
73+
describe("JSON object form", () => {
74+
it("parses a JSON object", () => {
75+
expect(parseTags('{"environment":"production","team":"data"}')).toEqual({
76+
environment: "production",
77+
team: "data",
78+
});
79+
});
80+
81+
it("rejects malformed JSON that begins with {", () => {
82+
expect(() => parseTags("{ not json")).toThrow(/not valid JSON/);
83+
});
84+
85+
it("rejects non-string JSON values (e.g. an array under a key)", () => {
86+
expect(() => parseTags('{"tags":["a","b"]}')).toThrow(/must be a string/);
87+
});
88+
89+
it("treats a root-level JSON array as YAML and rejects it as malformed", () => {
90+
// A literal `[…]` at the root doesn't start with `{`, so the parser
91+
// falls through to the block-map path, where it's rejected because the
92+
// first non-empty line lacks a "key: value" separator.
93+
expect(() => parseTags('["a","b"]')).toThrow(/expected "key: value"/);
94+
});
95+
96+
it("rejects JSON values that aren't strings", () => {
97+
expect(() => parseTags('{"replicas":3}')).toThrow(/must be a string/);
98+
});
99+
100+
it("rejects JSON keys with invalid characters", () => {
101+
expect(() => parseTags('{"1bad":"x"}')).toThrow(/start with a letter/);
102+
});
103+
104+
it("rejects JSON keys containing ':' (would conflict with the YAML separator)", () => {
105+
expect(() => parseTags('{"foo:bar":"value"}')).toThrow(/letters, numbers, and "_\.\/-"/);
106+
});
33107
});
34108
});

action.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,18 @@ inputs:
4141
default: private
4242
tags:
4343
description: |
44-
Newline-separated `KEY=VALUE` pairs to set as app tags.
45-
Tags are merged with existing tags on each run. Lines starting with `#` are comments.
44+
App tags as a YAML or JSON map. Merged with existing tags on each run.
4645
47-
Example:
46+
YAML form (recommended):
4847
tags: |
49-
environment=production
50-
team=data-platform
51-
commit=${{ github.sha }}
48+
environment: production
49+
team: data-platform
50+
commit: ${{ github.sha }}
51+
52+
JSON form:
53+
tags: '{"environment":"production","team":"data-platform"}'
54+
55+
Lines beginning with `#` are treated as comments.
5256
required: false
5357
spicepod:
5458
description: |

0 commit comments

Comments
 (0)