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

## [Unreleased]

### Changed
- `secrets` input now accepts the same YAML block mapping or JSON object form as `tags`, instead of multi-line `KEY=VALUE` lines. Existing key validation is unchanged (must start with a letter or underscore, alphanumeric + underscores only); values can contain any characters and are added to the runner's secret-mask list. Update your workflows to swap `=` for `:` between key and value.
- Every Spice Cloud Management API call now logs `<METHOD> <path> → <status> <statusText> (<durationMs>ms)` so you can see latency for each request inline in the action logs. Network failures log `<METHOD> <path> → network error in <durationMs>ms: <message>`. Removed the redundant pre-call path logs in `deploy.ts` since the timing line covers them.

### Fixed
- The step-summary "Branch" cell was empty when the management API's list-deployments response omitted the `branch` field even though the create request set it. The summary now falls back to the `branch` and `commit-sha` inputs we sent so the right values surface even when the API echoes them inconsistently.

### 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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
| `commit-sha` | no | `${{ github.sha }}` | Commit SHA attributed to the deployment. |
| `commit-message` | no | head-commit message | Commit message attributed to the deployment. |
| `debug` | no | `false` | Enable runtime debug mode for this deployment. |
| `secrets` | no | — | Multi-line `KEY=VALUE` app secrets to upsert before deploy. Values are masked. |
| `secrets` | no | — | YAML or JSON map of app secrets to upsert before deploy. Values are masked in logs. |
| `wait-for-completion` | no | `true` | Poll the deployment until it finishes. |
| `timeout-seconds` | no | `600` | Max wait when `wait-for-completion` is true. |
| `poll-interval-seconds` | no | `10` | Seconds between status polls. |
Expand Down Expand Up @@ -159,8 +159,8 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
app-name: analytics
secrets: |
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
PG_PASSWORD=${{ secrets.PG_PASSWORD }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PG_PASSWORD: ${{ secrets.PG_PASSWORD }}
test-sql: SELECT count(*) FROM taxi_trips
```

Expand Down
2 changes: 1 addition & 1 deletion __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe("runDeploy", () => {

await runDeploy(api, {
...baseInputs,
secretsRaw: "OPENAI=sk-1\nPG_PASS=hunter2",
secretsRaw: "OPENAI: sk-1\nPG_PASS: hunter2",
});

expect(upsertSecret).toHaveBeenNthCalledWith(1, 42, "OPENAI", "sk-1");
Expand Down
114 changes: 84 additions & 30 deletions __tests__/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,98 @@ describe("parseSecrets", () => {
expect(parseSecrets(undefined)).toEqual([]);
expect(parseSecrets("")).toEqual([]);
expect(parseSecrets("\n \n")).toEqual([]);
expect(parseSecrets("{}")).toEqual([]);
});

it("parses KEY=VALUE pairs across lines", () => {
const result = parseSecrets("FOO=bar\nBAZ=qux quux\n");
expect(result).toEqual([
{ name: "FOO", value: "bar" },
{ name: "BAZ", value: "qux quux" },
]);
});
describe("YAML block-map form", () => {
it("parses KEY: VALUE lines", () => {
expect(parseSecrets("FOO: bar\nBAZ: qux\n")).toEqual([
{ name: "FOO", value: "bar" },
{ name: "BAZ", value: "qux" },
]);
});

it("preserves '=' inside the value", () => {
expect(parseSecrets("URL=https://x.com?a=1&b=2")).toEqual([
{ name: "URL", value: "https://x.com?a=1&b=2" },
]);
});
it("preserves any character (incl. ':' '=' '/') inside the value", () => {
// Splits on the first ':' only. Values are otherwise unrestricted.
expect(parseSecrets("URL: https://x.com:8080?a=1&b=2!@#$%^&*()")).toEqual([
{ name: "URL", value: "https://x.com:8080?a=1&b=2!@#$%^&*()" },
]);
});

it("ignores blank lines and comments", () => {
const result = parseSecrets("\n# a comment\nFOO=bar\n # indented comment\nBAZ=qux\n");
expect(result).toEqual([
{ name: "FOO", value: "bar" },
{ name: "BAZ", value: "qux" },
]);
});
it("strips matching single or double quotes around values", () => {
expect(parseSecrets("A: \"with spaces and : colons\"\nB: 'special !@#$ chars'")).toEqual([
{ name: "A", value: "with spaces and : colons" },
{ name: "B", value: "special !@#$ chars" },
]);
});

it("rejects lines without '='", () => {
expect(() => parseSecrets("FOO\nBAR=baz")).toThrow(/missing "="/);
});
it("preserves trailing whitespace in unquoted values (don't auto-trim secrets)", () => {
// The leading separator after `:` is stripped, but trailing whitespace
// is preserved verbatim. Whitespace-significant secret values should
// be quoted to make the boundaries explicit.
const result = parseSecrets("KEY: trailing-space ");
expect(result).toEqual([{ name: "KEY", value: "trailing-space " }]);
});

it("rejects names that don't match the API pattern", () => {
expect(() => parseSecrets("1FOO=x")).toThrow(/must start with a letter or underscore/);
expect(() => parseSecrets("FOO-BAR=x")).toThrow(/letters, numbers, and underscores/);
});
it("preserves leading whitespace inside quoted values", () => {
expect(parseSecrets('KEY: " inner-leading"')).toEqual([
{ name: "KEY", value: " inner-leading" },
]);
});

it("strips multiple spaces between ':' and a quoted value", () => {
expect(parseSecrets('KEY: "value"')).toEqual([{ name: "KEY", value: "value" }]);
});

it("ignores blank lines and #-comment lines", () => {
expect(parseSecrets("\n# header\nFOO: bar\n # indented\nBAZ: qux")).toEqual([
{ name: "FOO", value: "bar" },
{ name: "BAZ", value: "qux" },
]);
});

it("rejects duplicate names", () => {
expect(() => parseSecrets("FOO=a\nFOO=b")).toThrow(/duplicate secret "FOO"/);
it("rejects lines without ':'", () => {
expect(() => parseSecrets("FOO\nBAR: baz")).toThrow(/expected "KEY: VALUE"/);
});

it("rejects names that don't match the API pattern", () => {
expect(() => parseSecrets("1FOO: x")).toThrow(/start with a letter or underscore/);
expect(() => parseSecrets("FOO-BAR: x")).toThrow(/letters, numbers, and underscores/);
});

it("rejects duplicate names", () => {
expect(() => parseSecrets("FOO: a\nFOO: b")).toThrow(/duplicate secret "FOO"/);
});

it("allows empty values without crashing", () => {
expect(parseSecrets("EMPTY: ")).toEqual([{ name: "EMPTY", value: "" }]);
});
});

it("allows empty values without crashing", () => {
expect(parseSecrets("EMPTY=")).toEqual([{ name: "EMPTY", value: "" }]);
describe("JSON object form", () => {
it("parses a JSON object", () => {
expect(parseSecrets('{"FOO":"bar","BAZ":"qux"}')).toEqual([
{ name: "FOO", value: "bar" },
{ name: "BAZ", value: "qux" },
]);
});

it("preserves any string value", () => {
expect(parseSecrets('{"URL":"https://x.com:8080?a=1&b=2!@#"}')).toEqual([
{ name: "URL", value: "https://x.com:8080?a=1&b=2!@#" },
]);
});

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

it("rejects non-string JSON values", () => {
expect(() => parseSecrets('{"REPLICAS":3}')).toThrow(/must be a string/);
});

it("rejects JSON keys that don't match the API pattern", () => {
expect(() => parseSecrets('{"1FOO":"x"}')).toThrow(/start with a letter or underscore/);
});
});
});
17 changes: 12 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,20 @@ inputs:
default: "false"
secrets:
description: |
Newline-separated `KEY=VALUE` pairs to upsert as app secrets before deploy.
Lines starting with `#` are comments. Values are masked in logs.
App secrets to upsert before deploy, as a YAML or JSON map. Values are
added to the runner's secret-mask list so they don't appear in logs.

Example:
YAML form (recommended):
secrets: |
OPENAI_API_KEY=<openai-api-key>
PG_PASSWORD=<pg-password>
OPENAI_API_KEY: <openai-api-key>
PG_PASSWORD: <pg-password>

JSON form:
secrets: '{"OPENAI_API_KEY":"<openai-api-key>","PG_PASSWORD":"<pg-password>"}'

Lines beginning with `#` are treated as comments. Secret values can
contain any characters; only the secret name is constrained (must
start with a letter or underscore, alphanumeric + underscores only).
required: false
wait-for-completion:
description: Poll the deployment until it succeeds or fails.
Expand Down
88 changes: 44 additions & 44 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.

4 changes: 2 additions & 2 deletions examples/full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ jobs:
team: data-platform
commit: ${{ github.sha }}
secrets: |
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
PG_PASSWORD=${{ secrets.PG_PASSWORD }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PG_PASSWORD: ${{ secrets.PG_PASSWORD }}
test-sql: SELECT count(*) AS n FROM taxi_trips
test-nsql: Show me revenue by month for the last 6 months
test-chat: "Summarize today's top 3 trips"
Expand Down
49 changes: 37 additions & 12 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export class SpiceApiClient {
};
if (body !== undefined) headers["Content-Type"] = "application/json";

const startMs = Date.now();
let res: Response;
try {
res = await this.fetchImpl(url, {
Expand All @@ -105,7 +106,9 @@ export class SpiceApiClient {
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (err) {
const durationMs = Date.now() - startMs;
lastError = err as Error;
core.info(`${method} ${path} → network error in ${durationMs}ms: ${lastError.message}`);
if (attempt < this.maxAttempts) {
await this.sleep(this.backoff(attempt));
continue;
Expand All @@ -117,21 +120,44 @@ export class SpiceApiClient {
);
}

// Read the body before logging so the timing covers true end-to-end
// request latency (request send → response headers → full body received),
// not just time-to-first-byte. 204 No Content has no body to read.
let bodyText = "";
let bodyError: Error | undefined;
if (res.status !== 204) {
try {
bodyText = await res.text();
} catch (err) {
bodyError = err as Error;
}
}
const durationMs = Date.now() - startMs;
core.info(`${method} ${path} → ${res.status} ${res.statusText} (${durationMs}ms)`);
Comment on lines 109 to +136

if (res.status === 204) {
return undefined as T;
}

if (bodyError) {
if (attempt < this.maxAttempts) {
await this.sleep(this.backoff(attempt));
continue;
}
throw new SpiceApiError(
`Failed to read response body for ${method} ${path}: ${bodyError.message}`,
res.status,
url,
);
}

if (res.ok) {
if (res.status === 202 || res.status === 201 || res.status === 200) {
const text = await res.text();
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
if (!bodyText) return undefined as T;
try {
return JSON.parse(bodyText) as T;
} catch {
return bodyText as unknown as T;
}
return (await res.json()) as T;
}

if (RETRYABLE_STATUSES.has(res.status) && attempt < this.maxAttempts) {
Expand All @@ -143,7 +169,7 @@ export class SpiceApiClient {
continue;
}

const errorBody = await readErrorBody(res);
const errorBody = parseErrorBody(bodyText);
throw new SpiceApiError(
formatApiError(method, path, res, errorBody),
res.status,
Expand All @@ -170,8 +196,7 @@ export class SpiceApiClient {
}
}

async function readErrorBody(res: Response): Promise<ApiErrorBody | string | undefined> {
const text = await res.text().catch(() => "");
function parseErrorBody(text: string): ApiErrorBody | string | undefined {
if (!text) return undefined;
try {
return JSON.parse(text) as ApiErrorBody;
Expand Down
5 changes: 2 additions & 3 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export async function runDeploy(

const body = buildDeploymentBody(inputs);
core.startGroup("Trigger deployment");
core.info(`POST /v1/apps/${app.id}/deployments`);
core.info(`Payload: ${JSON.stringify(body)}`);
const deployment = await api.createDeployment(app.id, body);
core.info(`Deployment created (id=${deployment.id}, status=${deployment.status}).`);
Expand Down Expand Up @@ -162,7 +161,7 @@ async function maybeUpdateAppMetadata(
const merged = { ...(app.tags ?? {}), ...tags };
const update: UpdateAppBody = { tags: merged };
core.startGroup("Update app tags");
core.info(`PUT /v1/apps/${app.id} tags=${JSON.stringify(merged)}`);
core.info(`Tags: ${JSON.stringify(merged)}`);
await api.updateApp(app.id, update);
core.endGroup();
}
Expand Down Expand Up @@ -192,7 +191,7 @@ async function maybeUpsertSecrets(

core.startGroup(`Upsert ${secrets.length} secret(s)`);
for (const secret of secrets) {
core.info(`POST /v1/apps/${app.id}/secrets — ${secret.name}`);
core.info(`Secret: ${secret.name}`);
await api.upsertSecret(app.id, secret.name, secret.value);
}
core.endGroup();
Expand Down
21 changes: 17 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async function run(): Promise<void> {
core.setOutput("test-results", JSON.stringify(probeResults));
core.setOutput("datasets", JSON.stringify(datasets));

await writeSummary({ app, deployment, appUrl, probeResults, datasets });
await writeSummary({ app, deployment, appUrl, probeResults, datasets, inputs });

core.info(`Deployment ${deployment.id} status: ${deployment.status}`);
core.info(`App URL: ${appUrl}`);
Expand Down Expand Up @@ -113,16 +113,29 @@ async function writeSummary(args: {
appUrl: string;
probeResults: ProbeResult[];
datasets: DatasetState[];
inputs: ActionInputs;
}): Promise<void> {
if (!process.env.GITHUB_STEP_SUMMARY) return;
const { app, deployment, appUrl, probeResults, datasets } = args;
const { app, deployment, appUrl, probeResults, datasets, inputs } = args;
const statusBadge =
deployment.status === "succeeded"
? "**succeeded**"
: deployment.status === "failed"
? "**failed**"
: `_${deployment.status}_`;

// The list-deployments endpoint sometimes returns Deployment objects with
// the `branch` field missing (or, in observed runs, populated as an empty
// string) even when the create request explicitly set it. Fall back to the
// inputs we sent in either case so the step summary stays accurate. We
// intentionally treat an empty string from the API as equivalent to
// missing — an explicit empty branch carries no information for the
// summary, and the user-reported symptom was an empty Branch cell.
const branch = deployment.branch?.length ? deployment.branch : (inputs.branch ?? "");
const commitSha = deployment.commit_sha?.length
? deployment.commit_sha
: (inputs.commitSha ?? "");

const summary = core.summary.addHeading("Spice Cloud Deploy", 2).addTable([
[
{ data: "Field", header: true },
Expand All @@ -132,8 +145,8 @@ async function writeSummary(args: {
["URL", `<a href="${appUrl}">${appUrl}</a>`],
["Deployment", String(deployment.id)],
["Status", statusBadge],
["Branch", deployment.branch ?? ""],
["Commit", deployment.commit_sha ?? ""],
["Branch", branch],
["Commit", commitSha],
]);

if (datasets.length > 0) {
Expand Down
Loading