import is from '@sindresorhus/is'; import { regEx } from '../../../util/regex.ts'; import type { ActionReference, CommentData, DockerReference, ParsedUsesLine, RepositoryReference, } from './types.ts'; function splitFirstFrom( str: string, sep: string, start: number, ): [string, string] | null { const idx = str.indexOf(sep, start); if (idx === -1) { return null; } return [str.slice(0, idx), str.slice(idx + sep.length)]; } function splitFirst(str: string, sep: string): [string, string] | null { return splitFirstFrom(str, sep, 0); } interface QuotedValue { value: string; quote: string; } export function parseQuote(input: string): QuotedValue { const trimmed = input.trim(); const first = trimmed[0]; const last = trimmed.at(-1); if ( trimmed.length >= 2 && first === last && (first === '"' || first === "'") ) { return { value: trimmed.slice(1, -1), quote: first }; } return { value: trimmed, quote: '' }; } const shaRe = regEx(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/); const shaShortRe = regEx(/^[a-f0-9]{6,7}$/); export function isSha(str: string): boolean { return shaRe.test(str); } export function isShortSha(str: string): boolean { return shaShortRe.test(str); } const DOCKER_PREFIX = 'docker://'; function parseDockerReference(input: string): DockerReference | null { const originalRef = input.slice(DOCKER_PREFIX.length); if (!originalRef) { return null; } const digestParts = splitFirst(originalRef, '@'); if (digestParts) { const [image, digest] = digestParts; return { kind: 'docker', image, digest, originalRef }; } // Find tag: look for first colon after last slash (to avoid matching port in registry) const lastSlashIndex = originalRef.lastIndexOf('/'); const searchStart = lastSlashIndex === -1 ? 0 : lastSlashIndex + 1; const tagParts = splitFirstFrom(originalRef, ':', searchStart); if (tagParts) { const [image, tag] = tagParts; return { kind: 'docker', image, tag, originalRef }; } return { kind: 'docker', image: originalRef, originalRef }; } const repositoryActionRegex = regEx( /^(?:https:\/\/(?[^/]+)\/)?(?[^/]+)\/(?[^/]+)(?:\/(?.+?))?@(?.+)$/, ); function parseRepositoryReference(input: string): RepositoryReference | null { const match = repositoryActionRegex.exec(input); if (!match?.groups) { return null; } const { owner, repo, path, ref } = match.groups; let { hostname } = match.groups; let isExplicitHostname = true; if (is.undefined(hostname)) { hostname = 'github.com'; isExplicitHostname = false; } return { kind: 'repository', hostname, isExplicitHostname, owner, repo, path, ref, }; } export function parseActionReference(uses: string): ActionReference | null { if (!uses) { return null; } if (uses.startsWith(DOCKER_PREFIX)) { return parseDockerReference(uses); } if (uses.startsWith('./') || uses.startsWith('../')) { return { kind: 'local', path: uses }; } return parseRepositoryReference(uses); } const pinTokenRe = regEx( /^\s*(?:(?:renovate\s*:\s*)?(?:pin\s+|tag\s*=\s*)?|(?:ratchet:[\w-]+\/[.\w-]+(?:\/[.\w-]+)*))?@?(?(?:[\w-]*[-/])?v?\d+(?:\.\d+(?:\.\d+)?)?(?:-[a-zA-Z0-9.]+)?)/, ); export const versionLikeRe = regEx(/^v?\d+/); const bareTokenRe = regEx(/^\s*(?\S+)\s*$/); export function parseComment(commentBody: string): CommentData { const trimmed = commentBody.trim(); if (trimmed === 'ratchet:exclude') { return { ratchetExclude: true }; } // We use commentBody (with leading spaces) to get the correct index relative to the comment start const match = pinTokenRe.exec(commentBody); if (match?.groups?.version) { return { pinnedVersion: match.groups.version, matchedString: match[0], index: match.index, }; } const bareMatch = bareTokenRe.exec(commentBody); if (bareMatch?.groups?.token) { return { ref: bareMatch.groups.token, matchedString: bareMatch[0], index: bareMatch.index, }; } return {}; } const usesLineRegex = regEx( /^(?\s+(?:-\s+)?uses\s*:\s*)(?.+)$/, ); /** * Parses a GitHub Actions `uses:` line into its components. * * Expected line format: * ``` * [- ]uses: [quote][quote][ # ] * ``` * * Examples: * - ` uses: actions/checkout@v4` * - ` - uses: "owner/repo@abc123" # v1.0.0` * - ` uses: docker://alpine:3.18` * * @returns Parsed components or `null` if the line doesn't match `uses:` pattern */ export function parseUsesLine(line: string): ParsedUsesLine | null { const match = usesLineRegex.exec(line); if (!match?.groups) { return null; } const { prefix, remainder } = match.groups; if (remainder.startsWith('#')) { return null; } const indentation = prefix.slice(0, prefix.indexOf('uses')); // We look for ' #' to determine where the comment starts. // This is a safe heuristic for valid "uses" values which cannot contain spaces. const commentIndex = remainder.indexOf(' #'); // No comment case if (commentIndex === -1) { const { value, quote } = parseQuote(remainder); return { indentation, usesPrefix: prefix, replaceString: remainder.trim(), commentPrecedingWhitespace: '', commentString: '', actionRef: parseActionReference(value), commentData: {}, quote, }; } // Has comment: split value and comment const rawValuePart = remainder.slice(0, commentIndex); const commentPart = remainder.slice(commentIndex + 1); // starts at # // Calculate whitespace between value and # const partBeforeHash = remainder.slice(0, commentIndex + 1); const commentPrecedingWhitespace = partBeforeHash.slice( partBeforeHash.trimEnd().length, ); const { value, quote } = parseQuote(rawValuePart); // commentPart always starts with '#' (see commentIndex search above) const cleanCommentBody = commentPart.slice(1); return { indentation, usesPrefix: prefix, replaceString: rawValuePart.trim(), commentPrecedingWhitespace, commentString: commentPart, actionRef: parseActionReference(value), commentData: parseComment(cleanCommentBody), quote, }; }