Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- New `org` input. The action now constructs the `app-url` output as `https://spice.ai/<org>/<app-name>` (the canonical Spice Cloud portal URL pattern). When `org` is unset, it falls back to the owner part of `GITHUB_REPOSITORY`, which matches the Spice org slug for personal orgs and orgs created from a connected GitHub organization.
- New `flight-url` input. The action now passes a regional Apache Arrow Flight gRPC endpoint to the `@spiceai/spice` SDK so the SQL probe uses gRPC instead of falling through to localhost. When `flight-url` is unset, it's derived from the resolved app's region as `<region>-prod-aws-flight.spiceai.io:443` (mirrors the data hostname with `-data` swapped for `-flight`). A `grpc+tls://` / `grpc://` scheme prefix on the input is stripped automatically.

### Fixed
- The `app-url` output was previously `https://<app-name>.spice.ai`, which doesn't resolve. It is now `https://spice.ai/<org>/<app-name>` (e.g. `https://spice.ai/lukekim/home`).
- SQL smoke test failed with `14 UNAVAILABLE: No connection established` because the SDK initialized a gRPC client with no `flightUrl` configured for Spice Cloud. The action now derives a regional flight URL by default; the SDK uses gRPC for SQL queries with HTTP fallback as it was designed to.

### 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 reaches a terminal-ok state (`ready`, `disabled`, or `refreshing`); fail the job immediately on `error` or on timeout-while-pending — regardless of `fail-on-test-error`, which still only governs runtime-probe results. Statuses like `shuttingdown` and any unrecognized values are treated as still-pending so the loop never returns a false-positive "loaded". Configured via `dataset-ready-timeout-seconds` (default `300`, set `0` to skip). Dataset states are surfaced as a `datasets` action output and as a table in the GitHub job step summary.
Comment on lines +9 to 19
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
| --- | --- |
| `app-id` | Resolved numeric app ID. |
| `app-name` | Resolved app name. |
| `app-url` | `https://<app-name>.spice.ai`. |
| `app-url` | Portal URL of the deployed app: `https://spice.ai/<org>/<app-name>`. The `<org>` slug comes from the `org` input when set, otherwise the owner part of `GITHUB_REPOSITORY`. |
| `deployment-id` | Created deployment ID. |
| `deployment-status` | Final status (`queued`, `in_progress`, `succeeded`, `failed`). |
| `deployment-created-at` | ISO 8601 timestamp the deployment was created. |
Expand Down
29 changes: 28 additions & 1 deletion __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ afterEach(() => {
});

import type { SpiceApiClient } from "../src/api.js";
import { resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
import { resolveFlightUrl, resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
import {
DeploymentFailedError,
DeploymentTimeoutError,
Expand Down Expand Up @@ -307,6 +307,33 @@ describe("runDeploy", () => {
).toThrow(/Cannot determine runtime URL/);
});

it("resolves flight URL by swapping `-data` for `-flight` in the cname", () => {
expect(resolveFlightUrl(sampleApp, baseInputs)).toBe(
"us-west-2-prod-aws-flight.spiceai.io:443",
);
});

it("resolves flight URL from app region when cname is missing", () => {
expect(resolveFlightUrl({ id: 1, name: "x", region: "us-east-1" }, baseInputs)).toBe(
"us-east-1-prod-aws-flight.spiceai.io:443",
);
});

it("respects explicit flight-url override (with scheme stripping)", () => {
expect(
resolveFlightUrl(sampleApp, { ...baseInputs, flightUrl: "custom-flight.example:443" }),
).toBe("custom-flight.example:443");
});
Comment on lines +322 to +326

it("returns undefined when no flight URL can be derived", () => {
expect(
resolveFlightUrl(
{ id: 1, name: "x" },
{ ...baseInputs, region: undefined, flightUrl: undefined },
),
).toBeUndefined();
});

it("runs all configured probes in order", async () => {
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
Expand Down
41 changes: 41 additions & 0 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@actions/core", () => ({
setFailed: vi.fn(),
setOutput: vi.fn(),
setSecret: vi.fn(),
info: vi.fn(),
summary: { addHeading: vi.fn(), addTable: vi.fn(), write: vi.fn() },
}));

import { buildAppUrl } from "../src/main.js";
Comment on lines +3 to +11

describe("buildAppUrl", () => {
const ORIGINAL = process.env.GITHUB_REPOSITORY;

beforeEach(() => {
delete process.env.GITHUB_REPOSITORY;
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = ORIGINAL;
});

it("uses the explicit org input when provided", () => {
expect(buildAppUrl("home", "lukekim")).toBe("https://spice.ai/lukekim/home");
});

it("falls back to the owner part of GITHUB_REPOSITORY", () => {
process.env.GITHUB_REPOSITORY = "lukekim/home";
expect(buildAppUrl("home", undefined)).toBe("https://spice.ai/lukekim/home");
});

it("prefers the explicit org over GITHUB_REPOSITORY", () => {
process.env.GITHUB_REPOSITORY = "github-org/repo";
expect(buildAppUrl("home", "spice-org")).toBe("https://spice.ai/spice-org/home");
});

it("falls back to the apps listing when no org is available", () => {
expect(buildAppUrl("home", undefined)).toBe("https://spice.ai/apps");
});
});
22 changes: 20 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ inputs:
description: Create the app if it does not exist. Requires `app-name`.
required: false
default: "false"
org:
description: |
Spice Cloud organization slug. Used to construct the `app-url` output
(`https://spice.ai/<org>/<app-name>`). When unset, the action falls back
to the owner part of `GITHUB_REPOSITORY` — which matches the Spice org
slug for personal orgs and for orgs created from a connected GitHub
organization. Set this explicitly only when your Spice org slug differs
from the GitHub repo owner.
required: false
region:
description: |
Spice Cloud region (e.g. `us-east-1`, `us-west-2`).
Expand Down Expand Up @@ -161,11 +170,20 @@ inputs:
default: "300"
runtime-url:
description: |
Override the runtime base URL probes connect to. When unset, the URL is
derived from the resolved app's region as
Override the runtime HTTP base URL probes connect to. When unset, the
URL is derived from the resolved app's region as
`https://<region>-prod-aws-data.spiceai.io` (e.g.
`https://us-east-1-prod-aws-data.spiceai.io`).
required: false
flight-url:
description: |
Override the Apache Arrow Flight gRPC endpoint the SQL probe connects
to (`host:port`, optionally prefixed with `grpc+tls://`). When unset,
it's derived from the app's region as
`<region>-prod-aws-flight.spiceai.io:443` (e.g.
`us-west-2-prod-aws-flight.spiceai.io:443`). Set this if your app
uses a custom Flight host.
required: false
fail-on-test-error:
description: Fail the action when any probe fails. Set to `false` to record failures without failing the job.
required: false
Expand Down
92 changes: 46 additions & 46 deletions dist/index.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/index.js.map

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as core from "@actions/core";
import type { SpiceApiClient } from "./api.js";
import { DeploymentFailedError, DeploymentTimeoutError, InputValidationError } from "./errors.js";
import type { ActionInputs } from "./inputs.js";
import { deriveRuntimeUrl, deriveRuntimeUrlFromCname } from "./inputs.js";
import {
deriveFlightUrl,
deriveFlightUrlFromCname,
deriveRuntimeUrl,
deriveRuntimeUrlFromCname,
} from "./inputs.js";
import { buildProbePlans } from "./probes.js";
import type { DatasetState, ProbeResult } from "./runtime.js";
import { DatasetReadinessError, RuntimeClient } from "./runtime.js";
Expand Down Expand Up @@ -131,6 +136,21 @@ export function resolveRuntimeUrl(app: App, inputs: ActionInputs): string {
);
}

/**
* Resolve the regional Flight gRPC endpoint (`host:port`) for the SDK. Prefer
* an explicit `flight-url` input, then derive from the app's cname (replacing
* `-data` with `-flight`), then from the app's region, then from the input
* region. Returns `undefined` if no source is available — callers fall back to
* letting the SDK use HTTP.
*/
export function resolveFlightUrl(app: App, inputs: ActionInputs): string | undefined {
if (inputs.flightUrl) return inputs.flightUrl;
if (app.cname) return deriveFlightUrlFromCname(app.cname);
if (app.region) return deriveFlightUrl(app.region);
if (inputs.region) return deriveFlightUrl(inputs.region);
return undefined;
}
Comment on lines +139 to +152

async function maybeUpdateAppMetadata(
api: SpiceApiClient,
app: App,
Expand Down Expand Up @@ -217,13 +237,16 @@ async function runPostDeployChecks(
core.setSecret(apiKey);

const runtimeUrl = resolveRuntimeUrl(app, inputs);
const flightUrl = resolveFlightUrl(app, inputs);
core.info(`Runtime URL: ${runtimeUrl}`);
if (flightUrl) core.info(`Flight URL: grpc+tls://${flightUrl}`);

const runtime = deps.runtimeFactory
? deps.runtimeFactory(apiKey, { ...inputs, runtimeUrl })
? deps.runtimeFactory(apiKey, { ...inputs, runtimeUrl, flightUrl })
: new RuntimeClient({
apiKey,
baseUrl: runtimeUrl,
flightUrl,
warmupSeconds: inputs.testWarmupSeconds,
timeoutSeconds: inputs.testTimeoutSeconds,
clock: deps.clock,
Expand Down
23 changes: 23 additions & 0 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ActionInputs {
clientSecret: string;
appId?: number;
appName?: string;
org?: string;
createAppIfMissing: boolean;
region?: string;
visibility: "public" | "private";
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface ActionInputs {
testTimeoutSeconds: number;
datasetReadyTimeoutSeconds: number;
runtimeUrl?: string;
flightUrl?: string;
failOnTestError: boolean;
}

Expand All @@ -51,6 +53,20 @@ export function deriveRuntimeUrlFromCname(cname: string): string {
return `https://${cname}.spiceai.io`;
}

/**
* Build the regional Apache Arrow Flight gRPC endpoint as `host:port`.
* Spice Cloud's flight endpoint mirrors the data hostname but with `-data`
* swapped for `-flight`, e.g. `us-west-2-prod-aws-flight.spiceai.io:443`.
*/
export function deriveFlightUrl(region: string): string {
return `${region}-prod-aws-flight.spiceai.io:443`;
}

export function deriveFlightUrlFromCname(cname: string): string {
const flightCname = cname.endsWith("-data") ? `${cname.slice(0, -"-data".length)}-flight` : cname;
return `${flightCname}.spiceai.io:443`;
}

function getOptional(name: string): string | undefined {
const value = core.getInput(name);
return value === "" ? undefined : value;
Expand Down Expand Up @@ -181,11 +197,17 @@ export function readInputs(): ActionInputs {
const runtimeUrlRaw = getOptional("runtime-url");
const runtimeUrl = runtimeUrlRaw ? parseUrl("runtime-url", runtimeUrlRaw) : undefined;

// Flight URL is `host:port` (gRPC), not an HTTP URL — don't validate as URL.
// Strip an optional `grpc+tls://` / `grpc://` scheme so docs-style values work.
const flightUrlRaw = getOptional("flight-url");
const flightUrl = flightUrlRaw?.replace(/^grpc(\+tls)?:\/\//, "");

Comment on lines +200 to +204
return {
clientId,
clientSecret,
appId,
appName,
org: getOptional("org"),
createAppIfMissing,
region,
visibility,
Expand Down Expand Up @@ -217,6 +239,7 @@ export function readInputs(): ActionInputs {
testTimeoutSeconds: getRequiredInt("test-timeout-seconds", 30, { min: 1 }),
datasetReadyTimeoutSeconds: getRequiredInt("dataset-ready-timeout-seconds", 300, { min: 0 }),
runtimeUrl,
flightUrl,
failOnTestError: getBool("fail-on-test-error", true),
};
}
18 changes: 17 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async function run(): Promise<void> {
try {
const { app, deployment, probeResults, datasets } = await runDeploy(api, inputs);

const appUrl = `https://${app.name}.spice.ai`;
const appUrl = buildAppUrl(app.name, inputs.org);
core.setOutput("app-id", String(app.id));
core.setOutput("app-name", app.name);
core.setOutput("app-url", appUrl);
Expand Down Expand Up @@ -91,6 +91,22 @@ function handleError(err: unknown): void {
core.setFailed(`Unexpected error: ${String(err)}`);
}

/**
* Build the Spice Cloud portal URL for the deployed app.
*
* Apps live at `https://spice.ai/<org>/<app>` — there is no
* `https://<app>.spice.ai` host. The org slug is the user's Spice
* organization, which for personal orgs and connected GitHub orgs matches the
* GitHub owner of the repository. We use the `org` input when set, fall back
* to the owner part of `GITHUB_REPOSITORY`, and finally fall back to the
* org-less `https://spice.ai/apps` listing if neither is available.
*/
export function buildAppUrl(appName: string, orgInput: string | undefined): string {
const org = orgInput ?? process.env.GITHUB_REPOSITORY?.split("/", 1)[0];
if (org) return `https://spice.ai/${org}/${appName}`;
return `https://spice.ai/apps`;
}

async function writeSummary(args: {
app: { id: number; name: string };
deployment: { id: number | string; status: string; commit_sha?: string; branch?: string };
Expand Down