Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

skuba migrate node 22 #1735

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion src/cli/migrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { log } from '../../utils/logging';
import { nodeVersionMigration } from './nodeVersion';

const migrations: Record<string, () => Promise<void>> = {
node20: () => nodeVersionMigration(20),
node22: () => nodeVersionMigration(22),
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like it could be fine to keep 20 around just for purposes of people doing a 2-step upgrade? 🤷 maybe not

};

const logAvailableMigrations = () => {
Expand Down
141 changes: 113 additions & 28 deletions src/cli/migrate/nodeVersion/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import memfs, { vol } from 'memfs';

import { nodeVersionMigration } from '.';
import { getLatestNode22Types, nodeVersionMigration } from '.';

jest.mock('fs-extra', () => memfs);
jest.mock('fast-glob', () => ({
Expand All @@ -9,9 +9,16 @@ jest.mock('fast-glob', () => ({
}));
jest.mock('../../../utils/logging');

jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ 'dist-tags': { latest: '22.9.0' } })),
),
);

const volToJson = () => vol.toJSON(process.cwd(), undefined, true);

beforeEach(jest.clearAllMocks);
beforeEach(() => vol.reset());

describe('nodeVersionMigration', () => {
Expand Down Expand Up @@ -41,22 +48,30 @@ describe('nodeVersionMigration', () => {
'plugins:\n - docker#v3.0.0:\n image: node:18.1.2-slim\n',
'.buildkite/pipeline2.yml':
'plugins:\n - docker#v3.0.0:\n image: node:18\n',
'.buildkite/pipline3.yml':
'plugins:\n - docker#v3.0.0:\n image: public.ecr.aws/docker/library/node:20-alpine\n',
'.node-version': '18.1.2\n',
'.node-version2': 'v20.15.0\n',
},
filesAfter: {
'.nvmrc': '20\n',
Dockerfile: 'FROM node:20\nRUN echo "hello"',
'.nvmrc': '22\n',
Dockerfile: 'FROM node:22\nRUN echo "hello"',
'Dockerfile.dev-deps':
'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"',
'serverless.yml':
'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2',
'provider:\n logRetentionInDays: 30\n runtime: nodejs22.x\n region: ap-southeast-2',
'serverless.melb.yaml':
'provider:\n logRetentionInDays: 7\n runtime: nodejs20.x\n region: ap-southeast-4',
'infra/myCoolStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_20_X,\n}`,
'infra/myCoolFolder/evenCoolerStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_20_X,\n}`,
'provider:\n logRetentionInDays: 7\n runtime: nodejs22.x\n region: ap-southeast-4',
'infra/myCoolStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_22_X,\n}`,
'infra/myCoolFolder/evenCoolerStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_22_X,\n}`,
'.buildkite/pipeline.yml':
'plugins:\n - docker#v3.0.0:\n image: node:20-slim\n',
'plugins:\n - docker#v3.0.0:\n image: node:22-slim\n',
'.buildkite/pipeline2.yml':
'plugins:\n - docker#v3.0.0:\n image: node:20\n',
'plugins:\n - docker#v3.0.0:\n image: node:22\n',
'.buildkite/pipline3.yml':
'plugins:\n - docker#v3.0.0:\n image: public.ecr.aws/docker/library/node:22-alpine\n',
'.node-version': '22\n',
'.node-version2': 'v22\n',
},
},
{
Expand All @@ -79,36 +94,48 @@ describe('nodeVersionMigration', () => {
'FROM gcr.io/distroless/nodejs18-debian12\nRUN echo "hello"',
'Dockerfile.10':
'FROM --platform=linux/amd64 gcr.io/distroless/nodejs18-debian12 AS dev-deps\nRUN echo "hello"',
'Dockerfile.11':
'FROM --platform=${BUILDPLATFORM:-arm64} gcr.io/distroless/nodejs20-debian12@sha256:9f43117c3e33c3ed49d689e51287a246edef3af0afed51a54dc0a9095b2b3ef9 AS runtime',
'Dockerfile.12':
'# syntax=docker/dockerfile:1.10@sha256:865e5dd094beca432e8c0a1d5e1c465db5f998dca4e439981029b3b81fb39ed5\nFROM --platform=arm64 node:20@sha256:a5e0ed56f2c20b9689e0f7dd498cac7e08d2a3a283e92d9304e7b9b83e3c6ff3 AS dev-deps',
'Dockerfile.13':
'FROM public.ecr.aws/docker/library/node:20-alpine@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 AS runtime',
},
filesAfter: {
'.nvmrc': '20\n',
'Dockerfile.1': 'FROM node:20\nRUN echo "hello"',
'Dockerfile.2': 'FROM node:20\nRUN echo "hello"',
'Dockerfile.3': 'FROM node:20-slim\nRUN echo "hello"',
'Dockerfile.4': 'FROM node:20-slim\nRUN echo "hello"',
'.nvmrc': '22\n',
'Dockerfile.1': 'FROM node:22\nRUN echo "hello"',
'Dockerfile.2': 'FROM node:22\nRUN echo "hello"',
'Dockerfile.3': 'FROM node:22-slim\nRUN echo "hello"',
'Dockerfile.4': 'FROM node:22-slim\nRUN echo "hello"',
'Dockerfile.5':
'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22 AS dev-deps\nRUN echo "hello"',
'Dockerfile.6':
'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22 AS dev-deps\nRUN echo "hello"',
'Dockerfile.7':
'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"',
'Dockerfile.8':
'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"',
'Dockerfile.9':
'FROM gcr.io/distroless/nodejs20-debian12\nRUN echo "hello"',
'FROM gcr.io/distroless/nodejs22-debian12\nRUN echo "hello"',
'Dockerfile.10':
'FROM --platform=linux/amd64 gcr.io/distroless/nodejs20-debian12 AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 gcr.io/distroless/nodejs22-debian12 AS dev-deps\nRUN echo "hello"',
'Dockerfile.11':
'FROM --platform=${BUILDPLATFORM:-arm64} gcr.io/distroless/nodejs22-debian12 AS runtime',
'Dockerfile.12':
'# syntax=docker/dockerfile:1.10@sha256:865e5dd094beca432e8c0a1d5e1c465db5f998dca4e439981029b3b81fb39ed5\nFROM --platform=arm64 node:22 AS dev-deps',
'Dockerfile.13':
'FROM public.ecr.aws/docker/library/node:22-alpine AS runtime',
},
},
{
scenario: 'already node 20',
scenario: 'already node 22',
filesBefore: {
'.nvmrc': '20\n',
Dockerfile: 'FROM node:20\nRUN echo "hello"',
'.nvmrc': '22\n',
Dockerfile: 'FROM node:22\nRUN echo "hello"',
'Dockerfile.dev-deps':
'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"',
'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"',
'serverless.yml':
'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2',
'provider:\n logRetentionInDays: 30\n runtime: nodejs22.x\n region: ap-southeast-2',
},
},
{
Expand All @@ -117,16 +144,74 @@ describe('nodeVersionMigration', () => {
Dockerfile: 'FROM node:latest\nRUN echo "hello"',
},
},
{
scenario: 'node types',
filesBefore: {
'package.json': '"@types/node": "^14.0.0"',
'1/package.json': '"@types/node": "18.0.0"',
'2/package.json': `"engines": {\n"node": ">=18"\n},\n`,
'3/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "package"\n}`,
'4/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "application"\n}`,
},
filesAfter: {
'package.json': '"@types/node": "^22.9.0"',
'1/package.json': '"@types/node": "22.9.0"',
'2/package.json': `"engines": {\n"node": ">=22"\n},\n`,
'3/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "package"\n}`,
'4/package.json': `"engines": {\n"node": ">=22"\n},\n"skuba": {\n"type": "application"\n}`,
},
},
{
scenario: 'tsconfig target',
filesBefore: {
'tsconfig.json': '"target": "ES2020"',
'1/tsconfig.json': '"target": "es2014"',
'2/tsconfig.json': '"target": "ESNext"',
},
filesAfter: {
'tsconfig.json': '"target": "ES2024"',
'1/tsconfig.json': '"target": "ES2024"',
'2/tsconfig.json': '"target": "ES2024"',
},
},
{
scenario: 'docker-compose.yml target',
filesBefore: {
'docker-compose.yml': 'image: node:18.1.2\n',
'docker-compose.dev.yml': 'image: node:18\n',
'docker-compose.prod.yml': 'image: node:18-slim\n',
},
filesAfter: {
'docker-compose.yml': 'image: node:22\n',
'docker-compose.dev.yml': 'image: node:22\n',
'docker-compose.prod.yml': 'image: node:22-slim\n',
},
},
];

it.each(scenarios)(
'handles $scenario',
async ({ filesBefore, filesAfter }) => {
vol.fromJSON(filesBefore, process.cwd());

await nodeVersionMigration(20);
await nodeVersionMigration(22);

expect(volToJson()).toEqual(filesAfter ?? filesBefore);
},
);
});

describe('getLatestNode22Types', () => {
it('fetches the latest node types version', async () => {
const { version, err } = await getLatestNode22Types();
expect(version).toBe('22.9.0');
expect(err).toBeUndefined();
});
it('defaults to 22.9.0 if the fetch fails', async () => {
jest.spyOn(global, 'fetch').mockImplementation(() => Promise.reject());
const { version, err } = await getLatestNode22Types();

expect(version).toBe('22.9.0');
expect(err).toBe('Failed to fetch latest version, using fallback version');
});
});
124 changes: 110 additions & 14 deletions src/cli/migrate/nodeVersion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,45 @@ type SubPatch = (
replace: string;
};

type VersionResult = {
version: string;
err: string | undefined;
};

type RegistryResponse = {
'dist-tags': Record<string, string>;
};

export const getLatestNode22Types = async (): Promise<VersionResult> => {
const FALLBACK_VERSION = '22.9.0';
const url = 'https://registry.npmjs.org/@types/node';
const headers = {
accept:
'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*',
};

try {
const fetchResponse = await fetch(url, { headers });
const jsonResponse = (await fetchResponse.json()) as RegistryResponse;
const latestNode22 = jsonResponse['dist-tags'].node22;
Copy link
Contributor

Choose a reason for hiding this comment

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

Does a node22 dist tag exist..?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As it turns out... no 😢

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Swapped it for your suggestion of
npm show @types/node@^22 version --json


return { version: latestNode22 ?? FALLBACK_VERSION, err: undefined };
} catch {
return {
version: FALLBACK_VERSION,
err: 'Failed to fetch latest version, using fallback version',
};
}
};

const SHA_REGEX = /(?<=node.*)(@sha256:[a-f0-9]{64})/gm;

const subPatches: SubPatch[] = [
{ file: '.nvmrc', replace: '<%- version %>\n' },
{
files: 'Dockerfile*',
test: /^FROM(.*) node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?( .+|)$/gm,
replace: 'FROM$1 node:<%- version %>$3$4',
test: /^FROM(.*) (public.ecr.aws\/docker\/library\/)?node:[0-9.]+(@sha256:[a-f0-9]{64})?(\.[^- \n]+)?(-[^ \n]+)?( .+|)$/gm,
replace: 'FROM$1 $2node:<%- version %>$3$5$6',
},
{
files: 'Dockerfile*',
Expand All @@ -37,13 +70,50 @@ const subPatches: SubPatch[] = [
replace: 'NODEJS_<%- version %>_X',
},
{
files: '.buildkite/*',
test: /image: node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm,
replace: 'image: node:<%- version %>$2',
files: '**/.buildkite/*',
test: /image: (public.ecr.aws\/docker\/library\/)?node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm,
replace: 'image: $1node:<%- version %>$3',
},
{
files: '.node-version*',
test: /(v)?\d+\.\d+\.\d+(.+)?/gm,
replace: '$1<%- version %>$2',
},
{
files: '**/package.json',
test: /"@types\/node": "(\^)?[0-9.]+"/gm,
replace: '"@types/node": "$1<%- version %>"',
},
{
files: '**/package.json',
test: /("engines":\s*{[^}]*"node":\s*">=)(\d+)("[^}]*})(?![^}]*"skuba":\s*{[^}]*"type":\s*"package")/gm,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This regex is doing what checkSkubaType() does, thoughts on keeping both or just using the function?

replace: '$1<%- version %>$3',
},
{
files: '**/tsconfig.json',
test: /("target":\s*")(ES?:[0-9]+|Next|[A-Za-z]+[0-9]*)"/gim,
replace: '$1<%- version %>"',
},
{
files: '**/docker-compose*.y*ml',
test: /image: (public.ecr.aws\/docker\/library\/)?node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm,
replace: 'image: $1node:<%- version %>$3',
},
];

const runSubPatch = async (version: number, dir: string, patch: SubPatch) => {
const removeNodeShas = (content: string): string =>
content.replace(SHA_REGEX, '');

type Versions = {
nodeVersion: number;
nodeTypesVersion: string;
};

const runSubPatch = async (
{ nodeVersion, nodeTypesVersion }: Versions,
dir: string,
patch: SubPatch,
) => {
const readFile = createDestinationFileReader(dir);
const paths = patch.file
? [patch.file]
Expand All @@ -60,22 +130,44 @@ const runSubPatch = async (version: number, dir: string, patch: SubPatch) => {
return;
}

const templated = patch.replace.replaceAll(
'<%- version %>',
version.toString(),
);
const unPinnedContents = removeNodeShas(contents);

let templated: string;
if (
path.includes('package.json') &&
patch.replace.includes('@types/node')
) {
templated = patch.replace.replaceAll(
'<%- version %>',
nodeTypesVersion,
);
} else if (path.includes('tsconfig.json')) {
templated = patch.replace.replaceAll('<%- version %>', 'ES2024');
} else {
templated = patch.replace.replaceAll(
'<%- version %>',
nodeVersion.toString(),
);
}

await fs.promises.writeFile(
path,
patch.test ? contents.replaceAll(patch.test, templated) : templated,
patch.test
? unPinnedContents.replaceAll(patch.test, templated)
: templated,
);
}),
);
};

const upgrade = async (version: number, dir: string) => {
const upgrade = async (
{ nodeVersion, nodeTypesVersion }: Versions,
dir: string,
) => {
await Promise.all(
subPatches.map((subPatch) => runSubPatch(version, dir, subPatch)),
subPatches.map((subPatch) =>
runSubPatch({ nodeVersion, nodeTypesVersion }, dir, subPatch),
),
);
};

Expand All @@ -85,7 +177,11 @@ export const nodeVersionMigration = async (
) => {
log.ok(`Upgrading to Node.js ${version}`);
try {
await upgrade(version, dir);
const { version: nodeTypesVersion, err } = await getLatestNode22Types();
if (err) {
log.warn(err);
}
await upgrade({ nodeVersion: version, nodeTypesVersion }, dir);
log.ok('Upgraded to Node.js', version);
} catch (err) {
log.err('Failed to upgrade');
Expand Down