Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added
- Auto-capture a `repository` tag from `GITHUB_REPOSITORY` when set, sanitized to fit the API's tag-value rule (`/` → `_`). Users can override by setting `repository:` explicitly in the `tags` input.
- New post-deploy dataset readiness check: poll `GET /v1/datasets?status=true` until every dataset leaves `initializing`, fail immediately on `error`. Configured via `dataset-ready-timeout-seconds` (default `300`, set to `0` to skip). Dataset states are surfaced as a `datasets` action output and as a table in the GitHub job step summary.

### Changed
- `parseTags` now validates tag values against the Spice Cloud API rule (alphanumeric plus `_@-`). Previously the action only enforced length, so values like `repo: foo/bar` would round-trip to the API and fail there with a generic 400.
- Bump the action runtime from Node 20 to Node 24 (`runs.using: node24`). Node 20 actions are deprecated by GitHub and will be force-defaulted to Node 24 on June 2, 2026, with Node 20 removed from the runner on September 16, 2026. The build target, CI matrix, `engines.node`, and `.nvmrc` are aligned to Node 24.

### Fixed
- The Spice Cloud `GET /v1/apps/{appId}/api-keys` response is `{ api_key, api_key_2 }`, but the action was reading `{ primary, secondary }` and bailing with `Cannot run runtime probes: no API key returned for app …` whenever runtime probes were enabled. Smoke tests now correctly retrieve the primary (or secondary) key.
- Suppress the `(node:NNNN) [DEP0040] DeprecationWarning: The 'punycode' module is deprecated` runtime warning by aliasing the bare `punycode` specifier to the userland `punycode@2` package at bundle time. The transitive chain `@spiceai/spice → node-fetch@2 → whatwg-url@5 → tr46@0.0.3` previously resolved to Node's deprecated built-in.

## [1.0.0] — 2026-05-02

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
| `test-mcp-arguments` | no | `{}` | JSON-encoded arguments for the MCP tool call. |
| `test-warmup-seconds` | no | `60` | Max wait for `isSpiceReady()` before running probes. |
| `test-timeout-seconds` | no | `30` | Per-probe HTTP timeout. |
| `dataset-ready-timeout-seconds` | no | `300` | Max wait for every dataset to leave `initializing` (via `GET /v1/datasets?status=true`). Action fails immediately if any dataset enters `error`. Set to `0` to skip. |
| `runtime-url` | no | derived | Override probe base URL. By default derived from the app's region as `https://<region>-prod-aws-data.spiceai.io`. |
| `fail-on-test-error` | no | `true` | Fail the job when any probe fails. |
| `api-url` | no | `https://api.spice.ai` | Management API base URL. |
Expand All @@ -122,6 +123,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
| `deployment-status` | Final status (`queued`, `in_progress`, `succeeded`, `failed`). |
| `deployment-created-at` | ISO 8601 timestamp the deployment was created. |
| `test-results` | JSON array of `{ name, ok, durationMs, detail?, error? }`. |
| `datasets` | JSON array of `{ name, status, from?, error?, error_message? }` from `GET /v1/datasets?status=true` after the deployment succeeds. |

## Examples

Expand Down
62 changes: 59 additions & 3 deletions __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const baseInputs: ActionInputs = {
oauthTokenUrl: "https://spice.ai/api/oauth/token",
testWarmupSeconds: 0,
testTimeoutSeconds: 30,
datasetReadyTimeoutSeconds: 0,
failOnTestError: true,
};

Expand Down Expand Up @@ -240,7 +241,7 @@ describe("runDeploy", () => {
it("runs probes and fails when one fails (fail-on-test-error=true)", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
const getApiKeys = vi.fn().mockResolvedValue({ primary: "rk_test" });
const getApiKeys = vi.fn().mockResolvedValue({ api_key: "rk_test", api_key_2: null });
const api = fakeApi({ listApps, createDeployment, getApiKeys });

const probeSql = vi.fn().mockResolvedValue<ProbeResult>({
Expand Down Expand Up @@ -268,7 +269,7 @@ describe("runDeploy", () => {
it("skips probes when API key is unavailable but warns when fail-on-test-error=false", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
const getApiKeys = vi.fn().mockResolvedValue({});
const getApiKeys = vi.fn().mockResolvedValue({ api_key: null, api_key_2: null });
const api = fakeApi({ listApps, createDeployment, getApiKeys });

const result = await runDeploy(api, {
Expand Down Expand Up @@ -309,7 +310,7 @@ describe("runDeploy", () => {
it("runs all configured probes in order", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
const getApiKeys = vi.fn().mockResolvedValue({ primary: "rk" });
const getApiKeys = vi.fn().mockResolvedValue({ api_key: "rk", api_key_2: null });
const api = fakeApi({ listApps, createDeployment, getApiKeys });

const calls: string[] = [];
Expand Down Expand Up @@ -352,4 +353,59 @@ describe("runDeploy", () => {

expect(calls).toEqual(["sql", "nsql", "chat", "search", "mcp"]);
});

it("waits for datasets before probes and fails when any dataset is in error state", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
const getApiKeys = vi.fn().mockResolvedValue({ api_key: "rk", api_key_2: null });
const api = fakeApi({ listApps, createDeployment, getApiKeys });

const waitForDatasetsReady = vi.fn().mockRejectedValue(
Object.assign(new Error("1 dataset(s) failed to load: foo: bad creds"), {
name: "DatasetReadinessError",
datasets: [{ name: "foo", status: "Error", error_message: "bad creds" }],
}),
);
const probeSql = vi.fn();
const fakeRuntime = {
waitForReady: vi.fn().mockResolvedValue(undefined),
waitForDatasetsReady,
probeSql,
} as unknown as RuntimeClient;

await expect(
runDeploy(
api,
{ ...baseInputs, datasetReadyTimeoutSeconds: 60, testSql: "SELECT 1" },
{ runtimeFactory: () => fakeRuntime },
),
).rejects.toThrow(/dataset.*failed to load/);

expect(waitForDatasetsReady).toHaveBeenCalledWith(60);
expect(probeSql).not.toHaveBeenCalled();
});

it("returns dataset states when all datasets are ready", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
const getApiKeys = vi.fn().mockResolvedValue({ api_key: "rk", api_key_2: null });
const api = fakeApi({ listApps, createDeployment, getApiKeys });

const datasets = [
{ name: "a", status: "Ready" },
{ name: "b", status: "Ready" },
];
const fakeRuntime = {
waitForReady: vi.fn().mockResolvedValue(undefined),
waitForDatasetsReady: vi.fn().mockResolvedValue(datasets),
} as unknown as RuntimeClient;

const result = await runDeploy(
api,
{ ...baseInputs, datasetReadyTimeoutSeconds: 60 },
{ runtimeFactory: () => fakeRuntime },
);

expect(result.datasets).toEqual(datasets);
});
});
117 changes: 117 additions & 0 deletions __tests__/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@actions/core", () => ({
debug: vi.fn(),
info: vi.fn(),
setSecret: vi.fn(),
warning: vi.fn(),
Expand Down Expand Up @@ -160,6 +161,122 @@ describe("RuntimeClient", () => {
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer k");
});

it("getDatasets requests /v1/datasets?status=true with x-api-key", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify([{ name: "a", status: "Ready" }]), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const rt = new RuntimeClient({
apiKey: "k",
baseUrl: "https://us-west-2-prod-aws-data.spiceai.io",
warmupSeconds: 0,
timeoutSeconds: 5,
sdkFactory: () => makeSdk(),
fetchImpl,
});
const result = await rt.getDatasets();
expect(result).toEqual([{ name: "a", status: "Ready" }]);
const [url, init] = fetchImpl.mock.calls[0]!;
expect(url).toBe("https://us-west-2-prod-aws-data.spiceai.io/v1/datasets?status=true");
expect(init.method).toBe("GET");
expect((init.headers as Record<string, string>)["x-api-key"]).toBe("k");
});

it("waitForDatasetsReady returns once all datasets are ready", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
{ name: "a", status: "Initializing" },
{ name: "b", status: "Ready" },
]),
{ status: 200, headers: { "content-type": "application/json" } },
),
)
.mockResolvedValue(
new Response(
JSON.stringify([
{ name: "a", status: "Ready" },
{ name: "b", status: "Ready" },
]),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const clock = makeClock();
const rt = new RuntimeClient({
apiKey: "k",
baseUrl: "https://x.example",
warmupSeconds: 0,
timeoutSeconds: 5,
sdkFactory: () => makeSdk(),
fetchImpl,
clock,
});
const datasets = await rt.waitForDatasetsReady(60);
expect(datasets.every((d) => d.status === "Ready")).toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});

it("waitForDatasetsReady throws DatasetReadinessError on error state", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify([{ name: "a", status: "Error", error_message: "auth failed" }]),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const rt = new RuntimeClient({
apiKey: "k",
baseUrl: "https://x.example",
warmupSeconds: 0,
timeoutSeconds: 5,
sdkFactory: () => makeSdk(),
fetchImpl,
});
await expect(rt.waitForDatasetsReady(60)).rejects.toMatchObject({
name: "DatasetReadinessError",
message: expect.stringContaining("auth failed"),
});
});

it("waitForDatasetsReady throws on timeout while still initializing", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify([{ name: "a", status: "Initializing" }]), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const clock = makeClock();
const rt = new RuntimeClient({
apiKey: "k",
baseUrl: "https://x.example",
warmupSeconds: 0,
timeoutSeconds: 5,
sdkFactory: () => makeSdk(),
fetchImpl,
clock,
});
await expect(rt.waitForDatasetsReady(5)).rejects.toThrow(/did not finish loading/);
});

it("waitForDatasetsReady is a no-op when timeout is 0", async () => {
const fetchImpl = vi.fn();
const rt = new RuntimeClient({
apiKey: "k",
baseUrl: "https://x.example",
warmupSeconds: 0,
timeoutSeconds: 5,
sdkFactory: () => makeSdk(),
fetchImpl,
});
expect(await rt.waitForDatasetsReady(0)).toEqual([]);
expect(fetchImpl).not.toHaveBeenCalled();
});

it("probeSearch reports failures with body context", async () => {
const fetchImpl = vi
.fn()
Expand Down
9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ inputs:
description: Per-probe HTTP timeout in seconds.
required: false
default: "30"
dataset-ready-timeout-seconds:
description: |
Maximum seconds to wait for every dataset reported by `GET /v1/datasets?status=true`
to leave the `initializing` state before running runtime probes. The action fails
immediately if any dataset enters the `error` state. Set to `0` to skip the check.
required: false
default: "300"
runtime-url:
description: |
Override the runtime base URL probes connect to. When unset, the URL is
Expand Down Expand Up @@ -187,6 +194,8 @@ outputs:
description: ISO 8601 timestamp the deployment was created.
test-results:
description: JSON array of `{ name, ok, durationMs, detail?, error? }` for each runtime probe.
datasets:
description: JSON array of `{ name, status, from?, error?, error_message? }` from `GET /v1/datasets?status=true` after deployment.

runs:
using: node24
Expand Down
Loading