Skip to content
Draft
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 docs/schema/yaml/1.0.0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -794,4 +794,8 @@ services:
# @param services.ignoreFiles
ignoreFiles:
# @param services.ignoreFiles[]
- ''
# @param services.includeFiles
includeFiles:
# @param services.includeFiles[]
- ''
87 changes: 86 additions & 1 deletion src/server/lib/__tests__/pushIgnoreFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
hasLifecycleConfigChange,
normalizeGithubPath,
normalizeIgnoreFiles,
normalizeIncludeFiles,
shouldSkipPushDeploy,
} from '../pushIgnoreFiles';

Expand Down Expand Up @@ -145,6 +146,87 @@ describe('pushIgnoreFiles', () => {
});
});

test('lists the services whose own policy ignores every changed file when others still deploy', () => {
expect(
shouldSkipPushDeploy({
changedFiles: ['apps/next-web/src/page.tsx'],
servicePolicies: [
{ serviceName: 'next-web', ignoreFiles: ['docs/**'] },
{ serviceName: 'psp-web', ignoreFiles: ['apps/next-web/**'] },
{ serviceName: 'rxsaver-web', ignoreFiles: ['apps/next-web/**'] },
],
})
).toEqual({
shouldSkip: false,
reason: 'file_not_ignored',
serviceName: 'next-web',
filePath: 'apps/next-web/src/page.tsx',
skipServices: ['psp-web', 'rxsaver-web'],
});
});

test('keeps a service without a policy deploying while skipping services that ignore the change', () => {
expect(
shouldSkipPushDeploy({
changedFiles: ['apps/next-web/src/page.tsx'],
servicePolicies: [
{ serviceName: 'worker', ignoreFiles: [] },
{ serviceName: 'psp-web', ignoreFiles: ['apps/next-web/**'] },
],
})
).toEqual({
shouldSkip: false,
reason: 'missing_service_policy',
serviceName: 'worker',
skipServices: ['psp-web'],
});
});

test('treats files outside includeFiles as ignored, with ignoreFiles still applied inside them', () => {
const psp = {
serviceName: 'psp-web',
ignoreFiles: ['**/*.spec.ts'],
includeFiles: ['apps/psp/**', 'packages/shared/**', 'pnpm-lock.yaml'],
};

expect(
shouldSkipPushDeploy({
changedFiles: ['packages/accounts/src/a.ts', 'packages/shared/src/a.spec.ts'],
servicePolicies: [psp],
})
).toEqual({ shouldSkip: true, reason: 'all_changed_files_ignored' });
expect(shouldSkipPushDeploy({ changedFiles: ['pnpm-lock.yaml'], servicePolicies: [psp] })).toEqual({
shouldSkip: false,
reason: 'file_not_ignored',
serviceName: 'psp-web',
filePath: 'pnpm-lock.yaml',
});
});

test('builds an includeFiles-only service policy', () => {
const config = {
version: '1.0.0',
environment: {},
services: [{ name: 'psp-web', includeFiles: ['apps/psp/**'] }],
} as any;

expect(getServicePushIgnorePolicy(config, 'psp-web')).toEqual({
serviceName: 'psp-web',
ignoreFiles: [],
includeFiles: ['apps/psp/**'],
});
});

test('validates includeFiles with a larger pattern cap', () => {
expect(normalizeIncludeFiles(Array.from({ length: 200 }, (_value, index) => `packages/${index}/**`))).toHaveLength(
200
);
expect(() => normalizeIncludeFiles(Array.from({ length: 201 }, (_value, index) => `p/${index}/**`))).toThrow(
'includeFiles has too many patterns'
);
expect(() => normalizeIncludeFiles(['../outside/**'])).toThrow('includeFiles patterns cannot traverse');
});

test('detects lifecycle config changes by new path', () => {
expect(hasLifecycleConfigChange(['docs/readme.md', 'lifecycle.yaml'])).toBe(true);
expect(hasLifecycleConfigChange(['docs/lifecycle.yml'])).toBe(false);
Expand All @@ -154,7 +236,10 @@ describe('pushIgnoreFiles', () => {
expect(
shouldSkipPushDeploy({
changedFiles: ['./lifecycle.yml'],
servicePolicies: [{ serviceName: 'api', ignoreFiles: ['**/*'] }],
servicePolicies: [
{ serviceName: 'api', ignoreFiles: ['**/*'] },
{ serviceName: 'web', ignoreFiles: [], includeFiles: ['apps/web/**'] },
],
})
).toEqual({
shouldSkip: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jest.mock('server/models/Build', () => ({
const deployStamp: any = {
patch: jest.fn(() => deployStamp),
where: jest.fn(() => deployStamp),
whereNotIn: jest.fn(() => deployStamp),
select: jest.fn(() => deployStamp),
join: jest.fn(() => deployStamp),
whereIn: jest.fn(() => deployStamp),
whereRaw: jest.fn(() => deployStamp),
then: (resolve: (value: number) => void, reject: (reason: unknown) => void) =>
Promise.resolve(2).then(resolve, reject),
};
Expand Down Expand Up @@ -506,6 +511,51 @@ describe('deploymentIntentDeployFilter', () => {
});

describe('acceptDeploymentIntent row stamping', () => {
const skipIntent = {
type: 'source' as const,
requestId: 'request-skip',
target: 'repository' as const,
githubRepositoryId: 123,
branch: 'main',
sha: 'commit-b',
skipServices: ['psp-web'],
};

it('leaves settled, healthy rows of skipped services out of the stamp', async () => {
(Build.query as jest.Mock)
.mockReturnValueOnce(readQuery({ desiredGeneration: '1', acceptedRefs: {} }))
.mockReturnValueOnce(writeQuery());

await acceptDeploymentIntent(42, skipIntent);

expect(deployStamp.whereNotIn).toHaveBeenCalledWith('id', deployStamp);
expect(deployStamp.join).toHaveBeenCalledWith('deployables', 'deployables.id', 'deploys.deployableId');
expect(deployStamp.where).toHaveBeenCalledWith('deploys.buildId', 42);
expect(deployStamp.whereIn).toHaveBeenCalledWith('deployables.name', ['psp-web']);
expect(deployStamp.whereIn).toHaveBeenCalledWith('deploys.status', [
DeployStatus.READY,
DeployStatus.DEPLOYED,
DeployStatus.BUILT,
]);
expect(deployStamp.whereRaw).toHaveBeenCalledWith('?? >= ??', [
'deploys.observedGeneration',
'deploys.desiredGeneration',
]);
});

it('stamps every selected row when no service is skipped or the push selects the whole environment', async () => {
(Build.query as jest.Mock)
.mockReturnValueOnce(readQuery({ desiredGeneration: '1', acceptedRefs: {} }))
.mockReturnValueOnce(writeQuery())
.mockReturnValueOnce(readQuery({ desiredGeneration: '2', acceptedRefs: {} }))
.mockReturnValueOnce(writeQuery());

await acceptDeploymentIntent(42, { ...skipIntent, skipServices: [] });
await acceptDeploymentIntent(42, { ...skipIntent, requestId: 'request-all', target: 'all' });

expect(deployStamp.whereNotIn).not.toHaveBeenCalled();
});

it('does not touch Deploy rows when a redelivery is deduplicated', async () => {
const existing: AcceptedDeploymentRefs = {
'source:123:main': {
Expand Down
42 changes: 37 additions & 5 deletions src/server/lib/deploymentReconciliation/mailbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import type { PartialModelObject, Transaction } from 'objection';
import type { PartialModelObject, QueryBuilder, Transaction } from 'objection';
import type { JobDataWithContext } from 'server/lib/logger';
import Build from 'server/models/Build';
import Deploy from 'server/models/Deploy';
Expand All @@ -30,6 +30,8 @@ export interface SourcePushIntent {
sha: string;
/** Delivered push predecessor; used to recognize a temporarily stale branch-head read. */
beforeSha?: string;
/** Services whose push-ignore policy matched every changed file; their healthy rows keep the current image. */
skipServices?: string[];
}

export interface RepositoryRedeployIntent {
Expand Down Expand Up @@ -102,6 +104,31 @@ export function deploymentIntentDeployFilter(intent: DeploymentIntent): {
return { githubRepositoryId: intent.githubRepositoryId };
}

const SKIPPABLE_DEPLOY_STATUSES = [DeployStatus.READY, DeployStatus.DEPLOYED, DeployStatus.BUILT];

/**
* Leaves out rows of the intent's skipped services that are settled and healthy. A row that is still pending
* (including one a replaced intent stamped), failed, or never deployed stays selected.
*/
export function excludeSkippedServiceDeploys<QB extends QueryBuilder<Deploy, any>>(
query: QB,
buildId: number,
intent: DeploymentIntent,
trx?: Transaction
): QB {
if (intent.type !== 'source' || intent.target !== 'repository' || !intent.skipServices?.length) return query;
return query.whereNotIn(
'id',
Deploy.query(trx)
.select('deploys.id')
.join('deployables', 'deployables.id', 'deploys.deployableId')
.where('deploys.buildId', buildId)
.whereIn('deployables.name', intent.skipServices)
.whereIn('deploys.status', SKIPPABLE_DEPLOY_STATUSES)
.whereRaw('?? >= ??', ['deploys.observedGeneration', 'deploys.desiredGeneration'])
);
}

/** A stamped row is unowned and queued: the older run can no longer write to it, and its old status is not current. */
export function deployStampForGeneration(generation: number): PartialModelObject<Deploy> {
const stamp = { desiredGeneration: generation, runUUID: null, status: DeployStatus.QUEUED, statusMessage: null };
Expand All @@ -116,10 +143,15 @@ export async function stampDeploysForIntent(
intent: DeploymentIntent,
generation: number
): Promise<number> {
return Deploy.query(trx)
.patch(deployStampForGeneration(generation))
.where({ buildId, active: true, ...deploymentIntentDeployFilter(intent) })
.where('desiredGeneration', '<', generation);
return excludeSkippedServiceDeploys(
Deploy.query(trx)
.patch(deployStampForGeneration(generation))
.where({ buildId, active: true, ...deploymentIntentDeployFilter(intent) })
.where('desiredGeneration', '<', generation),
buildId,
intent,
trx
);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/server/lib/jsonschema/schemas/1.0.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,12 @@
"items": {
"type": "string"
}
},
"includeFiles": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
Expand Down
Loading