import { isEmptyArray, isString } from '@sindresorhus/is'; import { quote } from 'shlex'; import { GlobalConfig } from '../../../config/global.ts'; import { TEMPORARY_ERROR } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import { exec } from '../../../util/exec/index.ts'; import type { ExecOptions } from '../../../util/exec/types.ts'; import { deleteLocalFile, ensureCacheDir, findLocalSiblingOrParent, getSiblingFileName, localPathExists, readLocalFile, writeLocalFile, } from '../../../util/fs/index.ts'; import * as hostRules from '../../../util/host-rules.ts'; import { regEx } from '../../../util/regex.ts'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types.ts'; const hexRepoUrl = 'https://hex.pm/'; const hexRepoOrgUrlRegex = regEx( `^https://hex\\.pm/api/repos/(?[a-z0-9_]+)/$`, ); export async function updateArtifacts({ packageFileName, updatedDeps, newPackageFileContent, config, }: UpdateArtifact): Promise { logger.debug(`mix.getArtifacts(${packageFileName})`); const { isLockFileMaintenance } = config; if (isEmptyArray(updatedDeps) && !isLockFileMaintenance) { logger.debug('No updated mix deps'); return null; } let lockFileName = getSiblingFileName(packageFileName, 'mix.lock'); let isUmbrella = false; let existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); if (!existingLockFileContent) { const lockFileError = await checkLockFileReadError(lockFileName); if (lockFileError) { return lockFileError; } const parentLockFileName = await findLocalSiblingOrParent( packageFileName, 'mix.lock', ); existingLockFileContent = parentLockFileName && (await readLocalFile(parentLockFileName, 'utf8')); if (parentLockFileName && existingLockFileContent) { lockFileName = parentLockFileName; isUmbrella = true; } else if (parentLockFileName) { const lockFileError = await checkLockFileReadError(parentLockFileName); if (lockFileError) { return lockFileError; } } } if (isLockFileMaintenance && isUmbrella) { logger.debug( `Cannot use lockFileMaintenance in an umbrella project, see ${GlobalConfig.get('productLinks').documentation}modules/manager/mix/#lockFileMaintenance`, ); return null; } if (isLockFileMaintenance && !existingLockFileContent) { logger.debug( 'Cannot use lockFileMaintenance when no mix.lock file is present', ); return null; } try { await writeLocalFile(packageFileName, newPackageFileContent); if (isLockFileMaintenance) { await deleteLocalFile(lockFileName); } } catch (err) { logger.warn({ err }, 'mix.exs could not be written'); return [ { artifactError: { fileName: lockFileName, stderr: err.message, }, }, ]; } if (!existingLockFileContent) { logger.debug('No mix.lock found'); return null; } const organizations = new Set(); const hexHostRulesWithMatchHost = hostRules .getAll() .filter( (hostRule) => !!hostRule.matchHost && hexRepoOrgUrlRegex.test(hostRule.matchHost), ); for (const { matchHost } of hexHostRulesWithMatchHost) { if (matchHost) { const result = hexRepoOrgUrlRegex.exec(matchHost); if (result?.groups) { const { organization } = result.groups; organizations.add(organization); } } } for (const { packageName } of updatedDeps) { if (packageName) { const [, organization] = packageName.split(':'); if (organization) { organizations.add(organization); } } } const preCommands = Array.from(organizations).reduce((acc, organization) => { const url = `${hexRepoUrl}api/repos/${organization}/`; const { token } = hostRules.find({ url }); if (token) { logger.debug(`Authenticating to hex organization ${organization}`); const authCommand = `mix hex.organization auth ${quote(organization)} --key ${quote(token)}`; return [...acc, authCommand]; } return acc; }, [] as string[]); // renovate: will update this const erlangVersion = '26'; const execOptions: ExecOptions = { extraEnv: { // https://hexdocs.pm/mix/1.15.0/Mix.Tasks.Archive.html // TODO: should include a version constraint MIX_ARCHIVES: await ensureCacheDir('mix_archives'), }, cwdFile: lockFileName, docker: {}, toolConstraints: [ { toolName: 'erlang', // https://hexdocs.pm/elixir/1.14.5/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp constraint: config.constraints?.erlang ?? `^${erlangVersion}`, }, { toolName: 'elixir', constraint: config.constraints?.elixir, }, ], preCommands, }; let command: string; if (isLockFileMaintenance) { command = 'mix deps.get'; } else { command = [ 'mix', 'deps.update', ...updatedDeps .map((dep) => dep.depName) .filter(isString) .map((dep) => quote(dep)), ].join(' '); } try { await exec(command, execOptions); } catch (err) { /* v8 ignore if -- defensive rethrow of TEMPORARY_ERROR from exec, not reproduced in mix specs */ if (err.message === TEMPORARY_ERROR) { throw err; } logger.debug( { err, message: err.message, command }, 'Failed to update Mix lock file', ); return [ { artifactError: { fileName: lockFileName, stderr: err.message, }, }, ]; } const newMixLockContent = await readLocalFile(lockFileName, 'utf8'); if (existingLockFileContent === newMixLockContent) { logger.debug('mix.lock is unchanged'); return null; } logger.debug('Returning updated mix.lock'); return [ { file: { type: 'addition', path: lockFileName, contents: newMixLockContent, }, }, ]; } async function checkLockFileReadError( lockFileName: string, ): Promise { if (await localPathExists(lockFileName)) { return [ { artifactError: { fileName: lockFileName, stderr: `Error reading ${lockFileName}`, }, }, ]; } return null; }