From 1c53e00e001a5a06909142915a6bba387ae004e8 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:02:13 +0200 Subject: [PATCH 1/7] Prune older function versions This replaces https://github.com/claygregory/serverless-prune-plugin and makes it a first class feature. --- docs/guides/functions.md | 13 +- docs/guides/serverless.yml.md | 4 + lib/plugins/aws/provider.js | 14 ++ lib/plugins/aws/prune.js | 232 ++++++++++++++++++++++++ lib/plugins/index.js | 1 + test/unit/lib/plugins/aws/prune.test.js | 200 ++++++++++++++++++++ types/index.d.ts | 6 + 7 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 lib/plugins/aws/prune.js create mode 100644 test/unit/lib/plugins/aws/prune.test.js diff --git a/docs/guides/functions.md b/docs/guides/functions.md index 172c1248e..84b7266b1 100644 --- a/docs/guides/functions.md +++ b/docs/guides/functions.md @@ -722,15 +722,24 @@ functions: By default, osls creates function versions for every deploy. This behavior is optional, and can be turned off in cases where you don't invoke past versions by their qualifier. If you would like to do this, you can invoke your functions as `arn:aws:lambda:....:function/myFunc:3` to invoke version 3 for example. -Versions are not cleaned up by osls, so make sure you use a plugin or other tool to prune sufficiently old versions. osls can't clean up versions because it doesn't have information about whether older versions are invoked or not. This feature adds to the number of total stack outputs and resources because a function version is a separate resource from the function it refers to. +Older versions are not removed automatically unless you enable `provider.pruneFunctionVersions`. When enabled, osls deletes function and layer versions beyond the configured limit after each deploy, while keeping versions referenced by aliases. Aliased versions are never deleted. -To turn off function versioning, set the provider-level option `versionFunctions`. +To turn off function versioning, set the provider-level option `versionFunctions`. `pruneFunctionVersions` cannot be used when `versionFunctions` is `false`. ```yml provider: versionFunctions: false ``` +Enable automatic pruning after deploy: + +```yml +provider: + pruneFunctionVersions: true # keeps 10 versions (default) + # pruneFunctionVersions: + # number: 20 +``` + ## Dead Letter Queue (DLQ) When AWS lambda functions fail, they are [retried](http://docs.aws.amazon.com/lambda/latest/dg/retries-on-errors.html). If the retries also fail, AWS has a feature to send information about the failed request to a SNS topic or SQS queue, called the [Dead Letter Queue](http://docs.aws.amazon.com/lambda/latest/dg/dlq.html), which you can use to track and diagnose and react to lambda failures. diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index 17e7f0eb2..28a07ba0f 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -123,6 +123,10 @@ provider: kmsKeyArn: arn:aws:kms:us-east-1:XXXXXX:key/some-hash # Use function versioning (enabled by default) versionFunctions: false + # After deploy, delete older Lambda function and layer versions (disabled by default) + pruneFunctionVersions: true # keeps 10 versions + # pruneFunctionVersions: + # number: 20 # explicit number of versions to keep # Processor architecture: 'x86_64' or 'arm64' via Graviton2 (default: x86_64) architecture: x86_64 ``` diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index 5a817a6d5..ccc2edbc5 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -562,6 +562,19 @@ class AwsProvider { awsLambdaTimeout: { type: 'integer', minimum: 1, maximum: 900 }, awsLambdaTracing: { anyOf: [{ enum: ['Active', 'PassThrough'] }, { type: 'boolean' }] }, awsLambdaVersioning: { type: 'boolean' }, + awsPruneFunctionVersions: { + anyOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + number: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + required: ['number'], + }, + ], + }, awsLambdaVpcConfig: { type: 'object', properties: { @@ -1237,6 +1250,7 @@ class AwsProvider { vpc: { $ref: '#/definitions/awsLambdaVpcConfig' }, vpcEndpointIds: { $ref: '#/definitions/awsCfArrayInstruction' }, versionFunctions: { $ref: '#/definitions/awsLambdaVersioning' }, + pruneFunctionVersions: { $ref: '#/definitions/awsPruneFunctionVersions' }, websocket: { type: 'object', properties: { diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js new file mode 100644 index 000000000..9736238ea --- /dev/null +++ b/lib/plugins/aws/prune.js @@ -0,0 +1,232 @@ +'use strict'; + +const { + LambdaClient, + DeleteFunctionCommand, + DeleteLayerVersionCommand, + ListAliasesCommand, + ListLayerVersionsCommand, + ListVersionsByFunctionCommand, +} = require('@aws-sdk/client-lambda'); +const ServerlessError = require('../../serverless-error'); +const { log } = require('../../utils/serverless-utils/log'); +const { + getAwsErrorMessage, + getAwsErrorStatusCode, + isLambdaResourceNotFoundError, +} = require('../../aws/aws-sdk-v3-error'); + +const DEFAULT_PRUNE_VERSIONS = 10; + +class AwsPrune { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options || {}; + this.provider = this.serverless.getProvider('aws'); + + this.hooks = { + 'before:deploy:deploy': async () => this.validateConfiguration(), + 'after:deploy:deploy': async () => this.postDeploy(), + }; + } + + resolvePruneConfig() { + const setting = this.serverless.service.provider.pruneFunctionVersions; + if (setting == null || setting === false) return null; + if (setting === true) return { number: DEFAULT_PRUNE_VERSIONS }; + if (typeof setting === 'object' && setting.number != null) { + const number = parseInt(setting.number, 10); + if (!isNaN(number) && number >= 0) return { number }; + } + return null; + } + + getNumber() { + const config = this.resolvePruneConfig(); + return config ? config.number : undefined; + } + + validateConfiguration() { + const config = this.resolvePruneConfig(); + if (!config) return; + + if (this.serverless.service.provider.versionFunctions === false) { + throw new ServerlessError( + 'provider.pruneFunctionVersions cannot be used when provider.versionFunctions is false', + 'PRUNE_INCOMPATIBLE_WITH_VERSION_FUNCTIONS' + ); + } + } + + async postDeploy() { + if (this.options.noDeploy === true) return; + + const config = this.resolvePruneConfig(); + if (!config) return; + + this.validateConfiguration(); + + await Promise.all([this.pruneFunctions(), this.pruneLayers()]); + } + + async getLambdaClient() { + this.lambdaClientPromise ||= this.provider + .getAwsSdkV3Config() + .then((lambdaConfig) => new LambdaClient(lambdaConfig)); + return this.lambdaClientPromise; + } + + async pruneLayers() { + const layerNames = this.serverless.service + .getAllLayers() + .map((key) => this.serverless.service.getLayer(key).name || key); + + for (const layerName of layerNames) { + const versions = await this.listVersionsForLayer(layerName); + if (!versions.length) continue; + + const deletionCandidates = this.selectPruneVersionsForLayer(versions); + await this.deleteVersionsForLayer(layerName, deletionCandidates); + } + + if (layerNames.length) log.notice.success('Pruning of layers complete'); + } + + async pruneFunctions() { + const functionNames = this.serverless.service + .getAllFunctions() + .map((key) => this.serverless.service.getFunction(key).name); + + for (const functionName of functionNames) { + const [versions, aliases] = await Promise.all([ + this.listVersionsForFunction(functionName), + this.listAliasesForFunction(functionName), + ]); + if (!versions.length) continue; + + const deletionCandidates = this.selectPruneVersionsForFunction(versions, aliases); + await this.deleteVersionsForFunction(functionName, deletionCandidates); + } + + if (functionNames.length) log.notice.success('Pruning of functions complete'); + } + + async deleteVersionsForLayer(layerName, versions) { + const lambda = await this.getLambdaClient(); + for (const version of versions) { + log.info(`Deleting layer version ${layerName}:${version}.`); + await lambda.send( + new DeleteLayerVersionCommand({ + LayerName: layerName, + VersionNumber: version, + }) + ); + } + } + + async deleteVersionsForFunction(functionName, versions) { + const lambda = await this.getLambdaClient(); + for (const version of versions) { + log.info(`Deleting function version ${functionName}:${version}.`); + try { + await lambda.send( + new DeleteFunctionCommand({ + FunctionName: functionName, + Qualifier: version, + }) + ); + } catch (error) { + const statusCode = getAwsErrorStatusCode(error); + const message = getAwsErrorMessage(error) || ''; + if ( + statusCode === 400 && + message.startsWith('Lambda was unable to delete') && + message.includes('because it is a replicated function.') + ) { + log.warning( + `Unable to delete replicated Lambda@Edge function version ${functionName}:${version}.` + ); + } else { + throw error; + } + } + } + } + + async listAliasesForFunction(functionName) { + try { + return await this.paginateLambda( + ListAliasesCommand, + { FunctionName: functionName }, + 'Aliases' + ); + } catch (error) { + if (isLambdaResourceNotFoundError(error)) return []; + throw error; + } + } + + async listVersionsForFunction(functionName) { + try { + return await this.paginateLambda( + ListVersionsByFunctionCommand, + { FunctionName: functionName }, + 'Versions' + ); + } catch (error) { + if (isLambdaResourceNotFoundError(error)) return []; + throw error; + } + } + + async listVersionsForLayer(layerName) { + try { + return await this.paginateLambda( + ListLayerVersionsCommand, + { LayerName: layerName }, + 'LayerVersions' + ); + } catch (error) { + if (isLambdaResourceNotFoundError(error)) return []; + throw error; + } + } + + async paginateLambda(Command, params, resultKey) { + const lambda = await this.getLambdaClient(); + const results = []; + let input = params; + + do { + const response = await lambda.send(new Command(input)); + results.push(...(response[resultKey] || [])); + input = response.NextMarker ? { ...params, Marker: response.NextMarker } : null; + } while (input); + + return results; + } + + selectPruneVersionsForFunction(versions, aliases) { + const aliasedVersions = aliases.map((alias) => alias.FunctionVersion); + + return versions + .map((versionEntry) => versionEntry.Version) + .filter((version) => version !== '$LATEST') + .filter((version) => !aliasedVersions.includes(version)) + .sort((a, b) => + parseInt(a, 10) === parseInt(b, 10) ? 0 : parseInt(a, 10) > parseInt(b, 10) ? -1 : 1 + ) + .slice(this.getNumber()); + } + + selectPruneVersionsForLayer(versions) { + return versions + .map((versionEntry) => versionEntry.Version) + .sort((a, b) => + parseInt(a, 10) === parseInt(b, 10) ? 0 : parseInt(a, 10) > parseInt(b, 10) ? -1 : 1 + ) + .slice(this.getNumber()); + } +} + +module.exports = AwsPrune; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 181489f5b..8d7b2e8fe 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -28,6 +28,7 @@ module.exports = [ require('./aws/remove/index.js'), require('./aws/rollback.js'), require('./aws/rollback-function.js'), + require('./aws/prune.js'), require('./aws/package/compile/layers.js'), require('./aws/package/compile/functions.js'), require('./aws/package/compile/events/schedule.js'), diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js new file mode 100644 index 000000000..b5d4e001e --- /dev/null +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -0,0 +1,200 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const AwsPrune = require('../../../../../lib/plugins/aws/prune'); +const AwsProvider = require('../../../../../lib/plugins/aws/provider'); +const Serverless = require('../../../../../lib/serverless'); +const ServerlessError = require('../../../../../lib/serverless-error'); +const CLI = require('../../../../../lib/classes/cli'); + +describe('AwsPrune', () => { + let serverless; + let awsPrune; + + beforeEach(() => { + serverless = new Serverless({ commands: [], options: {} }); + serverless.cli = new CLI(serverless); + serverless.service.provider = { name: 'aws', versionFunctions: true }; + serverless.service.functions = { + FunctionA: { name: 'service-FunctionA' }, + FunctionB: { name: 'service-FunctionB' }, + }; + serverless.service.layers = { + LayerA: { name: 'layer-LayerA' }, + }; + serverless.service.getAllLayers = () => Object.keys(serverless.service.layers); + serverless.service.getLayer = (key) => serverless.service.layers[key]; + const options = { stage: 'dev', region: 'us-east-1' }; + serverless.setProvider('aws', new AwsProvider(serverless, options)); + awsPrune = new AwsPrune(serverless, options); + }); + + describe('#constructor()', () => { + it('should define deploy hooks', () => { + expect(awsPrune.hooks['before:deploy:deploy']).to.be.a('function'); + expect(awsPrune.hooks['after:deploy:deploy']).to.be.a('function'); + }); + }); + + describe('#resolvePruneConfig()', () => { + it('should return null when pruning is not configured', () => { + expect(awsPrune.resolvePruneConfig()).to.equal(null); + }); + + it('should return the default number when enabled with true', () => { + serverless.service.provider.pruneFunctionVersions = true; + expect(awsPrune.resolvePruneConfig()).to.deep.equal({ number: 10 }); + }); + + it('should return an explicit number from object configuration', () => { + serverless.service.provider.pruneFunctionVersions = { number: 20 }; + expect(awsPrune.resolvePruneConfig()).to.deep.equal({ number: 20 }); + }); + + it('should return null when explicitly disabled', () => { + serverless.service.provider.pruneFunctionVersions = false; + expect(awsPrune.resolvePruneConfig()).to.equal(null); + }); + }); + + describe('#validateConfiguration()', () => { + it('should throw when versionFunctions is false', () => { + serverless.service.provider.pruneFunctionVersions = true; + serverless.service.provider.versionFunctions = false; + + expect(() => awsPrune.validateConfiguration()).to.throw(ServerlessError); + }); + + it('should not throw when pruning is disabled', () => { + serverless.service.provider.versionFunctions = false; + expect(() => awsPrune.validateConfiguration()).to.not.throw(); + }); + }); + + describe('#selectPruneVersionsForFunction()', () => { + beforeEach(() => { + serverless.service.provider.pruneFunctionVersions = { number: 2 }; + }); + + it('should keep the requested number of newest versions', () => { + const versions = [{ Version: '1' }, { Version: '2' }, { Version: '3' }, { Version: '4' }]; + const result = awsPrune.selectPruneVersionsForFunction(versions, []); + expect(result).to.deep.equal(['2', '1']); + }); + + it('should not delete $LATEST or aliased versions', () => { + const versions = [ + { Version: '$LATEST' }, + { Version: '1' }, + { Version: '2' }, + { Version: '3' }, + { Version: '4' }, + { Version: '5' }, + ]; + const aliases = [{ FunctionVersion: '1' }, { FunctionVersion: '3' }, { FunctionVersion: '4' }]; + const result = awsPrune.selectPruneVersionsForFunction(versions, aliases); + expect(result).to.not.include('$LATEST'); + expect(result).to.not.include('1'); + expect(result).to.not.include('3'); + expect(result).to.not.include('4'); + }); + }); + + describe('#selectPruneVersionsForLayer()', () => { + beforeEach(() => { + serverless.service.provider.pruneFunctionVersions = { number: 2 }; + }); + + it('should keep the requested number of newest layer versions', () => { + const versions = [{ Version: 1 }, { Version: 2 }, { Version: 3 }, { Version: 4 }]; + const result = awsPrune.selectPruneVersionsForLayer(versions); + expect(result).to.deep.equal([2, 1]); + }); + }); + + describe('#pruneFunctions()', () => { + let listVersionsStub; + let listAliasesStub; + let deleteStub; + + beforeEach(() => { + serverless.service.provider.pruneFunctionVersions = { number: 2 }; + listVersionsStub = sinon.stub(awsPrune, 'listVersionsForFunction'); + listAliasesStub = sinon.stub(awsPrune, 'listAliasesForFunction'); + deleteStub = sinon.stub(awsPrune, 'deleteVersionsForFunction').resolves(); + }); + + afterEach(() => { + listVersionsStub.restore(); + listAliasesStub.restore(); + deleteStub.restore(); + }); + + it('should delete old versions of functions', async () => { + listVersionsStub.resolves([ + { Version: '1' }, + { Version: '2' }, + { Version: '3' }, + { Version: '4' }, + { Version: '5' }, + ]); + listAliasesStub.resolves([]); + + await awsPrune.pruneFunctions(); + + expect(deleteStub.callCount).to.equal(2); + expect(deleteStub.getCall(0).args).to.deep.equal(['service-FunctionA', ['3', '2', '1']]); + }); + }); + + describe('#postDeploy()', () => { + it('should prune functions and layers when enabled', async () => { + serverless.service.provider.pruneFunctionVersions = true; + const pruneFunctionsStub = sinon.stub(awsPrune, 'pruneFunctions').resolves(); + const pruneLayersStub = sinon.stub(awsPrune, 'pruneLayers').resolves(); + + await awsPrune.postDeploy(); + + expect(pruneFunctionsStub.calledOnce).to.equal(true); + expect(pruneLayersStub.calledOnce).to.equal(true); + pruneFunctionsStub.restore(); + pruneLayersStub.restore(); + }); + + it('should prune when number is 0', async () => { + serverless.service.provider.pruneFunctionVersions = { number: 0 }; + const pruneFunctionsStub = sinon.stub(awsPrune, 'pruneFunctions').resolves(); + const pruneLayersStub = sinon.stub(awsPrune, 'pruneLayers').resolves(); + + await awsPrune.postDeploy(); + + expect(pruneFunctionsStub.calledOnce).to.equal(true); + pruneFunctionsStub.restore(); + pruneLayersStub.restore(); + }); + + it('should not prune when disabled', async () => { + const pruneFunctionsStub = sinon.stub(awsPrune, 'pruneFunctions').resolves(); + const pruneLayersStub = sinon.stub(awsPrune, 'pruneLayers').resolves(); + + await awsPrune.postDeploy(); + + expect(pruneFunctionsStub.called).to.equal(false); + expect(pruneLayersStub.called).to.equal(false); + pruneFunctionsStub.restore(); + pruneLayersStub.restore(); + }); + + it('should not prune when noDeploy flag is set', async () => { + serverless.service.provider.pruneFunctionVersions = true; + awsPrune.options.noDeploy = true; + const pruneFunctionsStub = sinon.stub(awsPrune, 'pruneFunctions').resolves(); + + await awsPrune.postDeploy(); + + expect(pruneFunctionsStub.called).to.equal(false); + pruneFunctionsStub.restore(); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 078714b30..96a09d78a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -81,6 +81,11 @@ export type AwsLambdaRuntimeManagement = export type AwsLambdaTimeout = number; export type AwsLambdaTracing = ('Active' | 'PassThrough') | boolean; export type AwsLambdaVersioning = boolean; +export type AwsPruneFunctionVersions = + | boolean + | { + number: number; + }; export type AwsHttpApiPayload = '1.0' | '2.0'; export type AwsApiGatewayApiKeys = ( | string @@ -1341,6 +1346,7 @@ export interface AWS { vpc?: AwsLambdaVpcConfig; vpcEndpointIds?: AwsCfArrayInstruction; versionFunctions?: AwsLambdaVersioning; + pruneFunctionVersions?: AwsPruneFunctionVersions; websocket?: { useProviderTags?: boolean; }; From 5c8a87b03e7266ff98369fe4ec628e64233f931f Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:10:18 +0200 Subject: [PATCH 2/7] Throw when provider.pruneFunctionVersions is invalid Reject unparseable configuration instead of silently disabling pruning. --- lib/plugins/aws/prune.js | 5 ++++- test/unit/lib/plugins/aws/prune.test.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 9736238ea..14bfb3be5 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -38,7 +38,10 @@ class AwsPrune { const number = parseInt(setting.number, 10); if (!isNaN(number) && number >= 0) return { number }; } - return null; + throw new ServerlessError( + 'provider.pruneFunctionVersions must be true or an object with a non-negative number property', + 'INVALID_PRUNE_FUNCTION_VERSIONS_CONFIG' + ); } getNumber() { diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js index b5d4e001e..331b33f35 100644 --- a/test/unit/lib/plugins/aws/prune.test.js +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -56,6 +56,11 @@ describe('AwsPrune', () => { serverless.service.provider.pruneFunctionVersions = false; expect(awsPrune.resolvePruneConfig()).to.equal(null); }); + + it('should throw when configuration is invalid', () => { + serverless.service.provider.pruneFunctionVersions = { number: 'invalid' }; + expect(() => awsPrune.resolvePruneConfig()).to.throw(ServerlessError); + }); }); describe('#validateConfiguration()', () => { From 33d2b7d4f712db8914a9ee42d793a8e6683f9c70 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:10:24 +0200 Subject: [PATCH 3/7] Remove unused noDeploy guard from prune post-deploy hook The option is not part of osls and never reached this code path. --- lib/plugins/aws/prune.js | 2 -- test/unit/lib/plugins/aws/prune.test.js | 10 ---------- 2 files changed, 12 deletions(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 14bfb3be5..02df3da79 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -62,8 +62,6 @@ class AwsPrune { } async postDeploy() { - if (this.options.noDeploy === true) return; - const config = this.resolvePruneConfig(); if (!config) return; diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js index 331b33f35..168c96ec3 100644 --- a/test/unit/lib/plugins/aws/prune.test.js +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -191,15 +191,5 @@ describe('AwsPrune', () => { pruneLayersStub.restore(); }); - it('should not prune when noDeploy flag is set', async () => { - serverless.service.provider.pruneFunctionVersions = true; - awsPrune.options.noDeploy = true; - const pruneFunctionsStub = sinon.stub(awsPrune, 'pruneFunctions').resolves(); - - await awsPrune.postDeploy(); - - expect(pruneFunctionsStub.called).to.equal(false); - pruneFunctionsStub.restore(); - }); }); }); From ed2f7abe9dad1ae1558ce16391ce1726c5da2640 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:10:33 +0200 Subject: [PATCH 4/7] Normalize alias versions when selecting function versions to prune Compare alias and version qualifiers as strings so numeric alias versions are not pruned by mistake. --- lib/plugins/aws/prune.js | 6 ++++-- test/unit/lib/plugins/aws/prune.test.js | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 02df3da79..9381eb9f1 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -208,12 +208,14 @@ class AwsPrune { } selectPruneVersionsForFunction(versions, aliases) { - const aliasedVersions = aliases.map((alias) => alias.FunctionVersion); + const aliasedVersions = new Set( + aliases.map((alias) => String(alias.FunctionVersion)) + ); return versions .map((versionEntry) => versionEntry.Version) .filter((version) => version !== '$LATEST') - .filter((version) => !aliasedVersions.includes(version)) + .filter((version) => !aliasedVersions.has(String(version))) .sort((a, b) => parseInt(a, 10) === parseInt(b, 10) ? 0 : parseInt(a, 10) > parseInt(b, 10) ? -1 : 1 ) diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js index 168c96ec3..e2192b82a 100644 --- a/test/unit/lib/plugins/aws/prune.test.js +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -104,6 +104,13 @@ describe('AwsPrune', () => { expect(result).to.not.include('3'); expect(result).to.not.include('4'); }); + + it('should not delete aliased versions when alias version is a number', () => { + const versions = [{ Version: '1' }, { Version: '2' }, { Version: '3' }, { Version: '4' }]; + const aliases = [{ FunctionVersion: 2 }]; + const result = awsPrune.selectPruneVersionsForFunction(versions, aliases); + expect(result).to.not.include('2'); + }); }); describe('#selectPruneVersionsForLayer()', () => { From 963d937cfc3e6d939a543e3be393d97390c44c23 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:10:47 +0200 Subject: [PATCH 5/7] Skip pruning for functions with versionFunction disabled Mirror compile-time versioning rules so unversioned functions are left untouched after deploy. --- lib/plugins/aws/prune.js | 17 +++++++++++------ test/unit/lib/plugins/aws/prune.test.js | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 9381eb9f1..7acd5497b 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -49,6 +49,14 @@ class AwsPrune { return config ? config.number : undefined; } + shouldVersionFunction(functionKey) { + const functionObject = this.serverless.service.getFunction(functionKey); + if (functionObject.versionFunction != null) { + return functionObject.versionFunction; + } + return this.serverless.service.provider.versionFunctions !== false; + } + validateConfiguration() { const config = this.resolvePruneConfig(); if (!config) return; @@ -94,11 +102,10 @@ class AwsPrune { } async pruneFunctions() { - const functionNames = this.serverless.service - .getAllFunctions() - .map((key) => this.serverless.service.getFunction(key).name); + for (const functionKey of this.serverless.service.getAllFunctions()) { + if (!this.shouldVersionFunction(functionKey)) continue; - for (const functionName of functionNames) { + const functionName = this.serverless.service.getFunction(functionKey).name; const [versions, aliases] = await Promise.all([ this.listVersionsForFunction(functionName), this.listAliasesForFunction(functionName), @@ -108,8 +115,6 @@ class AwsPrune { const deletionCandidates = this.selectPruneVersionsForFunction(versions, aliases); await this.deleteVersionsForFunction(functionName, deletionCandidates); } - - if (functionNames.length) log.notice.success('Pruning of functions complete'); } async deleteVersionsForLayer(layerName, versions) { diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js index e2192b82a..9e263db4d 100644 --- a/test/unit/lib/plugins/aws/prune.test.js +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -63,6 +63,14 @@ describe('AwsPrune', () => { }); }); + describe('#shouldVersionFunction()', () => { + it('should skip functions with versionFunction set to false', () => { + serverless.service.functions.FunctionA.versionFunction = false; + expect(awsPrune.shouldVersionFunction('FunctionA')).to.equal(false); + expect(awsPrune.shouldVersionFunction('FunctionB')).to.equal(true); + }); + }); + describe('#validateConfiguration()', () => { it('should throw when versionFunctions is false', () => { serverless.service.provider.pruneFunctionVersions = true; @@ -158,6 +166,17 @@ describe('AwsPrune', () => { expect(deleteStub.callCount).to.equal(2); expect(deleteStub.getCall(0).args).to.deep.equal(['service-FunctionA', ['3', '2', '1']]); }); + + it('should skip functions with versionFunction set to false', async () => { + serverless.service.functions.FunctionA.versionFunction = false; + listVersionsStub.resolves([{ Version: '1' }, { Version: '2' }, { Version: '3' }]); + listAliasesStub.resolves([]); + + await awsPrune.pruneFunctions(); + + expect(deleteStub.calledOnce).to.equal(true); + expect(deleteStub.firstCall.args[0]).to.equal('service-FunctionB'); + }); }); describe('#postDeploy()', () => { From 9449dd113c1efc02fe25a14596f778add8f7cce6 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:11:04 +0200 Subject: [PATCH 6/7] Log prune success only when versions were deleted Report how many function and layer versions were removed instead of always printing a completion message after deploy. --- lib/plugins/aws/prune.js | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 7acd5497b..9fbb1ff2a 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -90,18 +90,25 @@ class AwsPrune { .getAllLayers() .map((key) => this.serverless.service.getLayer(key).name || key); + let prunedCount = 0; for (const layerName of layerNames) { const versions = await this.listVersionsForLayer(layerName); if (!versions.length) continue; const deletionCandidates = this.selectPruneVersionsForLayer(versions); - await this.deleteVersionsForLayer(layerName, deletionCandidates); + prunedCount += await this.deleteVersionsForLayer(layerName, deletionCandidates); } - if (layerNames.length) log.notice.success('Pruning of layers complete'); + if (prunedCount > 0) { + log.notice.success( + `Pruned ${prunedCount} layer version${prunedCount === 1 ? '' : 's'}` + ); + } } async pruneFunctions() { + let prunedCount = 0; + for (const functionKey of this.serverless.service.getAllFunctions()) { if (!this.shouldVersionFunction(functionKey)) continue; @@ -113,12 +120,20 @@ class AwsPrune { if (!versions.length) continue; const deletionCandidates = this.selectPruneVersionsForFunction(versions, aliases); - await this.deleteVersionsForFunction(functionName, deletionCandidates); + prunedCount += await this.deleteVersionsForFunction(functionName, deletionCandidates); + } + + if (prunedCount > 0) { + log.notice.success( + `Pruned ${prunedCount} function version${prunedCount === 1 ? '' : 's'}` + ); } } async deleteVersionsForLayer(layerName, versions) { const lambda = await this.getLambdaClient(); + let deletedCount = 0; + for (const version of versions) { log.info(`Deleting layer version ${layerName}:${version}.`); await lambda.send( @@ -127,11 +142,16 @@ class AwsPrune { VersionNumber: version, }) ); + deletedCount++; } + + return deletedCount; } async deleteVersionsForFunction(functionName, versions) { const lambda = await this.getLambdaClient(); + let deletedCount = 0; + for (const version of versions) { log.info(`Deleting function version ${functionName}:${version}.`); try { @@ -141,6 +161,7 @@ class AwsPrune { Qualifier: version, }) ); + deletedCount++; } catch (error) { const statusCode = getAwsErrorStatusCode(error); const message = getAwsErrorMessage(error) || ''; @@ -157,6 +178,8 @@ class AwsPrune { } } } + + return deletedCount; } async listAliasesForFunction(functionName) { From 9aac85363607d5c79c05229cf34b5fc772a74f14 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 19 May 2026 23:15:14 +0200 Subject: [PATCH 7/7] Apply Prettier formatting to prune plugin files --- lib/plugins/aws/prune.js | 12 +++--------- test/unit/lib/plugins/aws/prune.test.js | 7 +++++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/plugins/aws/prune.js b/lib/plugins/aws/prune.js index 9fbb1ff2a..c2cc2d7ef 100644 --- a/lib/plugins/aws/prune.js +++ b/lib/plugins/aws/prune.js @@ -100,9 +100,7 @@ class AwsPrune { } if (prunedCount > 0) { - log.notice.success( - `Pruned ${prunedCount} layer version${prunedCount === 1 ? '' : 's'}` - ); + log.notice.success(`Pruned ${prunedCount} layer version${prunedCount === 1 ? '' : 's'}`); } } @@ -124,9 +122,7 @@ class AwsPrune { } if (prunedCount > 0) { - log.notice.success( - `Pruned ${prunedCount} function version${prunedCount === 1 ? '' : 's'}` - ); + log.notice.success(`Pruned ${prunedCount} function version${prunedCount === 1 ? '' : 's'}`); } } @@ -236,9 +232,7 @@ class AwsPrune { } selectPruneVersionsForFunction(versions, aliases) { - const aliasedVersions = new Set( - aliases.map((alias) => String(alias.FunctionVersion)) - ); + const aliasedVersions = new Set(aliases.map((alias) => String(alias.FunctionVersion))); return versions .map((versionEntry) => versionEntry.Version) diff --git a/test/unit/lib/plugins/aws/prune.test.js b/test/unit/lib/plugins/aws/prune.test.js index 9e263db4d..14f16b9f0 100644 --- a/test/unit/lib/plugins/aws/prune.test.js +++ b/test/unit/lib/plugins/aws/prune.test.js @@ -105,7 +105,11 @@ describe('AwsPrune', () => { { Version: '4' }, { Version: '5' }, ]; - const aliases = [{ FunctionVersion: '1' }, { FunctionVersion: '3' }, { FunctionVersion: '4' }]; + const aliases = [ + { FunctionVersion: '1' }, + { FunctionVersion: '3' }, + { FunctionVersion: '4' }, + ]; const result = awsPrune.selectPruneVersionsForFunction(versions, aliases); expect(result).to.not.include('$LATEST'); expect(result).to.not.include('1'); @@ -216,6 +220,5 @@ describe('AwsPrune', () => { pruneFunctionsStub.restore(); pruneLayersStub.restore(); }); - }); });