mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 15:58:48 +00:00
6d747ea8ff
* feat(manager): add apm manager for Agent Package Manager Add a new `apm` manager that updates git-pinned dependencies in APM (Agent Package Manager) `apm.yml` manifests, as requested in renovatebot/renovate#42507. The manager parses the string entries under `dependencies.apm` and `devDependencies.apm` (`[host/]owner/repo[/subpath]#<ref>`) and selects the datasource dynamically from the git host, reusing the existing github-tags, gitlab-tags and git-tags datasources. MCP entries carry no pinnable version and are ignored. When an `apm.lock.yaml` lockfile is present, it is refreshed by running `apm install` via a new `apm` tool constraint (pypi `apm-cli`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vfjt3BpqDriiVsvwqVe9M9 * fix(manager/apm): resolve apm tool from github releases APM ships self-contained binaries as GitHub release assets (apm-linux-<arch>.tar.gz under vX.Y.Z tags), not as the PyPI apm-cli wheel. Point the apm tool constraint at the github-releases datasource so the version Renovate resolves matches the artifact that actually gets installed, stripping the leading `v` from the tag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vfjt3BpqDriiVsvwqVe9M9 * docs(manager/apm): point to canonical microsoft/apm repository The APM project's canonical home is github.com/microsoft/apm (originally danielmeppiel/apm). Update the manager URL and readme accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vfjt3BpqDriiVsvwqVe9M9 * refactor(manager/apm): validate manifest with zod schema Address review feedback on the apm manager: - parse `apm.yml` with a dedicated zod schema via `parseSingleYaml(content, { customSchema })` instead of casting the parsed YAML to `ApmManifest` - `LooseArray(z.string())` keeps only the string dependency entries, so MCP objects and other non-string values are dropped by the schema and the manual `Array.isArray`/`is.string` guards are no longer needed - drop the `as SkipReason` casts now that the literals are inferred - remove the now-unused `types.ts` Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbMAQBiMRNkCNxc8EGQw1C * Update schema.ts Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com> * Update readme.md Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com> * Update readme.md Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com> * refactor(manager/apm): simplify manifest schema and host detection Address review feedback: - default the `apm` array to `[]` in the schema (`LooseArray(...).catch([])`) since LooseArray already discards non-string entries, so downstream code no longer needs to handle `undefined` - use `coerceArray` when mapping the optional dependency sections - drop the redundant `host === 'github.com'` / `host === 'gitlab.com'` checks in datasource selection; `detectPlatform` already resolves those hosts (the explicit host comparison is only kept to decide registryUrls for self-hosted instances) - document that APM only supports remote git references and that dots in repo names / subpaths are handled, with a regression test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbMAQBiMRNkCNxc8EGQw1C * docs(manager/apm): fix readme trailing newline Remove the trailing blank line left after the readme edits so it passes `prettier` and `markdown-lint`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbMAQBiMRNkCNxc8EGQw1C * refactor(manager/apm): parse dependency path with named capture groups Replace the manual host/owner/repo splitting (and the standalone dot regex) with a single named-capture regex, as suggested in review. `owner` excludes dots so the optional hostname segment is unambiguous, while repo names and subpaths may still contain dots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbMAQBiMRNkCNxc8EGQw1C * test(manager/apm): align admin config with InternalGlobalConfigOptions `localDir` moved out of `RepoGlobalConfig` into `InternalGlobalConfigOptions` on main, so annotate the test admin config as `RepoGlobalConfig & InternalGlobalConfigOptions`, matching the other manager artifacts specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbMAQBiMRNkCNxc8EGQw1C * fix(manager/apm): resolve GitLab nested groups and recognize SHA-pinned refs - resolve nested GitLab project slugs (e.g. `group/subgroup/project`) instead of capping at `owner/repo`, splitting the project from any virtual-package subpath using APM's primitive-dir / virtual-file markers - recognize the documented SHA-pinned form and skip it (`unversioned-reference`) rather than treating the SHA as a version or rewriting it * feat(manager/apm): support digest pinning for SHA-pinned deps Recover the release tag from the trailing YAML comment of a SHA-pinned entry (`owner/repo#<sha> # v2.0.0`) - which the structured parse strips - so it extracts as a digest (`currentDigest` + `currentValue`) and updates both via `pinDigests`, mirroring the github-actions manager. A bare SHA with no tag comment (or a quoted form we can't rewrite verbatim) is left skipped. * fix(manager/apm): map SHA-pin tag recovery per line to handle duplicates The raw-manifest scan matched the first line for the entry regardless of section, so the same `owner/repo#<sha>` pinned in both `dependencies` and `devDependencies` resolved to the first line's tag/replaceString. Consume each line at most once (in file order) so duplicate pins map to distinct lines. * fix(manager/apm): recognize `.chatmode.md` virtual files APM's VIRTUAL_FILE_EXTENSIONS includes `.chatmode.md`; add it to the subpath-boundary regex so a `*.chatmode.md` virtual dependency on a nested-group host (GitLab, Azure DevOps) is not folded into the repo path. * test(manager/apm): cover SHA-pin digest updates on git-tags hosts Lock in that a SHA-pinned dep on a non-github/gitlab host resolves via the git-tags datasource (which supports `getDigest` through `git ls-remote`), so the commit is preserved on a bump rather than dropped. * fix(manager/apm): commit regenerated harness files, not just the lockfile `apm install` re-deploys the harness directories APM consumers commit (`.github/`, `.claude/`, ...), but updateArtifacts only returned `apm.lock.yaml`, leaving instruction files stale after a bump. Capture every file the install changed via `getRepoStatus` (additions + deletions); the gitignored `apm_modules/` cache is excluded, and the manifest stays with `updatedPackageFiles`. * fix(manager/apm): declare lockFileMaintenanceIsDelegatedToPackageManager main now requires managers with `supportsLockFileMaintenance` to set this field. apm regenerates the lockfile via `apm install`, so it delegates to the package manager (`true`). * refactor(manager/apm): reuse shared regex and git-sha helpers Adopt the shared utilities per review: - use the exported `newlineRegex` from `util/regex` instead of a local copy - use `isLongCommitSha` from `util/schema-utils/git` instead of a bespoke SHA regex - filter path segments with `isTruthy` instead of `Boolean` * test(manager/apm): align docker exec snapshot with current base-image wrapper The shared `util/exec` docker wrapper now injects `-e CI` and single-quotes the `bash -l -c` payload. Update the docker-mode expectation to match, so the snapshot tracks the current wrapper output. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
import { isTruthy } from '@sindresorhus/is';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { coerceArray } from '../../../util/array.ts';
|
|
import { detectPlatform } from '../../../util/common.ts';
|
|
import { newlineRegex, regEx } from '../../../util/regex.ts';
|
|
import { isLongCommitSha } from '../../../util/schema-utils/git.ts';
|
|
import { parseSingleYaml } from '../../../util/yaml.ts';
|
|
import { GitTagsDatasource } from '../../datasource/git-tags/index.ts';
|
|
import { GithubTagsDatasource } from '../../datasource/github-tags/index.ts';
|
|
import { GitlabTagsDatasource } from '../../datasource/gitlab-tags/index.ts';
|
|
import type { PackageDependency, PackageFileContent } from '../types.ts';
|
|
import { ApmManifest } from './schema.ts';
|
|
|
|
interface DatasourceResult {
|
|
datasource: string;
|
|
packageName: string;
|
|
registryUrls?: string[];
|
|
}
|
|
|
|
/**
|
|
* Determine which Renovate datasource to use for an APM dependency, based on
|
|
* the git host `platform` (already resolved via `detectPlatform`, which honors
|
|
* `hostRules`). github/gitlab (and their self-hosted variants) map to the
|
|
* `github-tags` / `gitlab-tags` datasources; every other host (Bitbucket, Azure
|
|
* DevOps, etc.) falls back to the generic `git-tags` datasource.
|
|
*/
|
|
function determineDatasource(
|
|
host: string,
|
|
platform: string | null,
|
|
repoPath: string,
|
|
): DatasourceResult {
|
|
if (platform === 'github') {
|
|
return {
|
|
datasource: GithubTagsDatasource.id,
|
|
packageName: repoPath,
|
|
...(host === 'github.com' ? {} : { registryUrls: [`https://${host}`] }),
|
|
};
|
|
}
|
|
|
|
if (platform === 'gitlab') {
|
|
return {
|
|
datasource: GitlabTagsDatasource.id,
|
|
packageName: repoPath,
|
|
...(host === 'gitlab.com' ? {} : { registryUrls: [`https://${host}`] }),
|
|
};
|
|
}
|
|
|
|
return {
|
|
datasource: GitTagsDatasource.id,
|
|
packageName: `https://${host}/${repoPath}`,
|
|
};
|
|
}
|
|
|
|
/** A trailing ` # <tag>` comment on a manifest line. */
|
|
const commentTagRegex = regEx(/^\s+#\s*(?<tag>\S.*?)\s*$/);
|
|
|
|
/**
|
|
* Renders both `owner/repo#<tag>` and the digest-pinned
|
|
* `owner/repo#<sha> # <tag>` form, mirroring the github-actions
|
|
* `uses: owner/action@<sha> # v4` behaviour so `pinDigests` can pin a bare tag
|
|
* to a SHA and keep the trailing tag comment current.
|
|
*/
|
|
const autoReplaceStringTemplate =
|
|
'{{depName}}#{{#if newDigest}}{{newDigest}} # {{newValue}}{{else}}{{newValue}}{{/if}}';
|
|
|
|
/**
|
|
* APM virtual-package subpaths (skills/prompts/etc.) begin at one of these
|
|
* "primitive" directories or at a file with a virtual extension. APM only uses
|
|
* these to find where a repo path ends for hosts with nested namespaces; see
|
|
* `_GITLAB_VIRTUAL_ROOT_SEGMENTS` / `VIRTUAL_FILE_EXTENSIONS` in apm.
|
|
*/
|
|
const virtualRootSegments = new Set(['prompts', 'instructions', 'collections']);
|
|
const virtualFileRegex = regEx(/\.(?:prompt|instructions|chatmode|agent)\.md$/);
|
|
|
|
/**
|
|
* Resolve the repository path from the host-stripped path segments.
|
|
*
|
|
* GitHub repos are always `owner/repo`. GitLab (and other hosts) allow nested
|
|
* groups, so the project slug can span 3+ segments; the virtual-package subpath,
|
|
* if any, begins at a primitive directory or virtual file (index >= 2). Returns
|
|
* `null` when there is no `owner/repo` (fewer than two segments).
|
|
*/
|
|
function resolveRepoPath(
|
|
platform: string | null,
|
|
segments: string[],
|
|
): string | null {
|
|
if (segments.length < 2) {
|
|
return null;
|
|
}
|
|
if (platform === 'github') {
|
|
return segments.slice(0, 2).join('/');
|
|
}
|
|
let boundary = segments.length;
|
|
for (let i = 2; i < segments.length; i++) {
|
|
if (
|
|
virtualRootSegments.has(segments[i]) ||
|
|
virtualFileRegex.test(segments[i])
|
|
) {
|
|
boundary = i;
|
|
break;
|
|
}
|
|
}
|
|
return segments.slice(0, boundary).join('/');
|
|
}
|
|
|
|
interface PinnedTail {
|
|
replaceString: string;
|
|
currentValue: string;
|
|
}
|
|
|
|
/**
|
|
* APM keeps the release tag for a SHA-pinned dependency in a trailing YAML
|
|
* comment (`owner/repo#<sha> # v2.0.0`), which the structured parse strips.
|
|
*
|
|
* Returns a matcher that scans the raw manifest for the (unquoted) entry to
|
|
* recover the tag as `currentValue` and the exact text to replace, so
|
|
* `pinDigests` can update both the SHA and the tag. Each line is consumed at
|
|
* most once (in file order), so the same `owner/repo#<sha>` pinned in more than
|
|
* one section maps to distinct lines rather than all resolving to the first
|
|
* match. Yields `undefined` for a bare SHA (no tag comment) or a form we can't
|
|
* recover verbatim (e.g. quoted), which the caller then skips.
|
|
*/
|
|
function createPinnedTailFinder(
|
|
content: string,
|
|
): (value: string) => PinnedTail | undefined {
|
|
const lines = content.split(newlineRegex);
|
|
const consumed = new Set<number>();
|
|
return (value) => {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (consumed.has(i)) {
|
|
continue;
|
|
}
|
|
const item = lines[i].trimStart();
|
|
if (!item.startsWith('-')) {
|
|
continue;
|
|
}
|
|
const afterDash = item.slice(1).trimStart();
|
|
if (!afterDash.startsWith(value)) {
|
|
continue;
|
|
}
|
|
consumed.add(i);
|
|
const tail = commentTagRegex.exec(afterDash.slice(value.length));
|
|
if (!tail?.groups?.tag) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
replaceString: afterDash.trimEnd(),
|
|
currentValue: tail.groups.tag,
|
|
};
|
|
}
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a single APM dependency string of the form
|
|
* `[host/]owner/repo[/subpath...][#<ref>]`, where `<ref>` is a semver tag/range,
|
|
* a branch, or a commit SHA.
|
|
*
|
|
* For a SHA-pinned entry the release tag lives in a trailing YAML comment; when
|
|
* present it is recovered from `content` so the entry updates as a digest
|
|
* (`currentDigest` + `currentValue`). A bare SHA with no tag comment has no
|
|
* version to track and is skipped.
|
|
*/
|
|
export function parseApmDependency(
|
|
entry: string,
|
|
depType: string,
|
|
findPinnedTail: (value: string) => PinnedTail | undefined,
|
|
): PackageDependency {
|
|
const hashIndex = entry.indexOf('#');
|
|
const pathPart = hashIndex === -1 ? entry : entry.slice(0, hashIndex);
|
|
const ref = hashIndex === -1 ? '' : entry.slice(hashIndex + 1).trim();
|
|
|
|
const base: PackageDependency = {
|
|
depName: pathPart,
|
|
depType,
|
|
};
|
|
|
|
if (!ref) {
|
|
// Unpinned dependency (no `#ref`) - nothing for Renovate to update.
|
|
return { ...base, skipReason: 'unspecified-version' };
|
|
}
|
|
|
|
// The optional host prefix is a hostname (so it contains a dot); git host
|
|
// owner names never do, which disambiguates the leading segment.
|
|
const segments = pathPart.split('/').filter(isTruthy);
|
|
const hasHost = (segments[0] ?? '').includes('.');
|
|
const host = hasHost ? segments[0] : 'github.com';
|
|
const platform = detectPlatform(`https://${host}`);
|
|
const repoPath = resolveRepoPath(
|
|
platform,
|
|
hasHost ? segments.slice(1) : segments,
|
|
);
|
|
|
|
if (!repoPath) {
|
|
logger.debug({ entry }, 'apm: could not determine owner/repo');
|
|
return {
|
|
...base,
|
|
currentValue: ref,
|
|
skipReason: 'invalid-dependency-specification',
|
|
};
|
|
}
|
|
|
|
const { datasource, packageName, registryUrls } = determineDatasource(
|
|
host,
|
|
platform,
|
|
repoPath,
|
|
);
|
|
const dep: PackageDependency = {
|
|
...base,
|
|
datasource,
|
|
packageName,
|
|
...(registryUrls ? { registryUrls } : {}),
|
|
autoReplaceStringTemplate,
|
|
};
|
|
|
|
if (isLongCommitSha(ref)) {
|
|
const tail = findPinnedTail(entry);
|
|
if (!tail) {
|
|
// Bare SHA with no recoverable tag comment - no version to track.
|
|
return {
|
|
...base,
|
|
currentDigest: ref,
|
|
skipReason: 'unversioned-reference',
|
|
};
|
|
}
|
|
return {
|
|
...dep,
|
|
currentValue: tail.currentValue,
|
|
currentDigest: ref,
|
|
replaceString: tail.replaceString,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...dep,
|
|
currentValue: ref,
|
|
replaceString: entry,
|
|
};
|
|
}
|
|
|
|
function extractSection(
|
|
entries: string[] | undefined,
|
|
depType: string,
|
|
findPinnedTail: (value: string) => PinnedTail | undefined,
|
|
): PackageDependency[] {
|
|
return coerceArray(entries).map((entry) =>
|
|
parseApmDependency(entry, depType, findPinnedTail),
|
|
);
|
|
}
|
|
|
|
export function extractPackageFile(
|
|
content: string,
|
|
packageFile: string,
|
|
): PackageFileContent | null {
|
|
let manifest: ApmManifest;
|
|
try {
|
|
manifest = parseSingleYaml(content, { customSchema: ApmManifest });
|
|
} catch (err) {
|
|
logger.debug({ packageFile, err }, 'apm: failed to parse manifest');
|
|
return null;
|
|
}
|
|
|
|
const findPinnedTail = createPinnedTailFinder(content);
|
|
const deps = [
|
|
...extractSection(manifest.dependencies?.apm, 'apm', findPinnedTail),
|
|
...extractSection(manifest.devDependencies?.apm, 'apm-dev', findPinnedTail),
|
|
];
|
|
|
|
if (!deps.length) {
|
|
return null;
|
|
}
|
|
|
|
return { deps };
|
|
}
|