Skip to content

Commit a8db3ea

Browse files
authored
feat: secrets input accepts a map; log per-call API response time (#16)
* feat: secrets input accepts a map; log per-call API timing Three changes from the latest deploy run feedback. 1. Map-shaped `secrets` input Mirror the `tags` refactor: accept a YAML block mapping (the canonical workflow form) or a JSON object string, instead of multi-line `KEY=VALUE` lines. Same key validation as before — must start with a letter or underscore, alphanumeric + underscores only — and secret values can still contain any characters. Each value is added to the runner's secret-mask list before any API call. 2. Per-call API response time Every Spice Cloud Management API call now logs `<METHOD> <path> → <status> <statusText> (<durationMs>ms)` so the user can see each request's latency inline. Network failures log `<METHOD> <path> → network error in <durationMs>ms: <message>`. Removed the redundant pre-call path logs in deploy.ts that the timing line now covers. 3. Branch missing from step summary The list-deployments endpoint sometimes returns Deployment objects without `branch` even when 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. Tests: 107 passing (up from 101). New cases cover the YAML and JSON secret forms (incl. quote-stripping, special characters in values, and JSON validation) and the post-rebuild `dist/` round-trip. * chore: bump version to 1.1.0 for release - package.json: 1.0.0 → 1.1.0. - CHANGELOG.md: collapse the [Unreleased] block into a [1.1.0] section dated 2026-05-03, with explicit Breaking changes / Added / Changed / Fixed buckets. Added an empty [Unreleased] header for future entries. The release rolls up everything since v1.0.0: - Breaking: secrets input switched from KEY=VALUE to YAML/JSON map (matches `tags`); tag-value validation tightened to alphanumeric + `_@-` only. - Added: `org`, `flight-url`, `dataset-ready-timeout-seconds` inputs; `datasets` output; auto-captured `repository` tag; per-call `<METHOD> <path> → <status> (<ms>ms)` API timing in logs. - Changed: action runtime bumped from Node 20 to Node 24. - Fixed: app-url URL pattern, SQL probe gRPC connection, api-key field names, step-summary branch fallback, punycode deprecation warning, action.yml `${{ … }}` example evaluation bug. * Revert "chore: bump version to 1.1.0 for release" This reverts commit 57ecfd2. * fix: address PR review comments - src/main.ts: Use a length-aware fallback (deployment.branch?.length ? deployment.branch : inputs.branch ?? "") so the step summary still falls back when the API returns an empty string, while documenting why we treat empty-string as equivalent to missing here. - src/secrets.ts: Stop calling `.trim()` on secret values. Strip only the leading whitespace separator after `:` and a matching pair of outer quotes; trailing whitespace and inner whitespace are preserved verbatim. Also pass the un-trimmed raw input to parseBlockMap so a trailing space on the last line isn't lost. Whitespace-significant values still need quoting to make the boundaries explicit. - src/api.ts: Move the timing log to AFTER the response body is read so the duration reflects true end-to-end request latency, not just time-to-first-byte. Body-read errors retry on the next attempt and fail with a clear `Failed to read response body for …` message on exhaustion. New tests cover trailing whitespace preserved in unquoted secret values, leading whitespace preserved inside quoted secret values, and multiple-spaces-after-colon handling.
1 parent 074f5c2 commit a8db3ea

12 files changed

Lines changed: 308 additions & 121 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Changed
10+
- `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.
11+
- 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.
12+
13+
### Fixed
14+
- 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.
15+
916
### Added
1017
- 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.
1118
- 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.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
9292
| `commit-sha` | no | `${{ github.sha }}` | Commit SHA attributed to the deployment. |
9393
| `commit-message` | no | head-commit message | Commit message attributed to the deployment. |
9494
| `debug` | no | `false` | Enable runtime debug mode for this deployment. |
95-
| `secrets` | no | — | Multi-line `KEY=VALUE` app secrets to upsert before deploy. Values are masked. |
95+
| `secrets` | no | — | YAML or JSON map of app secrets to upsert before deploy. Values are masked in logs. |
9696
| `wait-for-completion` | no | `true` | Poll the deployment until it finishes. |
9797
| `timeout-seconds` | no | `600` | Max wait when `wait-for-completion` is true. |
9898
| `poll-interval-seconds` | no | `10` | Seconds between status polls. |
@@ -159,8 +159,8 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
159159
client-secret: ${{ secrets.SPICE_CLIENT_SECRET }}
160160
app-name: analytics
161161
secrets: |
162-
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
163-
PG_PASSWORD=${{ secrets.PG_PASSWORD }}
162+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
163+
PG_PASSWORD: ${{ secrets.PG_PASSWORD }}
164164
test-sql: SELECT count(*) FROM taxi_trips
165165
```
166166

__tests__/deploy.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe("runDeploy", () => {
128128

129129
await runDeploy(api, {
130130
...baseInputs,
131-
secretsRaw: "OPENAI=sk-1\nPG_PASS=hunter2",
131+
secretsRaw: "OPENAI: sk-1\nPG_PASS: hunter2",
132132
});
133133

134134
expect(upsertSecret).toHaveBeenNthCalledWith(1, 42, "OPENAI", "sk-1");

__tests__/secrets.test.ts

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,44 +11,98 @@ describe("parseSecrets", () => {
1111
expect(parseSecrets(undefined)).toEqual([]);
1212
expect(parseSecrets("")).toEqual([]);
1313
expect(parseSecrets("\n \n")).toEqual([]);
14+
expect(parseSecrets("{}")).toEqual([]);
1415
});
1516

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

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

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

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

42-
it("rejects names that don't match the API pattern", () => {
43-
expect(() => parseSecrets("1FOO=x")).toThrow(/must start with a letter or underscore/);
44-
expect(() => parseSecrets("FOO-BAR=x")).toThrow(/letters, numbers, and underscores/);
45-
});
47+
it("preserves leading whitespace inside quoted values", () => {
48+
expect(parseSecrets('KEY: " inner-leading"')).toEqual([
49+
{ name: "KEY", value: " inner-leading" },
50+
]);
51+
});
52+
53+
it("strips multiple spaces between ':' and a quoted value", () => {
54+
expect(parseSecrets('KEY: "value"')).toEqual([{ name: "KEY", value: "value" }]);
55+
});
56+
57+
it("ignores blank lines and #-comment lines", () => {
58+
expect(parseSecrets("\n# header\nFOO: bar\n # indented\nBAZ: qux")).toEqual([
59+
{ name: "FOO", value: "bar" },
60+
{ name: "BAZ", value: "qux" },
61+
]);
62+
});
4663

47-
it("rejects duplicate names", () => {
48-
expect(() => parseSecrets("FOO=a\nFOO=b")).toThrow(/duplicate secret "FOO"/);
64+
it("rejects lines without ':'", () => {
65+
expect(() => parseSecrets("FOO\nBAR: baz")).toThrow(/expected "KEY: VALUE"/);
66+
});
67+
68+
it("rejects names that don't match the API pattern", () => {
69+
expect(() => parseSecrets("1FOO: x")).toThrow(/start with a letter or underscore/);
70+
expect(() => parseSecrets("FOO-BAR: x")).toThrow(/letters, numbers, and underscores/);
71+
});
72+
73+
it("rejects duplicate names", () => {
74+
expect(() => parseSecrets("FOO: a\nFOO: b")).toThrow(/duplicate secret "FOO"/);
75+
});
76+
77+
it("allows empty values without crashing", () => {
78+
expect(parseSecrets("EMPTY: ")).toEqual([{ name: "EMPTY", value: "" }]);
79+
});
4980
});
5081

51-
it("allows empty values without crashing", () => {
52-
expect(parseSecrets("EMPTY=")).toEqual([{ name: "EMPTY", value: "" }]);
82+
describe("JSON object form", () => {
83+
it("parses a JSON object", () => {
84+
expect(parseSecrets('{"FOO":"bar","BAZ":"qux"}')).toEqual([
85+
{ name: "FOO", value: "bar" },
86+
{ name: "BAZ", value: "qux" },
87+
]);
88+
});
89+
90+
it("preserves any string value", () => {
91+
expect(parseSecrets('{"URL":"https://x.com:8080?a=1&b=2!@#"}')).toEqual([
92+
{ name: "URL", value: "https://x.com:8080?a=1&b=2!@#" },
93+
]);
94+
});
95+
96+
it("rejects malformed JSON that begins with {", () => {
97+
expect(() => parseSecrets("{ not json")).toThrow(/not valid JSON/);
98+
});
99+
100+
it("rejects non-string JSON values", () => {
101+
expect(() => parseSecrets('{"REPLICAS":3}')).toThrow(/must be a string/);
102+
});
103+
104+
it("rejects JSON keys that don't match the API pattern", () => {
105+
expect(() => parseSecrets('{"1FOO":"x"}')).toThrow(/start with a letter or underscore/);
106+
});
53107
});
54108
});

action.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,20 @@ inputs:
9797
default: "false"
9898
secrets:
9999
description: |
100-
Newline-separated `KEY=VALUE` pairs to upsert as app secrets before deploy.
101-
Lines starting with `#` are comments. Values are masked in logs.
100+
App secrets to upsert before deploy, as a YAML or JSON map. Values are
101+
added to the runner's secret-mask list so they don't appear in logs.
102102
103-
Example:
103+
YAML form (recommended):
104104
secrets: |
105-
OPENAI_API_KEY=<openai-api-key>
106-
PG_PASSWORD=<pg-password>
105+
OPENAI_API_KEY: <openai-api-key>
106+
PG_PASSWORD: <pg-password>
107+
108+
JSON form:
109+
secrets: '{"OPENAI_API_KEY":"<openai-api-key>","PG_PASSWORD":"<pg-password>"}'
110+
111+
Lines beginning with `#` are treated as comments. Secret values can
112+
contain any characters; only the secret name is constrained (must
113+
start with a letter or underscore, alphanumeric + underscores only).
107114
required: false
108115
wait-for-completion:
109116
description: Poll the deployment until it succeeds or fails.

dist/index.js

Lines changed: 44 additions & 44 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/full.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ jobs:
3333
team: data-platform
3434
commit: ${{ github.sha }}
3535
secrets: |
36-
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
37-
PG_PASSWORD=${{ secrets.PG_PASSWORD }}
36+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
37+
PG_PASSWORD: ${{ secrets.PG_PASSWORD }}
3838
test-sql: SELECT count(*) AS n FROM taxi_trips
3939
test-nsql: Show me revenue by month for the last 6 months
4040
test-chat: "Summarize today's top 3 trips"

src/api.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export class SpiceApiClient {
9797
};
9898
if (body !== undefined) headers["Content-Type"] = "application/json";
9999

100+
const startMs = Date.now();
100101
let res: Response;
101102
try {
102103
res = await this.fetchImpl(url, {
@@ -105,7 +106,9 @@ export class SpiceApiClient {
105106
body: body === undefined ? undefined : JSON.stringify(body),
106107
});
107108
} catch (err) {
109+
const durationMs = Date.now() - startMs;
108110
lastError = err as Error;
111+
core.info(`${method} ${path} → network error in ${durationMs}ms: ${lastError.message}`);
109112
if (attempt < this.maxAttempts) {
110113
await this.sleep(this.backoff(attempt));
111114
continue;
@@ -117,21 +120,44 @@ export class SpiceApiClient {
117120
);
118121
}
119122

123+
// Read the body before logging so the timing covers true end-to-end
124+
// request latency (request send → response headers → full body received),
125+
// not just time-to-first-byte. 204 No Content has no body to read.
126+
let bodyText = "";
127+
let bodyError: Error | undefined;
128+
if (res.status !== 204) {
129+
try {
130+
bodyText = await res.text();
131+
} catch (err) {
132+
bodyError = err as Error;
133+
}
134+
}
135+
const durationMs = Date.now() - startMs;
136+
core.info(`${method} ${path}${res.status} ${res.statusText} (${durationMs}ms)`);
137+
120138
if (res.status === 204) {
121139
return undefined as T;
122140
}
123141

142+
if (bodyError) {
143+
if (attempt < this.maxAttempts) {
144+
await this.sleep(this.backoff(attempt));
145+
continue;
146+
}
147+
throw new SpiceApiError(
148+
`Failed to read response body for ${method} ${path}: ${bodyError.message}`,
149+
res.status,
150+
url,
151+
);
152+
}
153+
124154
if (res.ok) {
125-
if (res.status === 202 || res.status === 201 || res.status === 200) {
126-
const text = await res.text();
127-
if (!text) return undefined as T;
128-
try {
129-
return JSON.parse(text) as T;
130-
} catch {
131-
return text as unknown as T;
132-
}
155+
if (!bodyText) return undefined as T;
156+
try {
157+
return JSON.parse(bodyText) as T;
158+
} catch {
159+
return bodyText as unknown as T;
133160
}
134-
return (await res.json()) as T;
135161
}
136162

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

146-
const errorBody = await readErrorBody(res);
172+
const errorBody = parseErrorBody(bodyText);
147173
throw new SpiceApiError(
148174
formatApiError(method, path, res, errorBody),
149175
res.status,
@@ -170,8 +196,7 @@ export class SpiceApiClient {
170196
}
171197
}
172198

173-
async function readErrorBody(res: Response): Promise<ApiErrorBody | string | undefined> {
174-
const text = await res.text().catch(() => "");
199+
function parseErrorBody(text: string): ApiErrorBody | string | undefined {
175200
if (!text) return undefined;
176201
try {
177202
return JSON.parse(text) as ApiErrorBody;

src/deploy.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export async function runDeploy(
5151

5252
const body = buildDeploymentBody(inputs);
5353
core.startGroup("Trigger deployment");
54-
core.info(`POST /v1/apps/${app.id}/deployments`);
5554
core.info(`Payload: ${JSON.stringify(body)}`);
5655
const deployment = await api.createDeployment(app.id, body);
5756
core.info(`Deployment created (id=${deployment.id}, status=${deployment.status}).`);
@@ -162,7 +161,7 @@ async function maybeUpdateAppMetadata(
162161
const merged = { ...(app.tags ?? {}), ...tags };
163162
const update: UpdateAppBody = { tags: merged };
164163
core.startGroup("Update app tags");
165-
core.info(`PUT /v1/apps/${app.id} tags=${JSON.stringify(merged)}`);
164+
core.info(`Tags: ${JSON.stringify(merged)}`);
166165
await api.updateApp(app.id, update);
167166
core.endGroup();
168167
}
@@ -192,7 +191,7 @@ async function maybeUpsertSecrets(
192191

193192
core.startGroup(`Upsert ${secrets.length} secret(s)`);
194193
for (const secret of secrets) {
195-
core.info(`POST /v1/apps/${app.id}/secrets — ${secret.name}`);
194+
core.info(`Secret: ${secret.name}`);
196195
await api.upsertSecret(app.id, secret.name, secret.value);
197196
}
198197
core.endGroup();

0 commit comments

Comments
 (0)