Files
fxwx23 8a378fc5b4 fix(manager/swift): don't rewrite Package.resolved pins in unrelated packages (#44317)
For exact (single-version) specs, only update a pin whose current version
matches the version being upgraded from. This prevents the repo-wide
Package.resolved sweep from rewriting a same-named transitive pin in an
unrelated package to a version that package's constraints don't allow.

Co-authored-by: Jamie Tanna <jamie.tanna@mend.io>
2026-09-18 11:02:17 +00:00

284 lines
8.1 KiB
TypeScript

import { logger } from '../../../logger/index.ts';
import { readLocalFile } from '../../../util/fs/index.ts';
import { parseGitUrl } from '../../../util/git/url.ts';
import { regEx } from '../../../util/regex.ts';
import { trimTrailingSlash } from '../../../util/url.ts';
import { GitTagsDatasource } from '../../datasource/git-tags/index.ts';
import { getDigest } from '../../datasource/index.ts';
import { scm } from '../../platform/scm.ts';
import { api as swiftVersioning } from '../../versioning/swift/index.ts';
import type {
PackageDependency,
UpdateArtifact,
UpdateArtifactsResult,
Upgrade,
} from '../types.ts';
import type { PackageResolvedPin } from './schema.ts';
import { PackageResolvedJson } from './schema.ts';
async function findPackageResolvedFiles(): Promise<string[]> {
const fileList = await scm.getFileList();
return fileList.filter((f) => f.endsWith('Package.resolved'));
}
function normalizeUrl(url: string): string {
try {
const parsed = parseGitUrl(url);
// Only use parsed result when both resource and full_name are present,
// otherwise fall through to basic normalization
if (parsed.resource && parsed.full_name) {
// Normalize to host/full_name form to allow cross-protocol matching
// e.g. https://github.com/owner/repo, git@github.com:owner/repo.git
// -> github.com/owner/repo
return `${parsed.resource}/${trimTrailingSlash(parsed.full_name)}`.toLowerCase();
}
} catch {
// Fall through to basic normalization
}
return url
.replace(regEx(/\.git$/), '')
.replace(regEx(/\/$/), '')
.toLowerCase();
}
function matchPinForDep(
dep: PackageDependency,
pins: PackageResolvedPin[],
): PackageResolvedPin | null {
let depUrl: string;
if (dep.datasource === GitTagsDatasource.id) {
/* v8 ignore next -- extract always sets depName for a git-tags dep */
depUrl = dep.depName ?? '';
} else {
const registryUrl = dep.registryUrls?.[0] ?? 'https://github.com';
depUrl = `${registryUrl}/${dep.depName}`;
}
const normalizedDepUrl = normalizeUrl(depUrl);
return (
pins.find((pin) => normalizeUrl(pin.location) === normalizedDepUrl) ?? null
);
}
async function resolveCommitSha(
dep: Upgrade,
newVersion: string,
): Promise<string | null> {
if (dep.newDigest) {
return dep.newDigest;
}
const datasource = dep.datasource;
const packageName = dep.packageName ?? dep.depName;
if (!datasource || !packageName) {
return null;
}
try {
const digest = await getDigest(
{
datasource,
packageName,
registryUrls: dep.registryUrls,
},
newVersion,
);
return digest;
} catch (err) {
logger.debug(
{ err, packageName, newVersion },
'swift: failed to resolve commit SHA',
);
return null;
}
}
function updatePinInJson(
content: string,
pin: PackageResolvedPin,
newVersion: string,
newRevision: string | null,
): string {
let updated = content;
// Find the pin block by its unique identity value
const identityPattern = regEx(
`"identity"\\s*:\\s*"${RegExp.escape(pin.identity)}"`,
);
const identityMatch = identityPattern.exec(updated);
/* istanbul ignore if: identity always exists after JSON parse + matchPinForDep */
if (!identityMatch) {
return content;
}
// Find the enclosing object block (search for the opening brace before identity)
const beforeIdentity = updated.slice(0, identityMatch.index);
const blockStart = beforeIdentity.lastIndexOf('{');
/* istanbul ignore if: valid JSON always has enclosing braces */
if (blockStart === -1) {
return content;
}
// Find the closing brace of this pin block (handle nested braces)
let braceDepth = 0;
let blockEnd = -1;
for (let i = blockStart; i < updated.length; i++) {
if (updated[i] === '{') {
braceDepth++;
} else if (updated[i] === '}') {
braceDepth--;
if (braceDepth === 0) {
blockEnd = i + 1;
break;
}
}
}
/* istanbul ignore if: valid JSON always has matching braces */
if (blockEnd === -1) {
return content;
}
let pinBlock = updated.slice(blockStart, blockEnd);
// Replace version within the pin block
const versionPattern = regEx(/(?<prefix>"version"\s*:\s*)"[^"]*"/);
pinBlock = pinBlock.replace(versionPattern, `$<prefix>"${newVersion}"`);
// Replace revision within the pin block if we have a new one
if (newRevision) {
const revisionPattern = regEx(/(?<prefix>"revision"\s*:\s*)"[^"]*"/);
pinBlock = pinBlock.replace(revisionPattern, `$<prefix>"${newRevision}"`);
}
updated = updated.slice(0, blockStart) + pinBlock + updated.slice(blockEnd);
return updated;
}
export async function updateArtifacts({
updatedDeps,
config,
}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
if (config.isLockFileMaintenance) {
logger.debug('swift: lockFileMaintenance is not supported');
return null;
}
if (!updatedDeps.length) {
logger.debug('swift: no updatedDeps, nothing to do');
return null;
}
const resolvedFiles = await findPackageResolvedFiles();
if (!resolvedFiles.length) {
logger.debug('swift: no Package.resolved files found');
return null;
}
const results: UpdateArtifactsResult[] = [];
for (const resolvedFile of resolvedFiles) {
const content = await readLocalFile(resolvedFile, 'utf8');
if (!content) {
logger.debug({ resolvedFile }, 'swift: could not read Package.resolved');
continue;
}
const parseResult = PackageResolvedJson.safeParse(content);
if (!parseResult.success) {
logger.debug(
{ resolvedFile, err: parseResult.error },
'swift: could not parse Package.resolved',
);
continue;
}
const parsed = parseResult.data;
let updated = content;
for (const dep of updatedDeps) {
const newValue = dep.newValue;
if (!newValue) {
continue;
}
// shouldn't happen
if (!dep.newVersion) {
logger.debug(
{
depName: dep.depName,
newVersion: dep.newVersion,
newValue: dep.newValue,
},
'swift: found a newValue but not a newVersion',
);
continue;
}
const pin = matchPinForDep(dep, parsed.pins);
if (!pin) {
logger.debug(
{ depName: dep.depName },
'swift: no matching pin found in Package.resolved',
);
continue;
}
// Only rewrite a pin we can confidently attribute to the dependency being
// upgraded. For exact (single-version) specs the pin to update is the one
// currently at the from-version; a pin at any other version belongs to a
// different constraint — e.g. a transitive dependency in an unrelated
// package — and must be left untouched, otherwise we may write a version
// that package does not allow.
// https://github.com/renovatebot/renovate/issues/44312
if (
dep.currentValue &&
swiftVersioning.isSingleVersion(dep.currentValue)
) {
const fromVersion = (dep.currentVersion ?? dep.currentValue).replace(
regEx(/^v/),
'',
);
if (pin.state.version !== fromVersion) {
logger.debug(
{
depName: dep.depName,
pinVersion: pin.state.version,
currentValue: dep.currentValue,
},
'swift: pin does not match the upgraded-from version; skipping to avoid touching an unrelated pin',
);
continue;
}
}
const resolvedVersion = dep.newVersion.replace(regEx(/^v/), '');
// Skip if already up-to-date
if (pin.state.version === resolvedVersion) {
logger.debug(
{ depName: dep.depName, resolvedVersion },
'swift: pin already at target version',
);
continue;
}
const newRevision = await resolveCommitSha(dep, dep.newVersion);
updated = updatePinInJson(updated, pin, resolvedVersion, newRevision);
}
if (updated !== content) {
results.push({
file: {
type: 'addition',
path: resolvedFile,
contents: updated,
},
});
}
}
return results.length ? results : null;
}