Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,12 @@ export const DEFAULT_CONFIG: Config = {
default: true,
},
},
"github-actions": {
github: {
name: "GitHub",
url: "https://api.github.com",
default: true,
},
},
},
};
83 changes: 83 additions & 0 deletions src/parsers/github-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* GitHub Actions Workflow Parser
* Parses GitHub Actions workflow YAML files to extract action references
*
* Extracts "uses:" directives from workflow files:
* - actions/checkout@v4
* - github/codeql-action/init@v3
* - docker://alpine:3.8 (skipped - Docker references)
* - ./.github/actions/my-action (skipped - local actions)
*/

import type { DependencyParser, ParsedDependency } from "./types.ts";

/**
* Parse an action reference string into owner/repo and version
* Handles subpath actions like: github/codeql-action/init@v3 -> github/codeql-action@v3
*/
function parseActionReference(
reference: string,
): { name: string; version: string } | null {
// Skip local actions (./path, ../path)
if (reference.startsWith(".")) {
return null;
}

// Skip Docker references (docker://...)
if (reference.startsWith("docker://")) {
return null;
}

// Match: owner/repo@version or owner/repo/subpath@version
const match = reference.match(
/^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/[^@]+)?@(.+)$/,
);
if (!match) {
return null;
}

const [, name, version] = match;

// Skip if version is a 40-char commit SHA (already pinned)
if (/^[a-f0-9]{40}$/i.test(version)) {
return null;
}

// Strip leading "v" prefix for version normalization
const normalizedVersion = version.startsWith("v")
? version.slice(1)
: version;

return { name, version: normalizedVersion };
}

export const githubActionsParser: DependencyParser = {
fileType: "workflow.yml",
registry: "github-actions",

parse(content: string): ParsedDependency[] {
const deps: ParsedDependency[] = [];
const seen = new Set<string>();

// Match all "uses:" directives in the workflow file
// Handles both quoted and unquoted values
const usesPattern = /^\s*-?\s*uses:\s*["']?([^"'\s#]+)["']?/gm;
let match: RegExpExecArray | null;

while ((match = usesPattern.exec(content)) !== null) {
const reference = match[1];
const parsed = parseActionReference(reference);

if (parsed) {
// Deduplicate by name+version
const key = `${parsed.name}@${parsed.version}`;
if (!seen.has(key)) {
seen.add(key);
deps.push(parsed);
}
}
}

return deps;
},
};
5 changes: 5 additions & 0 deletions src/parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { rubygemsParser } from "./rubygems.ts";
export { packagistParser } from "./packagist.ts";
export { pubParser } from "./pub.ts";
export { swiftParser } from "./swift.ts";
export { githubActionsParser } from "./github-actions.ts";

import type { Registry } from "../registries/types.ts";
import type { DependencyParser, ParsedDependency } from "./types.ts";
Expand All @@ -34,6 +35,7 @@ import { rubygemsParser } from "./rubygems.ts";
import { packagistParser } from "./packagist.ts";
import { pubParser } from "./pub.ts";
import { swiftParser } from "./swift.ts";
import { githubActionsParser } from "./github-actions.ts";

const parsers: Record<Registry, DependencyParser> = {
npm: npmParser,
Expand All @@ -48,6 +50,7 @@ const parsers: Record<Registry, DependencyParser> = {
packagist: packagistParser,
pub: pubParser,
swift: swiftParser,
"github-actions": githubActionsParser,
};

/**
Expand Down Expand Up @@ -125,6 +128,8 @@ export function parseDependencies(
return pubParser.parse(content);
case "swift":
return swiftParser.parse(content);
case "github-actions":
return githubActionsParser.parse(content);
default:
throw new Error(`Unsupported registry: ${registry}`);
}
Expand Down
147 changes: 147 additions & 0 deletions src/parsers/parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { rubygemsParser } from "./rubygems.ts";
import { packagistParser } from "./packagist.ts";
import { pubParser } from "./pub.ts";
import { swiftParser } from "./swift.ts";
import { githubActionsParser } from "./github-actions.ts";
import { parseDependencies } from "./index.ts";

// NPM Parser Tests
Expand Down Expand Up @@ -1009,3 +1010,149 @@ let package = Package(
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "apple/swift-nio", version: "2.0.0" });
});

// GitHub Actions Parser Tests
Deno.test("githubActionsParser - parses uses directives from workflow", () => {
const content = `
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: docker/build-push-action@v5
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 3);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
assertEquals(deps[1], { name: "actions/setup-node", version: "4" });
assertEquals(deps[2], { name: "docker/build-push-action", version: "5" });
});

Deno.test("githubActionsParser - parses full semver versions", () => {
const content = `
jobs:
build:
steps:
- uses: actions/[email protected]
- uses: actions/[email protected]
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 2);
assertEquals(deps[0], { name: "actions/checkout", version: "4.2.0" });
assertEquals(deps[1], { name: "actions/setup-node", version: "4.0.1" });
});

Deno.test("githubActionsParser - handles subpath actions", () => {
const content = `
jobs:
build:
steps:
- uses: github/codeql-action/init@v3
- uses: github/codeql-action/analyze@v3
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "github/codeql-action", version: "3" });
});

Deno.test("githubActionsParser - skips local actions", () => {
const content = `
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/my-action
- uses: ../shared-actions/build
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
});

Deno.test("githubActionsParser - skips Docker references", () => {
const content = `
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: docker://alpine:3.8
- uses: docker://ghcr.io/owner/image:latest
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
});

Deno.test("githubActionsParser - skips SHA-pinned references", () => {
const content = `
jobs:
build:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@v4
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "actions/setup-node", version: "4" });
});

Deno.test("githubActionsParser - deduplicates same action+version", () => {
const content = `
jobs:
build:
steps:
- uses: actions/checkout@v4
test:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 2);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
assertEquals(deps[1], { name: "actions/setup-node", version: "4" });
});

Deno.test("githubActionsParser - handles quoted uses values", () => {
const content = `
jobs:
build:
steps:
- uses: "actions/checkout@v4"
- uses: 'actions/setup-node@v4'
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 2);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
assertEquals(deps[1], { name: "actions/setup-node", version: "4" });
});

Deno.test("githubActionsParser - handles empty workflow", () => {
const content = `
name: Empty
on: [push]
jobs: {}
`;
const deps = githubActionsParser.parse(content);
assertEquals(deps.length, 0);
});

Deno.test("parser metadata - github-actions", () => {
assertEquals(githubActionsParser.fileType, "workflow.yml");
assertEquals(githubActionsParser.registry, "github-actions");
});

Deno.test("parseDependencies - uses correct parser for github-actions", () => {
const content = `
jobs:
build:
steps:
- uses: actions/checkout@v4
`;
const deps = parseDependencies(content, "github-actions");
assertEquals(deps.length, 1);
assertEquals(deps[0], { name: "actions/checkout", version: "4" });
});
Loading
Loading