mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 10:09:50 +00:00
4b4d14ec00
* feat(manager): pass extracted constraints to updateArtifacts `extractedConstraints` were collected during extraction but only ever read by `fetch.ts` for the lookup. At artifact time every manager had to derive the same tool constraints a second time, and when the updated package file no longer carried the constraint there was nothing to fall back to. `generateBranchConfig()` now merges the `extractedConstraints` of all upgrades of a branch onto the branch config, which reaches `updateArtifacts()` through `patchConfigForArtifactsUpdate()`. `UpdateArtifactsConfig` declares the field and documents what it contains. The new `resolveToolConstraint()` helper implements the ladder once: user `constraints` > an optional artifact-time derivation > the extracted value. The derivation is ranked above the extracted value on purpose: it reads the package files as the branch changed them, so poetry (python is an updatable dependency there), composer, pipenv (`pipenv` can be a dev package) and pep621 (`requires-python` is an updatable dependency) would otherwise regress to a stale constraint. pipenv, pep621 uv/pdm, poetry and composer are migrated to the helper and keep their derivation. Behaviour change: these managers now use the extract-time constraint as a last resort instead of running unconstrained, for example on lock file maintenance where pipenv deletes `Pipfile.lock` before deriving. `execute-post-upgrade-commands.ts` is unaffected: it reads `upgrade.constraints` per upgrade, and the extracted values are kept in a separate field rather than folded into `constraints`, so post-upgrade task tool constraints still come from user config only. Not migrated, because their constraints are never extracted, so the fallback would be dead code: bundler (`ruby`/`bundler`), pixi (`pixi`), pep621 uv (`required-version`) and npm's node ladder, whose last step already reads `package.json` engines. * refactor(manager): resolve tool constraints through resolveToolConstraint() Every manager now obtains the constraint of a tool it runs through `resolveToolConstraint()` instead of reading `config.constraints` itself, so the precedence "user config, then a value derived from the updated files, then the constraints collected during extraction" applies everywhere. The helper treats an empty string as not set at every step and accepts the `PostUpdateConfig` shape, whose `constraints` may be `null`. Behaviour changes that go beyond the new fallback to extracted constraints: - `mix` applies its hardcoded erlang default only after the extracted constraint, so an erlang version collected at extraction can win over it. - `kustomize` decides `HELM_EXPERIMENTAL_OCI` from the fully resolved helm constraint; `generateHelmEnvs()` takes that value instead of the config. - `npm` (yarn): an upgrade of yarn without a new value now consults the user constraint before deriving one from package.json or the lock file. - The "Using <tool> constraint from config" debug lines in bundler, composer, copier and pip-compile are gone, as the helper is the only reader of the config now. Helpers that read the config synchronously became async: bundler `getBundlerConstraint()`, copier `getPythonVersionConstraint()` and `getCopierVersionConstraint()`, pip-compile `get*VersionConstraint()` and `getToolVersionConstraint()`, gomod `deriveGoToolchainConstraints()` and `getUpdateImportPathCmds()`, mise `getMiseLockToolConstraints()`. composer `getPhpConstraint()` is removed. Each converted manager has a test showing the extracted constraint reaching the executed tool, and, where a derive callback exists, that the derived value still wins over it. * refactor(linters): add prefer-resolve-tool-constraint rule Reports member reads and destructuring of `constraints` and `extractedConstraints` from a manager config in `lib/modules/manager/**`, so that tool constraints are resolved through `resolveToolConstraint()` in `lib/modules/manager/util.ts` only. Writes and the same properties on package files, which extraction has to populate, are not reported. The helper itself and spec files are exempt in `.oxlintrc.json`. * refactor(manager): inline thin resolveToolConstraint wrappers Drop copier/utils.ts and the pip-compile getPipToolsVersionConstraint / getUvVersionConstraint helpers, which only forwarded to resolveToolConstraint(). Callers now call it directly. * docs(development): note lock file maintenance in tool constraints section * refactor(manager): address review on tool constraint resolution composer only runs extractConstraints() when a user constraint is missing, via Lazy. UpdateArtifactsConfig no longer re-declares constraints and extractedConstraints, which ToolConstraintsConfig already provides.
185 lines
5.0 KiB
TypeScript
185 lines
5.0 KiB
TypeScript
import { quote } from 'shlex';
|
|
import upath from 'upath';
|
|
import { GlobalConfig } from '../../../config/global.ts';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import type { ExecOptions } from '../../../util/exec/types.ts';
|
|
import { readLocalFile, statLocalFile } from '../../../util/fs/index.ts';
|
|
import { withGitEnvironment } from '../../../util/git/exec.ts';
|
|
import { getRepoStatus, isFileModeEnabled } from '../../../util/git/index.ts';
|
|
import type {
|
|
UpdateArtifact,
|
|
UpdateArtifactsConfig,
|
|
UpdateArtifactsResult,
|
|
} from '../types.ts';
|
|
import { resolveToolConstraint } from '../util.ts';
|
|
|
|
const DEFAULT_COMMAND_OPTIONS = ['--skip-answered', '--defaults'];
|
|
const ownerExecutePermission = 0o100;
|
|
const gitExec = withGitEnvironment(['git-tags']);
|
|
|
|
async function detectExecutable(
|
|
path: string,
|
|
canReadFileMode: boolean,
|
|
): Promise<true | undefined> {
|
|
if (!canReadFileMode) {
|
|
return undefined;
|
|
}
|
|
|
|
const fileStats = await statLocalFile(path);
|
|
if (!fileStats?.isFile()) {
|
|
return undefined;
|
|
}
|
|
|
|
if ((fileStats.mode & ownerExecutePermission) === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// Git derives its executable flag from the owner's execute permission.
|
|
return true;
|
|
}
|
|
|
|
function buildCommand(
|
|
config: UpdateArtifactsConfig,
|
|
packageFileName: string,
|
|
newValue: string,
|
|
): string {
|
|
const command = ['copier', 'update', ...DEFAULT_COMMAND_OPTIONS];
|
|
if (GlobalConfig.get('allowScripts') && !config.ignoreScripts) {
|
|
command.push('--trust');
|
|
}
|
|
command.push(
|
|
'--answers-file',
|
|
quote(upath.basename(packageFileName)),
|
|
'--vcs-ref',
|
|
quote(newValue),
|
|
);
|
|
return command.join(' ');
|
|
}
|
|
|
|
function artifactError(
|
|
packageFileName: string,
|
|
message: string,
|
|
): UpdateArtifactsResult[] {
|
|
return [
|
|
{
|
|
artifactError: {
|
|
fileName: packageFileName,
|
|
stderr: message,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function updateArtifacts({
|
|
packageFileName,
|
|
updatedDeps,
|
|
config,
|
|
}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
|
|
if (updatedDeps?.length !== 1) {
|
|
// Each answers file (~ packageFileName) has exactly one dependency to update.
|
|
return artifactError(
|
|
packageFileName,
|
|
`Unexpected number of dependencies: ${updatedDeps?.length} (should be 1)`,
|
|
);
|
|
}
|
|
|
|
const newValue = updatedDeps[0]?.newValue;
|
|
if (!newValue) {
|
|
return artifactError(
|
|
packageFileName,
|
|
'Missing copier template version to update to',
|
|
);
|
|
}
|
|
|
|
const command = buildCommand(config, packageFileName, newValue);
|
|
const execOptions: ExecOptions = {
|
|
cwdFile: packageFileName,
|
|
docker: {},
|
|
toolConstraints: [
|
|
{
|
|
toolName: 'python',
|
|
constraint: await resolveToolConstraint(config, 'python'),
|
|
},
|
|
{
|
|
toolName: 'copier',
|
|
constraint: await resolveToolConstraint(config, 'copier'),
|
|
},
|
|
],
|
|
};
|
|
try {
|
|
await gitExec(command, execOptions);
|
|
} catch (err) {
|
|
logger.debug({ err }, `Failed to update copier template: ${err.message}`);
|
|
return artifactError(packageFileName, err.message);
|
|
}
|
|
|
|
const status = await getRepoStatus();
|
|
// If the answers file didn't change, Copier did not update anything.
|
|
if (!status.modified.includes(packageFileName)) {
|
|
return null;
|
|
}
|
|
|
|
const res: UpdateArtifactsResult[] = [];
|
|
|
|
if (status.conflicted.length > 0) {
|
|
// Sometimes, Copier erroneously reports conflicts.
|
|
const msg = `Updating the Copier template yielded ${status.conflicted.length} merge conflicts. Please check the proposed changes carefully! Conflicting files:\n * ${status.conflicted.join('\n * ')}`;
|
|
logger.debug({ packageFileName, depName: updatedDeps[0]?.depName }, msg);
|
|
res.push(...artifactError(packageFileName, msg));
|
|
}
|
|
|
|
const canReadFileMode = await isFileModeEnabled();
|
|
|
|
for (const f of [
|
|
...status.modified,
|
|
...status.not_added,
|
|
...status.conflicted,
|
|
]) {
|
|
const fileRes: UpdateArtifactsResult = {
|
|
file: {
|
|
type: 'addition',
|
|
path: f,
|
|
contents: await readLocalFile(f),
|
|
isExecutable: await detectExecutable(f, canReadFileMode),
|
|
},
|
|
};
|
|
if (status.conflicted.includes(f)) {
|
|
// Make the reviewer aware of the conflicts.
|
|
// This will be posted in a comment.
|
|
fileRes.notice = {
|
|
file: f,
|
|
message:
|
|
'This file had merge conflicts. Please check the proposed changes carefully!',
|
|
};
|
|
}
|
|
res.push(fileRes);
|
|
}
|
|
for (const f of status.deleted) {
|
|
res.push({
|
|
file: {
|
|
type: 'deletion',
|
|
path: f,
|
|
},
|
|
});
|
|
}
|
|
// `git status` might detect a rename, which is then not contained
|
|
// in not_added/deleted. Ensure we respect renames as well if they happen.
|
|
for (const f of status.renamed) {
|
|
res.push({
|
|
file: {
|
|
type: 'deletion',
|
|
path: f.from,
|
|
},
|
|
});
|
|
res.push({
|
|
file: {
|
|
type: 'addition',
|
|
path: f.to,
|
|
contents: await readLocalFile(f.to),
|
|
isExecutable: await detectExecutable(f.to, canReadFileMode),
|
|
},
|
|
});
|
|
}
|
|
return res;
|
|
}
|