Files
renovate/lib/modules/manager/apm/extract.ts
Victor Sollerhed 6d747ea8ff feat(manager): add apm manager for Agent Package Manager (#44390)
* 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>
2026-09-02 16:17:15 +00:00

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 };
}