Compare commits

...

13 commits

Author SHA1 Message Date
Janus Troelsen
b0a20e2d25
Merge 70069e21aa into 73b842fe3a 2025-01-02 12:38:51 -06:00
Michael Kriese
73b842fe3a
feat(manager/github-actions): support registry aliases (#33377) 2025-01-02 18:06:11 +00:00
Rhys Arkins
60754ce088
feat(presets): add RUSTC_BOOTSTRAP to safe global env (#33347)
Some checks are pending
Build / setup (push) Waiting to run
Build / setup-build (push) Waiting to run
Build / prefetch (push) Blocked by required conditions
Build / lint-eslint (push) Blocked by required conditions
Build / lint-prettier (push) Blocked by required conditions
Build / lint-docs (push) Blocked by required conditions
Build / lint-other (push) Blocked by required conditions
Build / (push) Blocked by required conditions
Build / codecov (push) Blocked by required conditions
Build / coverage-threshold (push) Blocked by required conditions
Build / test-success (push) Blocked by required conditions
Build / build (push) Blocked by required conditions
Build / build-docs (push) Blocked by required conditions
Build / test-e2e (push) Blocked by required conditions
Build / release (push) Blocked by required conditions
Code scanning / CodeQL-Build (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
whitesource-scan / WS_SCAN (push) Waiting to run
2025-01-02 17:20:40 +00:00
Janus Troelsen
70069e21aa chore(manager/haskell-cabal): test getRangeStrategy 2024-12-18 11:21:34 -06:00
Janus Troelsen
7575acd193 chore(manager/haskell-cabal): Run prettier again 2024-12-18 07:53:56 -06:00
Janus Troelsen
cc15bbe755 chore(manager/haskell-cabal): on rangeStrategy=auto, set widen 2024-12-18 07:51:38 -06:00
Janus Troelsen
a5c810a82d chore(versioning): PVP only handles widen. Not auto. 2024-12-17 13:28:44 -06:00
Janus Troelsen
6158c2bae7 chore(manager/haskell-cabal): fix spelling of boolean 2024-12-17 12:54:17 -06:00
Janus Troelsen
38e5febd6d chore(manager/haskell-cabal): fix pkg name extraction
Packages cannot start with a hyphen.

But they can start with a number:
- https://hackage.haskell.org/package/3d-graphics-examples

They can be a single uppercase character:
- https://hackage.haskell.org/package/H

They can be a single lowercase character:
- https://hackage.haskell.org/package/j
2024-12-17 12:46:34 -06:00
Janus Troelsen
87d580d9e4 chore(manager/haskell-cabal): fix markdown link 2024-12-16 15:17:36 -06:00
Janus Troelsen
093e75f2c3 chore(manager/haskell-cabal): add readme 2024-12-16 08:25:45 -06:00
Janus Troelsen
ac0b8bf71e chore(manager/haskell-cabal): Run prettier 2024-12-16 07:58:10 -06:00
Janus Troelsen
122aab9a8d feat(manager): add Cabal/Haskell manager using Hackage/PVP 2024-12-16 07:45:05 -06:00
13 changed files with 413 additions and 18 deletions

View file

@ -3707,6 +3707,7 @@ This feature works with the following managers:
- [`dockerfile`](modules/manager/dockerfile/index.md) - [`dockerfile`](modules/manager/dockerfile/index.md)
- [`droneci`](modules/manager/droneci/index.md) - [`droneci`](modules/manager/droneci/index.md)
- [`flux`](modules/manager/flux/index.md) - [`flux`](modules/manager/flux/index.md)
- [`github-actions`](modules/manager/github-actions/index.md)
- [`gitlabci`](modules/manager/gitlabci/index.md) - [`gitlabci`](modules/manager/gitlabci/index.md)
- [`helm-requirements`](modules/manager/helm-requirements/index.md) - [`helm-requirements`](modules/manager/helm-requirements/index.md)
- [`helm-values`](modules/manager/helm-values/index.md) - [`helm-values`](modules/manager/helm-values/index.md)

View file

@ -4,7 +4,7 @@ import type { Preset } from '../types';
export const presets: Record<string, Preset> = { export const presets: Record<string, Preset> = {
safeEnv: { safeEnv: {
allowedEnv: ['GO*'], allowedEnv: ['GO*', 'RUSTC_BOOTSTRAP'],
description: description:
'Hopefully safe environment variables to allow users to configure.', 'Hopefully safe environment variables to allow users to configure.',
}, },

View file

@ -12,6 +12,7 @@ export const Categories = [
'dotnet', 'dotnet',
'elixir', 'elixir',
'golang', 'golang',
'haskell',
'helm', 'helm',
'iac', 'iac',
'java', 'java',

View file

@ -44,6 +44,7 @@ import * as gleam from './gleam';
import * as gomod from './gomod'; import * as gomod from './gomod';
import * as gradle from './gradle'; import * as gradle from './gradle';
import * as gradleWrapper from './gradle-wrapper'; import * as gradleWrapper from './gradle-wrapper';
import * as haskellCabal from './haskell-cabal';
import * as helmRequirements from './helm-requirements'; import * as helmRequirements from './helm-requirements';
import * as helmValues from './helm-values'; import * as helmValues from './helm-values';
import * as helmfile from './helmfile'; import * as helmfile from './helmfile';
@ -150,6 +151,7 @@ api.set('gleam', gleam);
api.set('gomod', gomod); api.set('gomod', gomod);
api.set('gradle', gradle); api.set('gradle', gradle);
api.set('gradle-wrapper', gradleWrapper); api.set('gradle-wrapper', gradleWrapper);
api.set('haskell-cabal', haskellCabal);
api.set('helm-requirements', helmRequirements); api.set('helm-requirements', helmRequirements);
api.set('helm-values', helmValues); api.set('helm-values', helmValues);
api.set('helmfile', helmfile); api.set('helmfile', helmfile);

View file

@ -10,7 +10,11 @@ import { GithubRunnersDatasource } from '../../datasource/github-runners';
import { GithubTagsDatasource } from '../../datasource/github-tags'; import { GithubTagsDatasource } from '../../datasource/github-tags';
import * as dockerVersioning from '../../versioning/docker'; import * as dockerVersioning from '../../versioning/docker';
import { getDep } from '../dockerfile/extract'; import { getDep } from '../dockerfile/extract';
import type { PackageDependency, PackageFileContent } from '../types'; import type {
ExtractConfig,
PackageDependency,
PackageFileContent,
} from '../types';
import type { Workflow } from './types'; import type { Workflow } from './types';
const dockerActionRe = regEx(/^\s+uses\s*: ['"]?docker:\/\/([^'"]+)\s*$/); const dockerActionRe = regEx(/^\s+uses\s*: ['"]?docker:\/\/([^'"]+)\s*$/);
@ -44,7 +48,10 @@ function detectCustomGitHubRegistryUrlsForActions(): PackageDependency {
return {}; return {};
} }
function extractWithRegex(content: string): PackageDependency[] { function extractWithRegex(
content: string,
config: ExtractConfig,
): PackageDependency[] {
const customRegistryUrlsPackageDependency = const customRegistryUrlsPackageDependency =
detectCustomGitHubRegistryUrlsForActions(); detectCustomGitHubRegistryUrlsForActions();
logger.trace('github-actions.extractWithRegex()'); logger.trace('github-actions.extractWithRegex()');
@ -57,7 +64,7 @@ function extractWithRegex(content: string): PackageDependency[] {
const dockerMatch = dockerActionRe.exec(line); const dockerMatch = dockerActionRe.exec(line);
if (dockerMatch) { if (dockerMatch) {
const [, currentFrom] = dockerMatch; const [, currentFrom] = dockerMatch;
const dep = getDep(currentFrom); const dep = getDep(currentFrom, true, config.registryAliases);
dep.depType = 'docker'; dep.depType = 'docker';
deps.push(dep); deps.push(dep);
continue; continue;
@ -126,11 +133,14 @@ function detectDatasource(registryUrl: string): PackageDependency {
}; };
} }
function extractContainer(container: unknown): PackageDependency | undefined { function extractContainer(
container: unknown,
registryAliases: Record<string, string> | undefined,
): PackageDependency | undefined {
if (is.string(container)) { if (is.string(container)) {
return getDep(container); return getDep(container, true, registryAliases);
} else if (is.plainObject(container) && is.string(container.image)) { } else if (is.plainObject(container) && is.string(container.image)) {
return getDep(container.image); return getDep(container.image, true, registryAliases);
} }
return undefined; return undefined;
} }
@ -181,6 +191,7 @@ function extractRunners(runner: unknown): PackageDependency[] {
function extractWithYAMLParser( function extractWithYAMLParser(
content: string, content: string,
packageFile: string, packageFile: string,
config: ExtractConfig,
): PackageDependency[] { ): PackageDependency[] {
logger.trace('github-actions.extractWithYAMLParser()'); logger.trace('github-actions.extractWithYAMLParser()');
const deps: PackageDependency[] = []; const deps: PackageDependency[] = [];
@ -198,14 +209,14 @@ function extractWithYAMLParser(
} }
for (const job of Object.values(pkg?.jobs ?? {})) { for (const job of Object.values(pkg?.jobs ?? {})) {
const dep = extractContainer(job?.container); const dep = extractContainer(job?.container, config.registryAliases);
if (dep) { if (dep) {
dep.depType = 'container'; dep.depType = 'container';
deps.push(dep); deps.push(dep);
} }
for (const service of Object.values(job?.services ?? {})) { for (const service of Object.values(job?.services ?? {})) {
const dep = extractContainer(service); const dep = extractContainer(service, config.registryAliases);
if (dep) { if (dep) {
dep.depType = 'service'; dep.depType = 'service';
deps.push(dep); deps.push(dep);
@ -221,11 +232,12 @@ function extractWithYAMLParser(
export function extractPackageFile( export function extractPackageFile(
content: string, content: string,
packageFile: string, packageFile: string,
config: ExtractConfig = {}, // TODO: enforce ExtractConfig
): PackageFileContent | null { ): PackageFileContent | null {
logger.trace(`github-actions.extractPackageFile(${packageFile})`); logger.trace(`github-actions.extractPackageFile(${packageFile})`);
const deps = [ const deps = [
...extractWithRegex(content), ...extractWithRegex(content, config),
...extractWithYAMLParser(content, packageFile), ...extractWithYAMLParser(content, packageFile, config),
]; ];
if (!deps.length) { if (!deps.length) {
return null; return null;

View file

@ -0,0 +1,86 @@
import {
countPackageNameLength,
countPrecedingIndentation,
extractNamesAndRanges,
findExtents,
splitSingleDependency,
} from './extract';
describe('modules/manager/haskell-cabal/extract', () => {
describe('countPackageNameLength', () => {
it.each`
input | expected
${'-'} | ${null}
${'-j'} | ${null}
${'-H'} | ${null}
${'j-'} | ${null}
${'3-'} | ${null}
${'-3'} | ${null}
${'3'} | ${null}
${'æ'} | ${null}
${'æe'} | ${null}
${'j'} | ${1}
${'H'} | ${1}
${'0ad'} | ${3}
${'3d'} | ${2}
`('matches $input', ({ input, expected }) => {
const maybeIndex = countPackageNameLength(input);
expect(maybeIndex).toStrictEqual(expected);
});
});
describe('countPrecedingIndentation()', () => {
it.each`
content | index | expected
${'\tbuild-depends: base\n\tother-field: hi'} | ${1} | ${1}
${' build-depends: base'} | ${1} | ${1}
${'a\tb'} | ${0} | ${0}
${'a\tb'} | ${2} | ${1}
${'a b'} | ${2} | ${1}
${' b'} | ${2} | ${2}
`(
'countPrecedingIndentation($content, $index)',
({ content, index, expected }) => {
expect(countPrecedingIndentation(content, index)).toBe(expected);
},
);
});
describe('findExtents()', () => {
it.each`
content | indent | expected
${'a: b\n\tc: d'} | ${1} | ${10}
${'a: b'} | ${2} | ${4}
${'a: b\n\tc: d'} | ${2} | ${4}
${'a: b\n '} | ${2} | ${6}
${'a: b\n c: d\ne: f'} | ${1} | ${10}
`('findExtents($indent, $content)', ({ indent, content, expected }) => {
expect(findExtents(indent, content)).toBe(expected);
});
});
describe('splitSingleDependency()', () => {
it.each`
depLine | expectedName | expectedRange
${'base >=2 && <3'} | ${'base'} | ${'>=2 && <3'}
${'base >=2 && <3 '} | ${'base'} | ${'>=2 && <3'}
${'base>=2&&<3'} | ${'base'} | ${'>=2&&<3'}
${'base'} | ${'base'} | ${''}
`(
'splitSingleDependency($depLine)',
({ depLine, expectedName, expectedRange }) => {
const res = splitSingleDependency(depLine);
expect(res?.name).toBe(expectedName);
expect(res?.range).toBe(expectedRange);
},
);
});
describe('extractNamesAndRanges()', () => {
it('trims replaceString', () => {
const res = extractNamesAndRanges(' a , b ');
expect(res).toHaveLength(2);
expect(res[0].replaceString).toBe('a');
});
});
});

View file

@ -0,0 +1,191 @@
import { regEx } from '../../../util/regex';
const buildDependsRegex = regEx(
/(?<buildDependsFieldName>build-depends[ \t]*:)/i,
);
function isNonASCII(str: string): boolean {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) {
return true;
}
}
return false;
}
export function countPackageNameLength(input: string): number | null {
if (input.length < 1 || isNonASCII(input)) {
return null;
}
if (!regEx(/^[A-Za-z0-9]/).test(input[0])) {
// Must start with letter or number
return null;
}
let idx = 1;
while (idx < input.length) {
if (regEx(/[A-Za-z0-9-]/).test(input[idx])) {
idx++;
} else {
break;
}
}
if (!regEx(/[A-Za-z]/).test(input.slice(0, idx))) {
// Must contain a letter
return null;
}
if (idx - 1 < input.length && input[idx - 1] === '-') {
// Can't end in a hyphen
return null;
}
return idx;
}
export interface CabalDependency {
packageName: string;
currentValue: string;
replaceString: string;
}
/**
* Find extents of field contents
*
* @param {number} indent -
* Indention level maintained within the block.
* Any indention lower than this means it's outside the field.
* Lines with this level or more are included in the field.
* @returns {number}
* Index just after the end of the block.
* Note that it may be after the end of the string.
*/
export function findExtents(indent: number, content: string): number {
let blockIdx: number = 0;
let mode: 'finding-newline' | 'finding-indention' = 'finding-newline';
for (;;) {
if (mode === 'finding-newline') {
while (content[blockIdx++] !== '\n') {
if (blockIdx >= content.length) {
break;
}
}
if (blockIdx >= content.length) {
return content.length;
}
mode = 'finding-indention';
} else {
let thisIndent = 0;
for (;;) {
if ([' ', '\t'].includes(content[blockIdx])) {
thisIndent += 1;
blockIdx++;
if (blockIdx >= content.length) {
return content.length;
}
continue;
}
mode = 'finding-newline';
blockIdx++;
break;
}
if (thisIndent < indent) {
// go back to before the newline
for (;;) {
if (content[blockIdx--] === '\n') {
break;
}
}
return blockIdx + 1;
}
mode = 'finding-newline';
}
}
}
/**
* Find indention level of build-depends
*
* @param {number} match -
* Search starts at this index, and proceeds backwards.
* @returns {number}
* Number of indention levels found before 'match'.
*/
export function countPrecedingIndentation(
content: string,
match: number,
): number {
let whitespaceIdx = match - 1;
let indent = 0;
while (whitespaceIdx >= 0 && [' ', '\t'].includes(content[whitespaceIdx])) {
indent += 1;
whitespaceIdx--;
}
return indent;
}
/**
* Find one 'build-depends' field name usage and its field value
*
* @returns {{buildDependsContent: string, lengthProcessed: number}}
* buildDependsContent:
* the contents of the field, excluding the field name and the colon.
*
* lengthProcessed:
* points to after the end of the field. Note that the field does _not_
* necessarily start at `content.length - lengthProcessed`.
*
* Returns null if no 'build-depends' field is found.
*/
export function findDepends(
content: string,
): { buildDependsContent: string; lengthProcessed: number } | null {
const matchObj = buildDependsRegex.exec(content);
if (matchObj === null) {
return null;
}
const indent = countPrecedingIndentation(content, matchObj.index);
const ourIdx: number =
matchObj.index + matchObj.groups!['buildDependsFieldName'].length;
const extent: number = findExtents(indent + 1, content.slice(ourIdx));
return {
buildDependsContent: content.slice(ourIdx, ourIdx + extent),
lengthProcessed: ourIdx + extent,
};
}
/**
* Split a cabal single dependency into its constituent parts.
* The first part is the package name, an optional second part contains
* the version constraint.
*
* For example 'base == 3.2' would be split into 'base' and ' == 3.2'.
*
* @returns {{name: string, range: string}}
* Null if the trimmed string doesn't begin with a package name.
*/
export function splitSingleDependency(
input: string,
): { name: string; range: string } | null {
const match = countPackageNameLength(input);
if (match === null) {
return null;
}
const name: string = input.slice(0, match);
const range = input.slice(match).trim();
return { name, range };
}
export function extractNamesAndRanges(content: string): CabalDependency[] {
const list = content.split(',');
const deps = [];
for (const untrimmedReplaceString of list) {
const replaceString = untrimmedReplaceString.trim();
const maybeNameRange = splitSingleDependency(replaceString);
if (maybeNameRange !== null) {
deps.push({
currentValue: maybeNameRange.range,
packageName: maybeNameRange.name,
replaceString,
});
}
}
return deps;
}

View file

@ -0,0 +1,33 @@
import { extractPackageFile, getRangeStrategy } from '.';
describe('modules/manager/haskell-cabal/index', () => {
describe('extractPackageFile()', () => {
it.each`
content | expected
${'build-depends: base,'} | ${['base']}
${'build-depends:,other,other2'} | ${['other', 'other2']}
${'build-depends : base'} | ${['base']}
${'Build-Depends: base'} | ${['base']}
${'build-depends: a\nbuild-depends: b'} | ${['a', 'b']}
${'dependencies: base'} | ${[]}
`(
'extractPackageFile($content).deps.map(x => x.packageName)',
({ content, expected }) => {
expect(
extractPackageFile(content).deps.map((x) => x.packageName),
).toStrictEqual(expected);
},
);
});
describe('getRangeStrategy()', () => {
it.each`
input | expected
${'auto'} | ${'widen'}
${'widen'} | ${'widen'}
${'replace'} | ${'replace'}
`('getRangeStrategy({ rangeStrategy: $input })', ({ input, expected }) => {
expect(getRangeStrategy({ rangeStrategy: input })).toBe(expected);
});
});
});

View file

@ -0,0 +1,58 @@
import type { Category } from '../../../constants';
import type { RangeStrategy } from '../../../types';
import { HackageDatasource } from '../../datasource/hackage';
import * as pvpVersioning from '../../versioning/pvp';
import type {
PackageDependency,
PackageFileContent,
RangeConfig,
} from '../types';
import type { CabalDependency } from './extract';
import { extractNamesAndRanges, findDepends } from './extract';
export const defaultConfig = {
fileMatch: ['\\.cabal$'],
pinDigests: false,
versioning: pvpVersioning.id,
};
export const categories: Category[] = ['haskell'];
export const supportedDatasources = [HackageDatasource.id];
export function extractPackageFile(content: string): PackageFileContent {
const deps = [];
let current = content;
for (;;) {
const maybeContent = findDepends(current);
if (maybeContent === null) {
break;
}
const cabalDeps: CabalDependency[] = extractNamesAndRanges(
maybeContent.buildDependsContent,
);
for (const cabalDep of cabalDeps) {
const dep: PackageDependency = {
depName: cabalDep.packageName,
currentValue: cabalDep.currentValue,
datasource: HackageDatasource.id,
packageName: cabalDep.packageName,
versioning: 'pvp',
replaceString: cabalDep.replaceString.trim(),
autoReplaceStringTemplate: '{{{depName}}} {{{newValue}}}',
};
deps.push(dep);
}
current = current.slice(maybeContent.lengthProcessed);
}
return { deps };
}
export function getRangeStrategy({
rangeStrategy,
}: RangeConfig): RangeStrategy {
if (rangeStrategy === 'auto') {
return 'widen';
}
return rangeStrategy;
}

View file

@ -0,0 +1,10 @@
Supports dependency extraction from `build-depends` fields in [Cabal package description files](https://cabal.readthedocs.io/en/3.12/cabal-package-description-file.html#pkg-field-build-depends).
They use the extension `.cabal`, and are used with the [Haskell programming language](https://www.haskell.org/).
Limitations:
- The dependencies of all components are mushed together in one big list.
- Fields like `pkgconfig-depends` and `build-tool-depends` are not handled.
- The default PVP versioning is [subject to limitations](../../versioning/pvp/index.md).
If you need to change the versioning format, read the [versioning](../../versioning/index.md) documentation to learn more.

View file

@ -141,12 +141,12 @@ describe('modules/versioning/pvp/index', () => {
describe('.getNewValue(newValueConfig)', () => { describe('.getNewValue(newValueConfig)', () => {
it.each` it.each`
currentValue | newVersion | rangeStrategy | expected currentValue | newVersion | rangeStrategy | expected
${'>=1.0 && <1.1'} | ${'1.1'} | ${'auto'} | ${'>=1.0 && <1.2'} ${'>=1.0 && <1.1'} | ${'1.1'} | ${'widen'} | ${'>=1.0 && <1.2'}
${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'} | ${null} ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null} ${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
${'gibberish'} | ${'1.2.3'} | ${'auto'} | ${null} ${'gibberish'} | ${'1.2.3'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${'0.9'} | ${'auto'} | ${null} ${'>=1.0 && <1.1'} | ${'0.9'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${''} | ${'auto'} | ${null} ${'>=1.0 && <1.1'} | ${''} | ${'widen'} | ${null}
`( `(
'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected', 'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
({ currentValue, newVersion, rangeStrategy, expected }) => { ({ currentValue, newVersion, rangeStrategy, expected }) => {

View file

@ -9,7 +9,7 @@ export const id = 'pvp';
export const displayName = 'Package Versioning Policy (Haskell)'; export const displayName = 'Package Versioning Policy (Haskell)';
export const urls = ['https://pvp.haskell.org']; export const urls = ['https://pvp.haskell.org'];
export const supportsRanges = true; export const supportsRanges = true;
export const supportedRangeStrategies: RangeStrategy[] = ['auto']; export const supportedRangeStrategies: RangeStrategy[] = ['widen'];
const digitsAndDots = regEx(/^[\d.]+$/); const digitsAndDots = regEx(/^[\d.]+$/);
@ -112,7 +112,7 @@ function getNewValue({
newVersion, newVersion,
rangeStrategy, rangeStrategy,
}: NewValueConfig): string | null { }: NewValueConfig): string | null {
if (rangeStrategy !== 'auto') { if (rangeStrategy !== 'widen') {
logger.info( logger.info(
{ rangeStrategy, currentValue, newVersion }, { rangeStrategy, currentValue, newVersion },
`PVP can't handle this range strategy.`, `PVP can't handle this range strategy.`,

View file

@ -48,6 +48,7 @@ export const CategoryNames: Record<Category, string> = {
dotnet: '.NET', dotnet: '.NET',
elixir: 'Elixir', elixir: 'Elixir',
golang: 'Go', golang: 'Go',
haskell: 'Haskell',
helm: 'Helm', helm: 'Helm',
iac: 'Infrastructure as Code', iac: 'Infrastructure as Code',
java: 'Java', java: 'Java',