projenrc/upgrade-dependencies.ts (262 lines of code) (raw):
import { Component, DependencyType, github, javascript, release, Task, TaskStep } from 'projen';
import { DEFAULT_GITHUB_ACTIONS_USER } from 'projen/lib/github/constants';
import { NodePackageManager } from 'projen/lib/javascript';
const CREATE_PATCH_STEP_ID = 'create_patch';
const PATCH_CREATED_OUTPUT = 'patch_created';
/**
* Options for `UpgradeDependencies`.
*/
export interface UpgradeDependenciesOptions {
/**
* List of package names to include during the upgrade.
*
* @default - Everything is included.
*/
readonly include?: string[];
/**
* Include a github workflow for creating PR's that upgrades the
* required dependencies, either by manual dispatch, or by a schedule.
*
* If this is `false`, only a local projen task is created, which can be executed manually to
* upgrade the dependencies.
*
* @default - true for root projects, false for sub-projects.
*/
readonly workflow?: boolean;
/**
* Options for the github workflow. Only applies if `workflow` is true.
*
* @default - default options.
*/
readonly workflowOptions?: UpgradeDependenciesWorkflowOptions;
/**
* The name of the task that will be created.
* This will also be the workflow name.
*
* @default "upgrade".
*/
readonly taskName?: string;
/**
* Title of the pull request to use (should be all lower-case).
*
* @default "upgrade dependencies"
*/
readonly pullRequestTitle?: string;
/**
* Add Signed-off-by line by the committer at the end of the commit log message.
*
* @default true
*/
readonly signoff?: boolean;
}
/**
* Upgrade node project dependencies.
*/
export class UpgradeDependencies extends Component {
/**
* The workflows that execute the upgrades. One workflow per branch.
*/
public readonly workflows: github.GithubWorkflow[] = [];
private readonly options: UpgradeDependenciesOptions;
private readonly _project: javascript.NodeProject;
private readonly pullRequestTitle: string;
/**
* Container definitions for the upgrade workflow.
*/
public containerOptions?: github.workflows.ContainerOptions;
/**
* The upgrade task.
*/
public readonly upgradeTask: Task;
/**
* A task run after the upgrade task.
*/
public readonly postUpgradeTask: Task;
private readonly gitIdentity: github.GitIdentity;
private readonly postBuildSteps: github.workflows.JobStep[];
private readonly permissions: github.workflows.JobPermissions;
constructor(project: javascript.NodeProject, options: UpgradeDependenciesOptions = {}) {
super(project);
this._project = project;
this.options = options;
this.pullRequestTitle = options.pullRequestTitle ?? 'upgrade dependencies';
this.gitIdentity = options.workflowOptions?.gitIdentity ?? DEFAULT_GITHUB_ACTIONS_USER;
this.permissions = {
contents: github.workflows.JobPermission.READ,
...options.workflowOptions?.permissions,
};
this.postBuildSteps = [];
this.containerOptions = options.workflowOptions?.container;
project.addDevDeps('npm-check-updates@^16');
this.postUpgradeTask =
project.tasks.tryFind('post-upgrade') ??
project.tasks.addTask('post-upgrade', {
description: 'Runs after upgrading dependencies',
});
this.upgradeTask = project.addTask(options.taskName ?? 'upgrade', {
// this task should not run in CI mode because its designed to
// update package.json and lock files.
env: { CI: '0' },
description: this.pullRequestTitle,
steps: { toJSON: () => this.renderTaskSteps() } as any,
});
this.upgradeTask.lock(); // this task is a lazy value, so make it readonly
if (this.upgradeTask && project.github && (options.workflow ?? true)) {
if (options.workflowOptions?.branches) {
for (const branch of options.workflowOptions.branches) {
this.workflows.push(this.createWorkflow(this.upgradeTask, project.github, branch));
}
} else if (release.Release.of(project)) {
const rel = release.Release.of(project)!;
rel._forEachBranch((branch: string) => {
this.workflows.push(this.createWorkflow(this.upgradeTask, project.github!, branch));
});
} else {
// represents the default repository branch.
// just like not specifying anything.
const defaultBranch = undefined;
this.workflows.push(this.createWorkflow(this.upgradeTask, project.github, defaultBranch));
}
}
}
/**
* Add steps to execute a successful build.
* @param steps workflow steps
*/
public addPostBuildSteps(...steps: github.workflows.JobStep[]) {
this.postBuildSteps.push(...steps);
}
private renderTaskSteps(): TaskStep[] {
// exclude depedencies that has already version pinned (fully or with patch version) by Projen with ncu (but not package manager upgrade)
// Getting only unique values through set
const ncuExcludes = [
...new Set(
this.project.deps.all
.filter(
(dep) =>
dep.name === 'typescript' ||
(dep.version && dep.version[0] !== '^' && dep.type !== DependencyType.OVERRIDE),
)
.map((dep) => dep.name),
),
];
// TypeScript is minor-pinned in this project...
const hasTypescript = ncuExcludes.includes('typescript');
const ncuIncludes = this.options.include?.filter((item) => !ncuExcludes.includes(item));
const includeLength = this.options.include?.length ?? 0;
const ncuIncludesLength = ncuIncludes?.length ?? 0;
// If all explicit includes already have version pinned, don't add task.
// Note that without explicit includes task gets added
if (includeLength > 0 && ncuIncludesLength === 0) {
return [{ exec: 'echo No dependencies to upgrade.' }];
}
const steps = new Array<TaskStep>();
// update npm-check-updates before everything else, in case there is a bug
// in it or one of its dependencies. This will make upgrade workflows
// slightly more stable and resilient to upstream changes.
steps.push({
exec: this.renderUpgradePackagesCommand(['npm-check-updates']),
});
for (const dep of ['dev', 'optional', 'peer', 'prod', 'bundle']) {
const ncuCommand = ['npm-check-updates', '--dep', dep, '--upgrade', '--target=minor'];
// Don't add includes and excludes same time
if (ncuIncludes) {
ncuCommand.push(`--filter='${ncuIncludes.join(',')}'`);
} else if (ncuExcludes.length > 0) {
ncuCommand.push(`--reject='${ncuExcludes.join(',')}'`);
}
steps.push({ exec: ncuCommand.join(' ') });
}
if (hasTypescript) {
const ncuCommand = ['npm-check-updates', '--upgrade', '--target=patch', '--filter=typescript'];
steps.push({ exec: ncuCommand.join(' ') });
}
// run "yarn/npm install" to update the lockfile and install any deps (such as projen)
steps.push({ exec: this._project.package.installAndUpdateLockfileCommand });
// run upgrade command to upgrade transitive deps as well
steps.push({
exec: this.renderUpgradePackagesCommand(this.options.include),
});
// run "projen" to give projen a chance to update dependencies (it will also run "yarn install")
steps.push({ exec: this._project.projenCommand });
steps.push({ spawn: this.postUpgradeTask.name });
return steps;
}
private createWorkflow(task: Task, gh: github.GitHub, branch?: string): github.GithubWorkflow {
const schedule = this.options.workflowOptions?.schedule ?? UpgradeDependenciesSchedule.DAILY;
const workflowName = `${task.name}${branch ? `-${branch.replace(/\//g, '-')}` : ''}`;
const workflow = gh.addWorkflow(workflowName);
const triggers: github.workflows.Triggers = {
workflowDispatch: {},
schedule: schedule.cron.length > 0 ? schedule.cron.map((e) => ({ cron: e })) : undefined,
};
workflow.on(triggers);
const upgrade = this.createUpgrade(task, gh, branch);
const pr = this.createPr(workflow, upgrade);
const jobs: Record<string, github.workflows.Job> = {};
jobs[upgrade.jobId] = upgrade.job;
jobs[pr.jobId] = pr.job;
workflow.addJobs(jobs);
return workflow;
}
private createUpgrade(task: Task, gh: github.GitHub, branch?: string): Upgrade {
const runsOn = this.options.workflowOptions?.runsOn ?? ['ubuntu-latest'];
const with_ = {
...(branch ? { ref: branch } : {}),
...(gh.downloadLfs ? { lfs: true } : {}),
};
const steps: github.workflows.JobStep[] = [
{
name: 'Checkout',
uses: 'actions/checkout@v3',
with: Object.keys(with_).length > 0 ? with_ : undefined,
},
...this._project.renderWorkflowSetup({ mutable: false }),
...(branch && branch !== 'main'
? [
{
env: {
// Important: this ensures `yarn projen` runs `yarn install` and not `yarn install:ci` so it can update
// the yarn.lock file.
CI: 'false',
},
name: 'Back-port projenrc changes from main',
run: 'git fetch origin main && git checkout FETCH_HEAD -- README.md && yarn projen',
},
]
: []),
{
name: 'Upgrade dependencies',
run: this._project.runTaskCommand(task),
},
];
steps.push(...this.postBuildSteps);
steps.push(
...github.WorkflowActions.uploadGitPatch({
stepId: CREATE_PATCH_STEP_ID,
outputName: PATCH_CREATED_OUTPUT,
}),
);
return {
job: {
name: 'Upgrade',
container: this.containerOptions,
permissions: this.permissions,
runsOn: runsOn ?? ['ubuntu-latest'],
steps: steps,
outputs: {
[PATCH_CREATED_OUTPUT]: {
stepId: CREATE_PATCH_STEP_ID,
outputName: PATCH_CREATED_OUTPUT,
},
},
},
jobId: 'upgrade',
ref: branch,
};
}
private createPr(workflow: github.GithubWorkflow, upgrade: Upgrade): PR {
const credentials = this.options.workflowOptions?.projenCredentials ?? workflow.projenCredentials;
return {
job: github.WorkflowJobs.pullRequestFromPatch({
patch: {
jobId: upgrade.jobId,
outputName: PATCH_CREATED_OUTPUT,
ref: upgrade.ref,
},
workflowName: workflow.name,
credentials,
runsOn: this.options.workflowOptions?.runsOn,
pullRequestTitle: `chore(deps): ${this.pullRequestTitle}`,
pullRequestDescription: 'Upgrades project dependencies.',
gitIdentity: this.gitIdentity,
assignees: this.options.workflowOptions?.assignees,
labels: this.options.workflowOptions?.labels,
signoff: this.options.signoff,
}),
jobId: 'pr',
};
}
/**
* Render a package manager specific command to upgrade all requested dependencies.
*/
private renderUpgradePackagesCommand(include?: string[]): string {
function upgradePackages(command: string) {
return () => {
return `${command} ${(include ?? []).join(' ')}`.trim();
};
}
const packageManager = this._project.package.packageManager;
let lazy;
switch (packageManager) {
case NodePackageManager.YARN:
case NodePackageManager.YARN2:
case NodePackageManager.YARN_CLASSIC:
case NodePackageManager.YARN_BERRY:
lazy = upgradePackages('yarn upgrade');
break;
case NodePackageManager.NPM:
lazy = upgradePackages('npm update');
break;
case NodePackageManager.PNPM:
lazy = upgradePackages('pnpm update');
break;
default:
throw new Error(`unexpected package manager ${packageManager}`);
}
// return a lazy function so that dependencies include ones that were
// added post project instantiation (i.e using project.addDeps)
return lazy as unknown as string;
}
}
interface Upgrade {
readonly ref?: string;
readonly job: github.workflows.Job;
readonly jobId: string;
}
interface PR {
readonly job: github.workflows.Job;
readonly jobId: string;
}
/**
* Options for `UpgradeDependencies.workflowOptions`.
*/
export interface UpgradeDependenciesWorkflowOptions {
/**
* Schedule to run on.
*
* @default UpgradeDependenciesSchedule.DAILY
*/
readonly schedule?: UpgradeDependenciesSchedule;
/**
* Choose a method for authenticating with GitHub for creating the PR.
*
* When using the default github token, PR's created by this workflow
* will not trigger any subsequent workflows (i.e the build workflow), so
* projen requires API access to be provided through e.g. a personal
* access token or other method.
*
* @see https://github.com/peter-evans/create-pull-request/issues/48
* @default - personal access token named PROJEN_GITHUB_TOKEN
*/
readonly projenCredentials?: github.GithubCredentials;
/**
* Labels to apply on the PR.
*
* @default - no labels.
*/
readonly labels?: string[];
/**
* Assignees to add on the PR.
*
* @default - no assignees
*/
readonly assignees?: string[];
/**
* Job container options.
*
* @default - defaults
*/
readonly container?: github.workflows.ContainerOptions;
/**
* List of branches to create PR's for.
*
* @default - All release branches configured for the project.
*/
readonly branches?: string[];
/**
* The git identity to use for commits.
* @default "github-actions@github.com"
*/
readonly gitIdentity?: github.GitIdentity;
/**
* Github Runner selection labels
* @default ["ubuntu-latest"]
*/
readonly runsOn?: string[];
/**
* Permissions granted to the upgrade job
* To limit job permissions for `contents`, the desired permissions have to be explicitly set, e.g.: `{ contents: JobPermission.NONE }`
* @default `{ contents: JobPermission.READ }`
*/
readonly permissions?: github.workflows.JobPermissions;
}
/**
* How often to check for new versions and raise pull requests for version upgrades.
*/
export class UpgradeDependenciesSchedule {
/**
* Disables automatic upgrades.
*/
public static readonly NEVER = new UpgradeDependenciesSchedule([]);
/**
* At 00:00.
*/
public static readonly DAILY = new UpgradeDependenciesSchedule(['0 0 * * *']);
/**
* At 00:00 on every day-of-week from Monday through Friday.
*/
public static readonly WEEKDAY = new UpgradeDependenciesSchedule(['0 0 * * 1-5']);
/**
* At 00:00 on Monday.
*/
public static readonly WEEKLY = new UpgradeDependenciesSchedule(['0 0 * * 1']);
/**
* At 00:00 on day-of-month 1.
*/
public static readonly MONTHLY = new UpgradeDependenciesSchedule(['0 0 1 * *']);
/**
* Create a schedule from a raw cron expression.
*/
public static expressions(cron: string[]) {
return new UpgradeDependenciesSchedule(cron);
}
private constructor(public readonly cron: string[]) {}
}