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
20 changes: 20 additions & 0 deletions packages/targets/deploy-netlify/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ describe('Netlify deployment target', () => {
});
});

it('rejects invalid Netlify config before plan or CLI work', async () => {
await expect(adapter.build(fakeBuildContext() as any, {
dir: ' ',
})).rejects.toThrow('deploy-netlify requires dir');

await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
dryRun: true,
}) as any, {
siteId: 'site/123',
})).rejects.toThrow('siteId must be a single URL path segment');

await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
dryRun: true,
}) as any, {
message: ' ',
})).rejects.toThrow('deploy-netlify requires message');
});

it('requires a vault token for real deployments', async () => {
await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
Expand Down
31 changes: 31 additions & 0 deletions packages/targets/deploy-netlify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,41 @@ interface Config {
message?: string;
}

function requireText(value: string | undefined, field: string): string {
const text = value?.trim();
if (!text) throw new Error(`deploy-netlify requires ${field}`);
return text;
}

function optionalText(value: string | undefined, field: string): string | undefined {
return value === undefined ? undefined : requireText(value, field);
}

function optionalSiteId(value: string | undefined): string | undefined {
const id = optionalText(value, 'siteId');
if (id && /[\\/?#\x00-\x1F\x7F]/.test(id)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The siteId regex does not reject embedded spaces. A value like 'my site' trims cleanly, passes the regex, and is accepted as valid. It is a semantically invalid Netlify site ID that silently slips through the stated "single URL path segment" guard. Adding \s to the character class would close the gap.

Suggested change
if (id && /[\\/?#\x00-\x1F\x7F]/.test(id)) {
if (id && /[\s\\/?#\x00-\x1F\x7F]/.test(id)) {

throw new Error('deploy-netlify siteId must be a single URL path segment');
}
return id;
}

function normalizedConfig(config: Config): Config {
return {
...config,
siteId: optionalSiteId(config.siteId),
dir: optionalText(config.dir, 'dir'),
message: optionalText(config.message, 'message'),
};
}

function deployDir(ctx: { projectDir: string }, config: Config): string {
config = normalizedConfig(config);
if (!config.dir) return ctx.projectDir;
return isAbsolute(config.dir) ? config.dir : join(ctx.projectDir, config.dir);
}

function deployArgs(ctx: { channel: string; projectDir: string; version: string }, config: Config, token?: string): string[] {
config = normalizedConfig(config);
const prod = config.prod ?? ctx.channel === 'stable';
const args = ['--yes', 'netlify-cli', 'deploy', '--json', '--dir', deployDir(ctx, config)];
if (prod) args.push('--prod');
Expand All @@ -26,6 +55,7 @@ function deployArgs(ctx: { channel: string; projectDir: string; version: string
}
Comment on lines 45 to 55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant normalizedConfig calls in inner helpers

deployDir and deployArgs both call normalizedConfig internally, and both are called from renderPlan and ship after those callers have already normalized the config. This means normalizedConfig — and its validation logic — runs 3–4 times for a single ship or renderPlan invocation. While idempotent today, any future validator with observable side-effects (e.g., logging, counters) would fire multiple times unexpectedly. Consider removing the normalizedConfig calls from deployDir and deployArgs and relying solely on callers to normalize before calling these helpers.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


function renderPlan(ctx: { channel: string; projectDir: string; version: string }, config: Config): string {
config = normalizedConfig(config);
const prod = config.prod ?? ctx.channel === 'stable';
return `${JSON.stringify({
provider: 'netlify',
Expand Down Expand Up @@ -66,6 +96,7 @@ export default defineTarget<Config>({
return { artifact: planPath };
},
async ship(ctx, config) {
config = normalizedConfig(config);
const prod = config.prod ?? ctx.channel === 'stable';
ctx.log(`netlify deploy ${prod ? '--prod' : ''} · site=${config.siteId ?? 'linked'}`);
if (ctx.dryRun) return { id: 'dry-run', meta: { command: ['npx', ...deployArgs(ctx, config)] } };
Expand Down
Loading