Skip to content

Commit 399858d

Browse files
authored
fix: tighten tag-value validation; auto-capture repository from GITHUB_REPOSITORY (#12)
Bug - The Spice Cloud API rejects tag values that contain anything besides alphanumerics and `_@-`, but the action only validated value length. A user passing `tags: |\n repository: owner/repo` would hit the API with a 400 Bad Request mid-deploy. Add a local value pattern check matching the API rule so the failure surfaces locally with a clear error before any state-changing call. Auto-capture - When `GITHUB_REPOSITORY` is set (always true on GitHub-hosted runners), the action now auto-adds a `repository` tag derived from that env var, rewriting `/` to `_` so the value passes API validation. User-supplied `repository:` in the `tags` input still wins on conflict. - New `sanitizeTagValue`, `deriveDefaultTags`, and `mergeWithDefaultTags` helpers in `src/tags.ts`, all unit-tested. Other - Drop the post-update mutation of `app.tags` in `maybeUpdateAppMetadata`. Nothing downstream reads it, and the mutation made shared `App` objects in tests leak state across cases.
1 parent df24d42 commit 399858d

8 files changed

Lines changed: 214 additions & 58 deletions

File tree

CHANGELOG.md

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

77
## [Unreleased]
88

9+
### Added
10+
- 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.
11+
912
### Changed
13+
- `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.
1014
- 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.
1115

1216
## [1.0.0] — 2026-05-02

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
8282
| `create-app-if-missing` | no | `false` | Create the app if it doesn't exist (requires `app-name` and `region`). |
8383
| `region` | conditional | — | Spice Cloud region (e.g. `us-east-1`, `us-west-2`). Required for new apps. |
8484
| `visibility` | no | `private` | `public` or `private` — only used on app creation. |
85-
| `tags` | no | — | YAML or JSON map of tag key/value pairs. Merged into existing app tags. |
85+
| `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. |
8686
| `spicepod` | no | `spicepod.yaml` | Path to the Spicepod manifest. Pushed to the app before deploy when present. |
8787
| `working-directory` | no | `.` | Working directory used to resolve relative paths. |
8888
| `image-tag` | no | — | Override the runtime image tag (e.g. `1.5.0-models`). |
@@ -143,6 +143,10 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
143143
```
144144

145145
> `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.
146+
>
147+
> **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.
148+
>
149+
> **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.
146150

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

__tests__/deploy.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

33
vi.mock("@actions/core", () => ({
44
debug: vi.fn(),
@@ -10,6 +10,15 @@ vi.mock("@actions/core", () => ({
1010
warning: vi.fn(),
1111
}));
1212

13+
const ORIGINAL_GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
14+
beforeEach(() => {
15+
delete process.env.GITHUB_REPOSITORY;
16+
});
17+
afterEach(() => {
18+
if (ORIGINAL_GITHUB_REPOSITORY === undefined) delete process.env.GITHUB_REPOSITORY;
19+
else process.env.GITHUB_REPOSITORY = ORIGINAL_GITHUB_REPOSITORY;
20+
});
21+
1322
import type { SpiceApiClient } from "../src/api.js";
1423
import { resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
1524
import {
@@ -202,6 +211,32 @@ describe("runDeploy", () => {
202211
expect(updateApp).toHaveBeenCalledWith(42, { tags: { existing: "1", environment: "prod" } });
203212
});
204213

214+
it("auto-captures `repository` from GITHUB_REPOSITORY when no user tag overrides it", async () => {
215+
process.env.GITHUB_REPOSITORY = "lukekim/home";
216+
const listApps = vi.fn().mockResolvedValue([sampleApp]);
217+
const updateApp = vi.fn().mockResolvedValue(sampleApp);
218+
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
219+
const api = fakeApi({ listApps, updateApp, createDeployment });
220+
221+
await runDeploy(api, { ...baseInputs, tagsRaw: "environment: prod" });
222+
223+
expect(updateApp).toHaveBeenCalledWith(42, {
224+
tags: { repository: "lukekim_home", environment: "prod" },
225+
});
226+
});
227+
228+
it("user-supplied `repository` tag wins over the auto-captured default", async () => {
229+
process.env.GITHUB_REPOSITORY = "lukekim/home";
230+
const listApps = vi.fn().mockResolvedValue([sampleApp]);
231+
const updateApp = vi.fn().mockResolvedValue(sampleApp);
232+
const createDeployment = vi.fn().mockResolvedValue(queuedDeployment);
233+
const api = fakeApi({ listApps, updateApp, createDeployment });
234+
235+
await runDeploy(api, { ...baseInputs, tagsRaw: "repository: explicit-name" });
236+
237+
expect(updateApp).toHaveBeenCalledWith(42, { tags: { repository: "explicit-name" } });
238+
});
239+
205240
it("runs probes and fails when one fails (fail-on-test-error=true)", async () => {
206241
const listApps = vi.fn().mockResolvedValue([sampleApp]);
207242
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);

__tests__/tags.test.ts

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { describe, expect, it } from "vitest";
2-
import { parseTags } from "../src/tags.js";
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
deriveDefaultTags,
4+
mergeWithDefaultTags,
5+
parseTags,
6+
sanitizeTagValue,
7+
} from "../src/tags.js";
38

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

2631
it("strips matching single or double quotes around values", () => {
27-
expect(parseTags("a: \"with spaces\"\nb: 'single quoted'")).toEqual({
28-
a: "with spaces",
29-
b: "single quoted",
30-
});
31-
});
32-
33-
it("preserves a single colon inside the value", () => {
34-
expect(parseTags("url: https://example.com:8080/x")).toEqual({
35-
url: "https://example.com:8080/x",
32+
expect(parseTags("a: \"alpha-num_v1\"\nb: 'beta-2'")).toEqual({
33+
a: "alpha-num_v1",
34+
b: "beta-2",
3635
});
3736
});
3837

@@ -68,6 +67,21 @@ describe("parseTags", () => {
6867
const long = "x".repeat(257);
6968
expect(() => parseTags(`big: ${long}`)).toThrow(/exceeds 256/);
7069
});
70+
71+
it("rejects values with characters not allowed by the API", () => {
72+
// The Spice Cloud API allows only alphanumeric plus `_@-` in tag values.
73+
expect(() => parseTags("repo: lukekim/home")).toThrow(/letters, numbers, and "_@-"/);
74+
expect(() => parseTags('env: "prod env"')).toThrow(/letters, numbers, and "_@-"/);
75+
});
76+
77+
it("accepts allowed value characters (alphanumeric, underscore, at-sign, hyphen)", () => {
78+
expect(parseTags("a: foo_bar\nb: foo@bar\nc: foo-bar\nd: AB123")).toEqual({
79+
a: "foo_bar",
80+
b: "foo@bar",
81+
c: "foo-bar",
82+
d: "AB123",
83+
});
84+
});
7185
});
7286

7387
describe("JSON object form", () => {
@@ -106,3 +120,58 @@ describe("parseTags", () => {
106120
});
107121
});
108122
});
123+
124+
describe("sanitizeTagValue", () => {
125+
it("replaces disallowed characters with underscore", () => {
126+
expect(sanitizeTagValue("lukekim/home")).toBe("lukekim_home");
127+
expect(sanitizeTagValue("foo bar")).toBe("foo_bar");
128+
expect(sanitizeTagValue("a/b/c")).toBe("a_b_c");
129+
});
130+
131+
it("leaves already-valid characters alone", () => {
132+
expect(sanitizeTagValue("alpha-num_123@v1")).toBe("alpha-num_123@v1");
133+
});
134+
135+
it("truncates to 256 characters", () => {
136+
const big = "a".repeat(300);
137+
expect(sanitizeTagValue(big)).toHaveLength(256);
138+
});
139+
});
140+
141+
describe("deriveDefaultTags / mergeWithDefaultTags", () => {
142+
const ORIGINAL = process.env.GITHUB_REPOSITORY;
143+
144+
beforeEach(() => {
145+
delete process.env.GITHUB_REPOSITORY;
146+
});
147+
148+
afterEach(() => {
149+
if (ORIGINAL === undefined) delete process.env.GITHUB_REPOSITORY;
150+
else process.env.GITHUB_REPOSITORY = ORIGINAL;
151+
});
152+
153+
it("returns no defaults when GITHUB_REPOSITORY is unset", () => {
154+
expect(deriveDefaultTags({})).toEqual({});
155+
expect(mergeWithDefaultTags(undefined, {})).toBeUndefined();
156+
});
157+
158+
it("auto-captures `repository` from GITHUB_REPOSITORY, sanitizing '/'", () => {
159+
expect(deriveDefaultTags({ GITHUB_REPOSITORY: "lukekim/home" })).toEqual({
160+
repository: "lukekim_home",
161+
});
162+
});
163+
164+
it("merges defaults under user-supplied tags (user wins on conflict)", () => {
165+
const merged = mergeWithDefaultTags(
166+
{ repository: "explicit", env: "prod" },
167+
{ GITHUB_REPOSITORY: "lukekim/home" },
168+
);
169+
expect(merged).toEqual({ repository: "explicit", env: "prod" });
170+
});
171+
172+
it("returns just the defaults when the user provided no tags", () => {
173+
expect(mergeWithDefaultTags(undefined, { GITHUB_REPOSITORY: "spicehq/x" })).toEqual({
174+
repository: "spicehq_x",
175+
});
176+
});
177+
});

dist/index.js

Lines changed: 38 additions & 38 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.

src/deploy.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { ProbeResult } from "./runtime.js";
88
import { RuntimeClient } from "./runtime.js";
99
import { parseSecrets } from "./secrets.js";
1010
import { readSpicepod } from "./spicepod.js";
11-
import { parseTags } from "./tags.js";
11+
import { mergeWithDefaultTags, parseTags } from "./tags.js";
1212
import type {
1313
App,
1414
CreateDeploymentBody,
@@ -107,7 +107,7 @@ async function resolveApp(api: SpiceApiClient, inputs: ActionInputs): Promise<Ap
107107
);
108108
}
109109

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

138138
const merged = { ...(app.tags ?? {}), ...tags };
139139
const update: UpdateAppBody = { tags: merged };
140140
core.startGroup("Update app tags");
141141
core.info(`PUT /v1/apps/${app.id} tags=${JSON.stringify(merged)}`);
142142
await api.updateApp(app.id, update);
143-
app.tags = merged;
144143
core.endGroup();
145144
}
146145

src/tags.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { InputValidationError } from "./errors.js";
22

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

68
/**
@@ -107,4 +109,47 @@ function validateValue(key: string, value: string): void {
107109
`tags: value for "${key}" exceeds ${MAX_VALUE_LENGTH} characters.`,
108110
);
109111
}
112+
if (!TAG_VALUE_PATTERN.test(value)) {
113+
throw new InputValidationError(
114+
`tags: value for "${key}" must contain only letters, numbers, and "_@-" (got "${value}").`,
115+
);
116+
}
117+
}
118+
119+
/**
120+
* Replace any character not allowed by the Spice Cloud tag-value rule with `_`,
121+
* and truncate to the API's max length. Used when auto-deriving tag values
122+
* from GitHub context (e.g. `lukekim/home` → `lukekim_home`).
123+
*/
124+
export function sanitizeTagValue(value: string): string {
125+
return value.replace(/[^A-Za-z0-9_@-]/g, "_").slice(0, MAX_VALUE_LENGTH);
126+
}
127+
128+
/**
129+
* Build the tag map of GitHub-context-derived defaults the action sets when the
130+
* user does not explicitly override them. Today this is just `repository` from
131+
* `GITHUB_REPOSITORY`, sanitized to fit the API's tag-value rule.
132+
*/
133+
export function deriveDefaultTags(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
134+
const defaults: Record<string, string> = {};
135+
if (env.GITHUB_REPOSITORY) {
136+
defaults.repository = sanitizeTagValue(env.GITHUB_REPOSITORY);
137+
}
138+
return defaults;
139+
}
140+
141+
/**
142+
* Merge user-supplied tags on top of GitHub-context-derived defaults. Returns
143+
* `undefined` when neither side has any tags so the caller can skip the API
144+
* round-trip entirely.
145+
*/
146+
export function mergeWithDefaultTags(
147+
userTags: Record<string, string> | undefined,
148+
env: NodeJS.ProcessEnv = process.env,
149+
): Record<string, string> | undefined {
150+
const defaults = deriveDefaultTags(env);
151+
const hasDefaults = Object.keys(defaults).length > 0;
152+
const hasUser = userTags !== undefined && Object.keys(userTags).length > 0;
153+
if (!hasDefaults && !hasUser) return undefined;
154+
return { ...defaults, ...(userTags ?? {}) };
110155
}

0 commit comments

Comments
 (0)