Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
38 changes: 26 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,39 @@ 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.
## [1.1.0] — 2026-05-03

### Breaking changes
- **`secrets` input is now a YAML/JSON map, not `KEY=VALUE` lines.** Mirrors the `tags` input shape. Update existing workflows to swap `=` for `:` between key and value:
```yaml
# Before (1.0.0)
secrets: |
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
# After (1.1.0)
secrets: |
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
```
Secret-name validation is unchanged (must start with a letter or underscore, alphanumeric + underscores only). Secret values can contain any characters and are added to the runner's secret-mask list before any API call.
- **Tag values are now strictly validated against the Spice Cloud API's allowed character set** (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`. Workflows that passed `/`, `:`, spaces, or other special characters in tag values must update to use only `_@-` (the action also auto-sanitizes the auto-captured `repository` tag).

### 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.
- **`org` input** controlling the `app-url` output, which is now constructed as `https://spice.ai/<org>/<app-name>` (the canonical Spice Cloud portal URL). 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.
- **`flight-url` input** for the regional Apache Arrow Flight gRPC endpoint. When unset, 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.
- **`dataset-ready-timeout-seconds` input** for a post-deploy dataset readiness check. The action polls `GET /v1/datasets?status=true` until every dataset reaches a terminal-ok state (`ready`, `disabled`, or `refreshing`) and fails immediately on `error` or on timeout-while-pending — independent of `fail-on-test-error`, which 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". Default `300` seconds, set `0` to skip.
- **`datasets` action output** — JSON array of `{ name, status, from?, error?, error_message? }` from the `/v1/datasets?status=true` response. Also rendered as a "Datasets" table in the GitHub job step summary.
- **Auto-captured `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.
- **Per-call response time logging.** Every Spice Cloud Management API call now logs `<METHOD> <path> → <status> <statusText> (<durationMs>ms)` so request latency is visible inline in the action logs. Network failures log `<METHOD> <path> → network error in <durationMs>ms: <message>`.

### 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.
- 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. Build target, CI matrix, `engines.node`, and `.nvmrc` are aligned to Node 24.

### Fixed
- `app-url` output was previously `https://<app-name>.spice.ai`, which doesn't resolve. It is now `https://spice.ai/<org>/<app-name>`.
- 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 designed.
- 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.
- 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.
- 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.
- `action.yml` no longer embeds literal `${{ … }}` example tokens in `description:` blocks, which the runner was evaluating at action-load time and erroring out with `Unrecognized named-value: 'github'` / `'secrets'`.

## [1.0.0] — 2026-05-02

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
96 changes: 66 additions & 30 deletions __tests__/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,80 @@ 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("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 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("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("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
Loading