Skip to content

Commit e6835ab

Browse files
committed
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 8342c72 commit e6835ab

6 files changed

Lines changed: 112 additions & 56 deletions

File tree

__tests__/secrets.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ describe("parseSecrets", () => {
3636
]);
3737
});
3838

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+
});
46+
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+
3957
it("ignores blank lines and #-comment lines", () => {
4058
expect(parseSecrets("\n# header\nFOO: bar\n # indented\nBAZ: qux")).toEqual([
4159
{ name: "FOO", value: "bar" },

dist/index.js

Lines changed: 26 additions & 26 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/api.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,24 +120,44 @@ export class SpiceApiClient {
120120
);
121121
}
122122

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+
}
123135
const durationMs = Date.now() - startMs;
124136
core.info(`${method} ${path}${res.status} ${res.statusText} (${durationMs}ms)`);
125137

126138
if (res.status === 204) {
127139
return undefined as T;
128140
}
129141

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+
130154
if (res.ok) {
131-
if (res.status === 202 || res.status === 201 || res.status === 200) {
132-
const text = await res.text();
133-
if (!text) return undefined as T;
134-
try {
135-
return JSON.parse(text) as T;
136-
} catch {
137-
return text as unknown as T;
138-
}
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;
139160
}
140-
return (await res.json()) as T;
141161
}
142162

143163
if (RETRYABLE_STATUSES.has(res.status) && attempt < this.maxAttempts) {
@@ -149,7 +169,7 @@ export class SpiceApiClient {
149169
continue;
150170
}
151171

152-
const errorBody = await readErrorBody(res);
172+
const errorBody = parseErrorBody(bodyText);
153173
throw new SpiceApiError(
154174
formatApiError(method, path, res, errorBody),
155175
res.status,
@@ -176,8 +196,7 @@ export class SpiceApiClient {
176196
}
177197
}
178198

179-
async function readErrorBody(res: Response): Promise<ApiErrorBody | string | undefined> {
180-
const text = await res.text().catch(() => "");
199+
function parseErrorBody(text: string): ApiErrorBody | string | undefined {
181200
if (!text) return undefined;
182201
try {
183202
return JSON.parse(text) as ApiErrorBody;

src/main.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,17 @@ async function writeSummary(args: {
124124
? "**failed**"
125125
: `_${deployment.status}_`;
126126

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

134139
const summary = core.summary.addHeading("Spice Cloud Deploy", 2).addTable([
135140
[

src/secrets.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ export function parseSecrets(raw: string | undefined): ParsedSecret[] {
3030
const trimmed = raw.trim();
3131
if (!trimmed) return [];
3232

33-
const entries = trimmed.startsWith("{") ? parseJsonObject(trimmed) : parseBlockMap(trimmed);
33+
// For the JSON-form check we use the trimmed view, but the block-map parser
34+
// needs the original `raw` so trailing whitespace on the last line (which
35+
// could be part of a secret's value) is preserved.
36+
const entries = trimmed.startsWith("{") ? parseJsonObject(trimmed) : parseBlockMap(raw);
3437
for (const { value } of entries) {
3538
if (value !== "") core.setSecret(value);
3639
}
@@ -83,7 +86,7 @@ function parseBlockMap(raw: string): ParsedSecret[] {
8386
}
8487

8588
const name = line.slice(0, colon).trim();
86-
const value = stripQuotes(line.slice(colon + 1).trim());
89+
const value = parseValue(line.slice(colon + 1));
8790

8891
validateName(name, `secrets line ${i + 1}`);
8992
if (seen.has(name)) {
@@ -95,14 +98,25 @@ function parseBlockMap(raw: string): ParsedSecret[] {
9598
return out;
9699
}
97100

98-
function stripQuotes(value: string): string {
99-
if (value.length < 2) return value;
100-
const first = value.charAt(0);
101-
const last = value.charAt(value.length - 1);
102-
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
103-
return value.slice(1, -1);
101+
/**
102+
* Extract a secret value from the substring after the first `:` separator.
103+
*
104+
* Strips leading whitespace (the canonical YAML `KEY: VALUE` separator) and
105+
* a matching pair of outer quotes if present. Trailing whitespace and inner
106+
* whitespace are preserved verbatim, so a secret value can contain any
107+
* characters; users that need leading or trailing whitespace must wrap the
108+
* value in matching single or double quotes.
109+
*/
110+
function parseValue(rest: string): string {
111+
const ltrimmed = rest.replace(/^\s+/, "");
112+
if (ltrimmed.length >= 2) {
113+
const first = ltrimmed.charAt(0);
114+
const last = ltrimmed.charAt(ltrimmed.length - 1);
115+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
116+
return ltrimmed.slice(1, -1);
117+
}
104118
}
105-
return value;
119+
return ltrimmed;
106120
}
107121

108122
function validateName(name: string, context: string): void {

0 commit comments

Comments
 (0)