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

## [Unreleased]

### 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.

### 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.

## [1.0.0] — 2026-05-02
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
| `create-app-if-missing` | no | `false` | Create the app if it doesn't exist (requires `app-name` and `region`). |
| `region` | conditional | — | Spice Cloud region (e.g. `us-east-1`, `us-west-2`). Required for new apps. |
| `visibility` | no | `private` | `public` or `private` — only used on app creation. |
| `tags` | no | — | YAML or JSON map of tag key/value pairs. Merged into existing app tags. |
| `tags` | no | — | YAML or JSON map of tag key/value pairs. Merged into existing app tags. Tag values may contain only alphanumerics and `_@-`. The action auto-adds a `repository` tag from `GITHUB_REPOSITORY` (sanitizing `/` → `_`) unless you override it. |
| `spicepod` | no | `spicepod.yaml` | Path to the Spicepod manifest. Pushed to the app before deploy when present. |
| `working-directory` | no | `.` | Working directory used to resolve relative paths. |
| `image-tag` | no | — | Override the runtime image tag (e.g. `1.5.0-models`). |
Expand Down Expand Up @@ -143,6 +143,10 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
```

> `tags` accepts either a YAML block mapping (shown above) or a JSON object string (e.g. `tags: '{"environment":"production","team":"data-platform"}'`). Tags are merged into the app's existing tags on every run.
>
> **Tag value rule:** the Spice Cloud API allows only alphanumerics and `_@-`. The action validates this locally so you fail fast with a clear error instead of a `400 Bad Request` on the server.
>
> **Auto-captured tags:** when `GITHUB_REPOSITORY` is set (always true on GitHub-hosted runners), the action adds a `repository` tag derived from that env var, with `/` rewritten to `_` so the value passes API validation. Setting `repository:` explicitly in your `tags` overrides the auto-captured value.

### Upsert app secrets and run a SQL smoke test

Expand Down
37 changes: 36 additions & 1 deletion __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@actions/core", () => ({
debug: vi.fn(),
Expand All @@ -10,6 +10,15 @@ vi.mock("@actions/core", () => ({
warning: vi.fn(),
}));

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

import type { SpiceApiClient } from "../src/api.js";
import { resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
import {
Expand Down Expand Up @@ -202,6 +211,32 @@ describe("runDeploy", () => {
expect(updateApp).toHaveBeenCalledWith(42, { tags: { existing: "1", environment: "prod" } });
});

it("auto-captures `repository` from GITHUB_REPOSITORY when no user tag overrides it", async () => {
process.env.GITHUB_REPOSITORY = "lukekim/home";
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const updateApp = vi.fn().mockResolvedValue(sampleApp);
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
const api = fakeApi({ listApps, updateApp, createDeployment });

await runDeploy(api, { ...baseInputs, tagsRaw: "environment: prod" });

expect(updateApp).toHaveBeenCalledWith(42, {
tags: { repository: "lukekim_home", environment: "prod" },
});
});

it("user-supplied `repository` tag wins over the auto-captured default", async () => {
process.env.GITHUB_REPOSITORY = "lukekim/home";
const listApps = vi.fn().mockResolvedValue([sampleApp]);
const updateApp = vi.fn().mockResolvedValue(sampleApp);
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
const api = fakeApi({ listApps, updateApp, createDeployment });

await runDeploy(api, { ...baseInputs, tagsRaw: "repository: explicit-name" });

expect(updateApp).toHaveBeenCalledWith(42, { tags: { repository: "explicit-name" } });
});

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);
Expand Down
91 changes: 80 additions & 11 deletions __tests__/tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseTags } from "../src/tags.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deriveDefaultTags,
mergeWithDefaultTags,
parseTags,
sanitizeTagValue,
} from "../src/tags.js";

describe("parseTags", () => {
it("returns undefined for empty input", () => {
Expand All @@ -24,15 +29,9 @@ describe("parseTags", () => {
});

it("strips matching single or double quotes around values", () => {
expect(parseTags("a: \"with spaces\"\nb: 'single quoted'")).toEqual({
a: "with spaces",
b: "single quoted",
});
});

it("preserves a single colon inside the value", () => {
expect(parseTags("url: https://example.com:8080/x")).toEqual({
url: "https://example.com:8080/x",
expect(parseTags("a: \"alpha-num_v1\"\nb: 'beta-2'")).toEqual({
a: "alpha-num_v1",
b: "beta-2",
});
});

Expand Down Expand Up @@ -68,6 +67,21 @@ describe("parseTags", () => {
const long = "x".repeat(257);
expect(() => parseTags(`big: ${long}`)).toThrow(/exceeds 256/);
});

it("rejects values with characters not allowed by the API", () => {
// The Spice Cloud API allows only alphanumeric plus `_@-` in tag values.
expect(() => parseTags("repo: lukekim/home")).toThrow(/letters, numbers, and "_@-"/);
expect(() => parseTags('env: "prod env"')).toThrow(/letters, numbers, and "_@-"/);
});

it("accepts allowed value characters (alphanumeric, underscore, at-sign, hyphen)", () => {
expect(parseTags("a: foo_bar\nb: foo@bar\nc: foo-bar\nd: AB123")).toEqual({
a: "foo_bar",
b: "foo@bar",
c: "foo-bar",
d: "AB123",
});
});
});

describe("JSON object form", () => {
Expand Down Expand Up @@ -106,3 +120,58 @@ describe("parseTags", () => {
});
});
});

describe("sanitizeTagValue", () => {
it("replaces disallowed characters with underscore", () => {
expect(sanitizeTagValue("lukekim/home")).toBe("lukekim_home");
expect(sanitizeTagValue("foo bar")).toBe("foo_bar");
expect(sanitizeTagValue("a/b/c")).toBe("a_b_c");
});

it("leaves already-valid characters alone", () => {
expect(sanitizeTagValue("alpha-num_123@v1")).toBe("alpha-num_123@v1");
});

it("truncates to 256 characters", () => {
const big = "a".repeat(300);
expect(sanitizeTagValue(big)).toHaveLength(256);
});
});

describe("deriveDefaultTags / mergeWithDefaultTags", () => {
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("returns no defaults when GITHUB_REPOSITORY is unset", () => {
expect(deriveDefaultTags({})).toEqual({});
expect(mergeWithDefaultTags(undefined, {})).toBeUndefined();
});

it("auto-captures `repository` from GITHUB_REPOSITORY, sanitizing '/'", () => {
expect(deriveDefaultTags({ GITHUB_REPOSITORY: "lukekim/home" })).toEqual({
repository: "lukekim_home",
});
});

it("merges defaults under user-supplied tags (user wins on conflict)", () => {
const merged = mergeWithDefaultTags(
{ repository: "explicit", env: "prod" },
{ GITHUB_REPOSITORY: "lukekim/home" },
);
expect(merged).toEqual({ repository: "explicit", env: "prod" });
});

it("returns just the defaults when the user provided no tags", () => {
expect(mergeWithDefaultTags(undefined, { GITHUB_REPOSITORY: "spicehq/x" })).toEqual({
repository: "spicehq_x",
});
});
});
76 changes: 38 additions & 38 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.

7 changes: 3 additions & 4 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ProbeResult } from "./runtime.js";
import { RuntimeClient } from "./runtime.js";
import { parseSecrets } from "./secrets.js";
import { readSpicepod } from "./spicepod.js";
import { parseTags } from "./tags.js";
import { mergeWithDefaultTags, parseTags } from "./tags.js";
import type {
App,
CreateDeploymentBody,
Expand Down Expand Up @@ -107,7 +107,7 @@ async function resolveApp(api: SpiceApiClient, inputs: ActionInputs): Promise<Ap
);
}

const tags = parseTags(inputs.tagsRaw);
const tags = mergeWithDefaultTags(parseTags(inputs.tagsRaw));
core.info(`App "${inputs.appName}" not found; creating in region "${inputs.region}".`);
return api.createApp({
name: inputs.appName,
Expand All @@ -132,15 +132,14 @@ async function maybeUpdateAppMetadata(
app: App,
inputs: ActionInputs,
): Promise<void> {
const tags = parseTags(inputs.tagsRaw);
const tags = mergeWithDefaultTags(parseTags(inputs.tagsRaw));
if (!tags) return;

Comment on lines +135 to 137
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)}`);
await api.updateApp(app.id, update);
app.tags = merged;
core.endGroup();
}

Expand Down
45 changes: 45 additions & 0 deletions src/tags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { InputValidationError } from "./errors.js";

const TAG_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_./-]{0,62}$/;
// Match the Spice Cloud API's tag-value rule: alphanumeric plus `_@-`.
const TAG_VALUE_PATTERN = /^[A-Za-z0-9_@-]*$/;
const MAX_VALUE_LENGTH = 256;

/**
Expand Down Expand Up @@ -107,4 +109,47 @@ function validateValue(key: string, value: string): void {
`tags: value for "${key}" exceeds ${MAX_VALUE_LENGTH} characters.`,
);
}
if (!TAG_VALUE_PATTERN.test(value)) {
throw new InputValidationError(
`tags: value for "${key}" must contain only letters, numbers, and "_@-" (got "${value}").`,
);
}
}

/**
* Replace any character not allowed by the Spice Cloud tag-value rule with `_`,
* and truncate to the API's max length. Used when auto-deriving tag values
* from GitHub context (e.g. `lukekim/home` → `lukekim_home`).
*/
export function sanitizeTagValue(value: string): string {
return value.replace(/[^A-Za-z0-9_@-]/g, "_").slice(0, MAX_VALUE_LENGTH);
Comment on lines +124 to +125
}

/**
* Build the tag map of GitHub-context-derived defaults the action sets when the
* user does not explicitly override them. Today this is just `repository` from
* `GITHUB_REPOSITORY`, sanitized to fit the API's tag-value rule.
*/
export function deriveDefaultTags(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
const defaults: Record<string, string> = {};
if (env.GITHUB_REPOSITORY) {
defaults.repository = sanitizeTagValue(env.GITHUB_REPOSITORY);
}
return defaults;
}

/**
* Merge user-supplied tags on top of GitHub-context-derived defaults. Returns
* `undefined` when neither side has any tags so the caller can skip the API
* round-trip entirely.
*/
export function mergeWithDefaultTags(
userTags: Record<string, string> | undefined,
env: NodeJS.ProcessEnv = process.env,
): Record<string, string> | undefined {
const defaults = deriveDefaultTags(env);
const hasDefaults = Object.keys(defaults).length > 0;
const hasUser = userTags !== undefined && Object.keys(userTags).length > 0;
if (!hasDefaults && !hasUser) return undefined;
return { ...defaults, ...(userTags ?? {}) };
}