name: write-upgrade description: Use when writing a new Webiny upgrade script for a specific version in the webiny-upgrades-v6 project.
Writing a Webiny Upgrade Script
File Structure
Every upgrade lives in src/upgrades/<version>/ and consists of exactly two files:
src/upgrades/6.2.0/
Upgrade.ts # implementation
index.ts # feature export (default export)
Use relative imports throughout — ~/ aliases are not reliable here.
Upgrade.ts
Implement Upgrade.Interface — a version property and two methods: canHandle and execute.
canHandle returns true when this upgrade's version falls within the range (currentVersion, targetVersion] — strictly greater than currentVersion and less than or equal to targetVersion.
currentVersionis the user's installed Webiny version. It acts as a lower bound so that upgrades at or below the user's current version are skipped. The handler advancescurrentVersionafter each successful upgrade step, so when running6.1.0 → 6.3.0, the 6.2.0 upgrade seescurrentVersion=6.1.0and the 6.3.0 upgrade seescurrentVersion=6.2.0.targetVersionis the version the user wants to reach. It caps the upper bound so only relevant upgrades run.
Both bounds are required — without currentVersion, older upgrades would re-run on every invocation. The handler also checks upgrade history and skips already-executed upgrades as a second layer of protection. Do not use registry checks — the version does not exist on npm yet when the upgrade is being written.
import { Upgrade as UpgradeAbstraction } from "../../base/Upgrade/index.js";
import { PackageJsonTool } from "../../tool/PackageJsonTool/index.js";
import { WebinyConfigTool } from "../../tool/WebinyConfigTool/index.js";
import { Version } from "../../base/Version/index.js";
class UpgradeImpl implements UpgradeAbstraction.Interface {
public readonly version = Version.create("6.2.0");
public constructor(
private readonly packageJsonTool: PackageJsonTool.Interface,
private readonly webinyConfigTool: WebinyConfigTool.Interface
) {}
public async canHandle({ targetVersion, currentVersion }: UpgradeAbstraction.Params): Promise<boolean> {
return this.version.between(currentVersion, targetVersion);
}
public async execute(): Promise<void> {
const packageJson = this.packageJsonTool.loadOrThrow();
// Version-specific transformations go here.
this.packageJsonTool.save(packageJson);
}
}
export const Upgrade = UpgradeAbstraction.createImplementation({
implementation: UpgradeImpl,
dependencies: [PackageJsonTool, WebinyConfigTool]
});
Only include WebinyConfigTool in dependencies if the upgrade actually modifies webiny.config.tsx.
index.ts
import { createFeature } from "../../utils/createFeature.js";
import { Upgrade } from "./Upgrade.js";
export default createFeature({
name: "Upgrade 6.2.0",
register(container) {
container.register(Upgrade);
}
});
Available Dependencies
Declare these in the dependencies array of createImplementation. They are resolved from the DI container automatically.
| Abstraction | Import (relative from src/upgrades/<version>/) |
Description |
|---|---|---|
Context |
../../base/Context/index.js |
cwd, registry, inputVersion, targetVersion, installedVersion (read-once from disk), currentVersion (logical — advances after each upgrade step), resolve() |
Logger |
../../base/Logger/index.js |
debug, info, warn, error, fatal, done — standard pino levels + done (emits info with _done metadata; JSON transport maps to type: "done") |
PackageJsonTool |
../../tool/PackageJsonTool/index.js |
Higher-level package.json ops scoped to cwd. load(target?: string): PackageJsonFile | null, loadOrThrow(target?: string): PackageJsonFile (throws on failure — prefer this over load + null guard), save(file): void. See PackageJsonFile API below. |
WebinyConfigTool |
../../tool/WebinyConfigTool/index.js |
Reads and mutates webiny.config.tsx via ts-morph AST. read(): WebinyConfigFile (throws if not found), save(file): void. The returned file exposes file.imports and file.jsx sub-objects. See WebinyConfigFile API below. |
PackageJsonService |
../../service/PackageJson/index.js |
Low-level load/save for any package.json path. load(target: string): PackageJsonFile | null, loadOrThrow(target: string): PackageJsonFile, save(file): void. Same PackageJsonFile API as above. |
DependencyGuard |
../../tool/DependencyGuard/index.js |
execute(): Mismatch[] — reads node_modules/@webiny/cli/files/references.json (synchronous), compares against user's package.json (all four sections), strips ranges, returns Mismatch[] where each entry is { name, userVersion, expectedVersion } (empty array = no mismatches). |
YarnrcGuard |
../../tool/YarnrcGuard/index.js |
execute({ targetVersion, breakOnVersion }): void — reads .yarnrc.yml from cwd, checks four required security settings (approvedGitRepositories, enableScripts, npmMinimalAgeGate, npmPreapprovedPackages). Logs info when targetVersion < breakOnVersion; throws YarnrcGuardError when >= breakOnVersion. Not typically used in upgrade scripts — runs automatically in the Application layer before upgrades. |
UpgradeHistory |
../../tool/UpgradeHistory/index.js |
add(version), remove(version), get(version): Entry | null, list(): Entry[] — reads/writes webiny.history array in package.json. Each entry has { version, timestamp }. Managed by the handler automatically. |
PackageManagerService |
../../service/PackageManager/index.js |
install(), version(), name(): "yarn" | "pnpm" | "npm" — use name() to branch on the user's package manager without touching the filesystem directly. |
ReferencesService |
../../service/References/index.js |
getReference(name): IReference | null, getVersion(name): string | null — looks up canonical package versions from references.json. |
RegistryService |
../../service/Registry/index.js |
getLatestVersion(name: string): Promise<Version | null> — resolves the current latest dist-tag. getVersion(name: string, version: string | Version): Promise<Version | null> — resolves a specific version. |
WebinyConfigFile API
The object returned by WebinyConfigTool.read():
// imports sub-object
file.imports.add(options: ImportOptions): void
file.imports.remove(options: RemoveImportOptions): void
// jsx sub-object
file.jsx.addChild(tag: string, options?: ChildOptions): void
file.jsx.insertBefore(ref: string, tag: string, options?: ChildOptions): void
file.jsx.insertAfter(ref: string, tag: string, options?: ChildOptions): void
// file
file.save(): void
type ImportEntry = string | Record<string, string>;
interface ImportOptions {
package: string;
imports: ImportEntry[]; // plain string or { originalName: localAlias }
}
interface RemoveImportOptions {
package: string;
imports?: string[]; // omit to remove the entire declaration
}
interface ChildOptions {
comment?: string; // renders as {/* comment */} above the element
props?: Record<string, string>; // expression syntax: { passphrase: 'process.env.X || ""' }
children?: (jsx: Jsx) => void; // nested children callback
}
jsx.addChild behaviour:
- Not found → inserts after the last JSX fragment child (self-closing if no
children, block element ifchildrenprovided) - Found, no
childrencallback → logs a warning and skips (never creates duplicates) - Found,
childrencallback provided → structural merge: recurses into the existing element
jsx.insertBefore(ref, tag, options) / jsx.insertAfter(ref, tag, options) behaviour:
refnot found → warns and falls back to append at endtagalready exists → warns and no-ops — no structural merge, even ifoptions.childrenprovided- Normal path → inserts
tagimmediately before/after the first occurrence ofref; indent is inferred fromref's column offset - Available at every nesting level via the
Jsxobject passed to achildrencallback
PackageJsonFile API
The object returned by PackageJsonTool.load() or PackageJsonService.load():
// read
file.getDependencies(): Record<string, string>
file.getDevDependencies(): Record<string, string>
file.getPeerDependencies(): Record<string, string>
file.getResolutions(): Record<string, string>
file.getVersion(): string | null
// dependencies
file.getDependency(name: string): string | null
file.setDependency(name: string, version: string | Version): void
file.removeDependency(name: string): void
// devDependencies
file.getDevDependency(name: string): string | null
file.setDevDependency(name: string, version: string | Version): void
file.removeDevDependency(name: string): void
// peerDependencies
file.getPeerDependency(name: string): string | null
file.setPeerDependency(name: string, version: string | Version): void
file.removePeerDependency(name: string): void
// resolutions
file.getResolution(name: string): string | null
file.setResolution(name: string, version: string | Version): void
file.removeResolution(name: string): void
// arbitrary fields
file.get(key: string): unknown
file.set(key: string, value: unknown): void
Testing
Every upgrade needs both a unit test and an integration test.
Unit test (Upgrade.test.ts)
Next to Upgrade.ts. Mocks PackageJsonTool via registerUpgradeDeps. Uses the canonical createMockPackageJsonFile from src/__tests__/utils/. If the upgrade uses WebinyConfigTool, register a mock instance directly in createContainer (not in registerUpgradeDeps). Existing examples: src/upgrades/6.3.0/Upgrade.test.ts.
Integration test (Upgrade.integration.test.ts)
Next to Upgrade.ts. Uses the real UpgradeHandler + UpgradeRunner pipeline against a fixture package.json copied into a tmpdir.
Layout:
src/upgrades/<version>/
├── Upgrade.ts
├── Upgrade.test.ts
├── Upgrade.integration.test.ts
├── __tests__/
│ └── fixtures/
│ └── before/
│ └── package.json ← hand-written, minimal, self-contained
└── index.ts
Test shape:
import { describe, it, expect } from "vitest";
import path from "node:path";
import { createUpgradeIntegrationHarness } from "../../__tests__/utils/createUpgradeIntegrationHarness.js";
const fixtureDir = path.join(import.meta.dirname, "__tests__", "fixtures", "before");
describe("Upgrade 6.x.0 - integration", () => {
it("applies its transformations and pins @webiny/* to the target version", async () => {
const harness = await createUpgradeIntegrationHarness({
fixtureDir,
currentVersion: "6.(x-1).0",
targetVersion: "6.x.0"
});
await harness.run();
const pkg = harness.readPackageJson();
// assert upgrade-specific transformations
expect(pkg.dependencies?.["@webiny/cli"]).toBe("6.x.0");
expect(harness.upgradeHistory.list()).toContainEqual(
expect.objectContaining({ version: "6.x.0" })
);
});
});
Chain test
After shipping a new upgrade, bump targetVersion and extend assertions in src/__tests__/integration/chain.test.ts.
Coverage
Thresholds in vitest.config.ts enforce 100% statements / functions / lines and 98% branches. yarn test:coverage fails if any threshold regresses.
Post-Task Sequence
After every change, run:
yarn && yarn build && yarn adio:check && yarn format:fix && yarn lint:fix && yarn test:coverage
If any step fails, fix the issue and re-run the full chain.
Fix Upgrades
To ship a bugfix for an already-released upgrade (e.g. 6.1.0), create a new upgrade with a pre-release version like 6.1.0-fix.0. History matching is exact on version.raw, so 6.1.0-fix.0 runs even when 6.1.0 is already in history.
Rules
canHandlemust returnthis.version.between(currentVersion, targetVersion)— this upgrade's hardcoded version must fall in the range- Do NOT call
upWebiny.execute()in an upgrade — the handler pins all@webiny/*packages to the target version after all upgrade steps complete - Never check the npm registry in
canHandleorexecute— the version does not exist yet - Always inject dependencies, never instantiate services directly
- Use relative imports, not
~/ - Windows compatibility: use
path.join()for all file paths, never string concatenation or hardcoded slashes