From 9ddcdcd63d9fbcab160736aa3b4ab8e44ec0b1d9 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Wed, 11 Feb 2026 06:10:01 +0000 Subject: [PATCH 1/2] ci: automate GitHub Actions freshness audits --- .github/dependabot.yml | 3 +- .github/workflows/actions-freshness.yml | 50 ++ package.json | 1 + scripts/audit-actions-freshness.js | 536 +++++++++++++++++++ scripts/lib/actions-freshness.js | 191 +++++++ tests/unit/scripts/actions-freshness.test.js | 106 ++++ 6 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/actions-freshness.yml create mode 100644 scripts/audit-actions-freshness.js create mode 100644 scripts/lib/actions-freshness.js create mode 100644 tests/unit/scripts/actions-freshness.test.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a122902..7f82305 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,7 @@ updates: - package-ecosystem: github-actions directory: "/" schedule: - interval: weekly - day: monday + interval: daily time: "03:15" timezone: UTC open-pull-requests-limit: 10 diff --git a/.github/workflows/actions-freshness.yml b/.github/workflows/actions-freshness.yml new file mode 100644 index 0000000..a18aa5e --- /dev/null +++ b/.github/workflows/actions-freshness.yml @@ -0,0 +1,50 @@ +name: Actions Freshness Audit + +on: + workflow_dispatch: + schedule: + - cron: '20 4 * * *' + +permissions: + contents: read + issues: write + +concurrency: + group: actions-freshness-${{ github.ref }} + cancel-in-progress: false + +jobs: + audit: + name: Audit pinned GitHub Actions references + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + + - name: Audit pinned action references and manage tracking issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/audit-actions-freshness.js \ + --report actions-freshness-report.md \ + --json actions-freshness-report.json \ + --manage-issue + + - name: Upload freshness report artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: actions-freshness-report + path: | + actions-freshness-report.md + actions-freshness-report.json + if-no-files-found: error + retention-days: 14 diff --git a/package.json b/package.json index cf49652..ee91c89 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "predocs:screenshots": "npm run build:ts && npm run build:webpack", "docs:screenshots": "node scripts/generate-doc-screenshots.js", "security": "node scripts/index.js security", + "security:actions-freshness": "node scripts/audit-actions-freshness.js --report actions-freshness-report.md --json actions-freshness-report.json", "gitleaks": "node scripts/index.js gitleaks", "gitleaks:staged": "node scripts/index.js gitleaks-staged", "sbom": "node scripts/index.js sbom", diff --git a/scripts/audit-actions-freshness.js b/scripts/audit-actions-freshness.js new file mode 100644 index 0000000..68428db --- /dev/null +++ b/scripts/audit-actions-freshness.js @@ -0,0 +1,536 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { + buildMarkdownReport, + collectWorkflowReferences, + splitReferencesByPinning, +} = require('./lib/actions-freshness'); + +const DEFAULT_REPORT_PATH = 'actions-freshness-report.md'; +const DEFAULT_JSON_PATH = 'actions-freshness-report.json'; +const DEFAULT_WORKFLOW_DIRECTORY = path.join('.github', 'workflows'); +const DEFAULT_TRACKING_ISSUE_TITLE = 'CI: refresh pinned GitHub Actions SHAs'; +const TRACKING_ISSUE_MARKER = ''; + +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function parseArguments(argv) { + const options = { + reportPath: DEFAULT_REPORT_PATH, + jsonPath: DEFAULT_JSON_PATH, + workflowDirectory: DEFAULT_WORKFLOW_DIRECTORY, + failOnStale: false, + manageIssue: false, + issueTitle: DEFAULT_TRACKING_ISSUE_TITLE, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === '--report') { + options.reportPath = argv[index + 1]; + index += 1; + continue; + } + + if (argument === '--json') { + options.jsonPath = argv[index + 1]; + index += 1; + continue; + } + + if (argument === '--workflow-dir') { + options.workflowDirectory = argv[index + 1]; + index += 1; + continue; + } + + if (argument === '--issue-title') { + options.issueTitle = argv[index + 1]; + index += 1; + continue; + } + + if (argument === '--fail-on-stale') { + options.failOnStale = true; + continue; + } + + if (argument === '--manage-issue') { + options.manageIssue = true; + continue; + } + + throw new Error(`Unsupported argument: ${argument}`); + } + + if (!options.reportPath) { + throw new Error('The --report option requires a value.'); + } + + if (!options.jsonPath) { + throw new Error('The --json option requires a value.'); + } + + if (!options.workflowDirectory) { + throw new Error('The --workflow-dir option requires a value.'); + } + + if (!options.issueTitle) { + throw new Error('The --issue-title option requires a value.'); + } + + return options; +} + +function readWorkflowFiles(workflowDirectory) { + const directoryPath = path.resolve(process.cwd(), workflowDirectory); + + if (!fs.existsSync(directoryPath) || !fs.statSync(directoryPath).isDirectory()) { + throw new Error(`Workflow directory not found: ${workflowDirectory}`); + } + + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) + ) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); + + return entries.map((entryName) => { + const absolutePath = path.join(directoryPath, entryName); + const relativePath = toPosixPath(path.relative(process.cwd(), absolutePath)); + + return { + path: relativePath, + content: fs.readFileSync(absolutePath, 'utf8'), + }; + }); +} + +function ensureParentDirectory(filePath) { + const absolutePath = path.resolve(process.cwd(), filePath); + const parentDirectory = path.dirname(absolutePath); + + if (!fs.existsSync(parentDirectory)) { + fs.mkdirSync(parentDirectory, { recursive: true }); + } + + return absolutePath; +} + +function parseRepositoryFromEnvironment() { + const repository = process.env.GITHUB_REPOSITORY; + + if (!repository || !repository.includes('/')) { + return null; + } + + const [owner, name] = repository.split('/'); + if (!owner || !name) { + return null; + } + + return { owner, repository: name }; +} + +async function githubRequest({ endpoint, token, method = 'GET', body = null }) { + const url = `https://api.github.com${endpoint}`; + const response = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: token ? `Bearer ${token}` : '', + 'User-Agent': 'actions-freshness-audit', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (response.status === 204) { + return null; + } + + const responseText = await response.text(); + let data = null; + + if (responseText.length > 0) { + try { + data = JSON.parse(responseText); + } catch (error) { + data = responseText; + } + } + + if (!response.ok) { + const detail = data && typeof data === 'object' && data.message ? data.message : responseText; + const error = new Error(`GitHub API ${method} ${endpoint} failed (${response.status}): ${detail}`); + error.status = response.status; + throw error; + } + + return data; +} + +async function resolveLatestActionVersion({ owner, repository, token }) { + const repositoryPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`; + + try { + const latestRelease = await githubRequest({ + endpoint: `${repositoryPath}/releases/latest`, + token, + }); + const releaseTag = latestRelease && latestRelease.tag_name ? latestRelease.tag_name : ''; + + if (releaseTag.length > 0) { + const commit = await githubRequest({ + endpoint: `${repositoryPath}/commits/${encodeURIComponent(releaseTag)}`, + token, + }); + + if (commit && commit.sha) { + return { + latestTag: releaseTag, + latestSha: commit.sha, + source: 'release', + }; + } + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + const tags = await githubRequest({ + endpoint: `${repositoryPath}/tags?per_page=1`, + token, + }); + + if (!Array.isArray(tags) || tags.length === 0) { + throw new Error(`No release tags found for ${owner}/${repository}`); + } + + const [latestTag] = tags; + + return { + latestTag: latestTag.name, + latestSha: latestTag.commit && latestTag.commit.sha ? latestTag.commit.sha : '', + source: 'tags', + }; +} + +function buildRepositoryMap(pinnedReferences) { + const repositories = new Map(); + + for (const reference of pinnedReferences) { + if (repositories.has(reference.repositoryKey)) { + continue; + } + + repositories.set(reference.repositoryKey, { + owner: reference.owner, + repository: reference.repository, + repositoryKey: reference.repositoryKey, + }); + } + + return repositories; +} + +async function resolveRepositoryVersions(pinnedReferences, token) { + const repositoryMap = buildRepositoryMap(pinnedReferences); + const resolvedVersions = new Map(); + const errors = []; + + for (const repositoryInfo of repositoryMap.values()) { + try { + const version = await resolveLatestActionVersion({ + owner: repositoryInfo.owner, + repository: repositoryInfo.repository, + token, + }); + + if (!version.latestSha) { + throw new Error( + `Could not resolve latest commit SHA for ${repositoryInfo.owner}/${repositoryInfo.repository}` + ); + } + + resolvedVersions.set(repositoryInfo.repositoryKey, { + owner: repositoryInfo.owner, + repository: repositoryInfo.repository, + latestTag: version.latestTag, + latestSha: version.latestSha, + source: version.source, + }); + } catch (error) { + errors.push({ + repository: `${repositoryInfo.owner}/${repositoryInfo.repository}`, + message: error.message, + }); + } + } + + return { resolvedVersions, errors }; +} + +function collectStaleReferences(pinnedReferences, resolvedVersions) { + const staleReferences = []; + + for (const reference of pinnedReferences) { + const latest = resolvedVersions.get(reference.repositoryKey); + if (!latest || !latest.latestSha) { + continue; + } + + if (reference.ref.toLowerCase() === latest.latestSha.toLowerCase()) { + continue; + } + + staleReferences.push({ + ...reference, + latestTag: latest.latestTag, + latestSha: latest.latestSha, + latestSource: latest.source, + }); + } + + return staleReferences; +} + +function buildJsonReport({ + workflowCount, + references, + pinnedReferences, + unpinnedReferences, + staleReferences, + resolutionErrors, + resolvedVersions, +}) { + const generatedAt = new Date().toISOString(); + const latestByRepository = {}; + + for (const [repositoryKey, metadata] of resolvedVersions.entries()) { + latestByRepository[repositoryKey] = { + latestTag: metadata.latestTag, + latestSha: metadata.latestSha, + source: metadata.source, + }; + } + + return { + generatedAt, + workflowCount, + totalReferences: references.length, + pinnedCount: pinnedReferences.length, + unpinnedCount: unpinnedReferences.length, + staleCount: staleReferences.length, + staleReferences, + unpinnedReferences, + resolutionErrors, + latestByRepository, + }; +} + +async function findTrackingIssue({ owner, repository, token, issueTitle }) { + for (let page = 1; page <= 5; page += 1) { + const issues = await githubRequest({ + endpoint: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues?state=open&per_page=100&page=${page}`, + token, + }); + + if (!Array.isArray(issues) || issues.length === 0) { + return null; + } + + const trackingIssue = issues.find( + (issue) => + !issue.pull_request && + issue.title === issueTitle && + typeof issue.body === 'string' && + issue.body.includes(TRACKING_ISSUE_MARKER) + ); + + if (trackingIssue) { + return trackingIssue; + } + + if (issues.length < 100) { + break; + } + } + + return null; +} + +async function closeTrackingIssue({ owner, repository, issueNumber, token }) { + await githubRequest({ + endpoint: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/comments`, + token, + method: 'POST', + body: { + body: 'Automated actions freshness audit is clean. Closing this tracker.', + }, + }); + + await githubRequest({ + endpoint: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}`, + token, + method: 'PATCH', + body: { state: 'closed' }, + }); +} + +async function upsertTrackingIssue({ + token, + owner, + repository, + issueTitle, + staleCount, + reportMarkdown, +}) { + const trackingIssue = await findTrackingIssue({ + owner, + repository, + token, + issueTitle, + }); + const body = `${TRACKING_ISSUE_MARKER}\n\n${reportMarkdown}`; + + if (staleCount === 0) { + if (!trackingIssue) { + return; + } + + await closeTrackingIssue({ + owner, + repository, + issueNumber: trackingIssue.number, + token, + }); + console.log(`[actions-freshness] Closed issue #${trackingIssue.number}`); + return; + } + + if (trackingIssue) { + await githubRequest({ + endpoint: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${trackingIssue.number}`, + token, + method: 'PATCH', + body: { + title: issueTitle, + body, + }, + }); + console.log(`[actions-freshness] Updated issue #${trackingIssue.number}`); + return; + } + + const createdIssue = await githubRequest({ + endpoint: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues`, + token, + method: 'POST', + body: { + title: issueTitle, + body, + }, + }); + console.log(`[actions-freshness] Created issue #${createdIssue.number}`); +} + +function writeReportFiles({ reportPath, jsonPath, markdownReport, jsonReport }) { + const reportAbsolutePath = ensureParentDirectory(reportPath); + const jsonAbsolutePath = ensureParentDirectory(jsonPath); + + fs.writeFileSync(reportAbsolutePath, markdownReport, 'utf8'); + fs.writeFileSync(jsonAbsolutePath, JSON.stringify(jsonReport, null, 2), 'utf8'); + + return { reportAbsolutePath, jsonAbsolutePath }; +} + +function writeStepSummary(markdownReport) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + + if (!summaryPath) { + return; + } + + fs.appendFileSync(summaryPath, `${markdownReport}\n`); +} + +async function run() { + const options = parseArguments(process.argv.slice(2)); + const workflows = readWorkflowFiles(options.workflowDirectory); + const references = collectWorkflowReferences(workflows); + const { pinned: pinnedReferences, unpinned: unpinnedReferences } = + splitReferencesByPinning(references); + + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; + const { resolvedVersions, errors: resolutionErrors } = await resolveRepositoryVersions( + pinnedReferences, + token + ); + const staleReferences = collectStaleReferences(pinnedReferences, resolvedVersions); + + const jsonReport = buildJsonReport({ + workflowCount: workflows.length, + references, + pinnedReferences, + unpinnedReferences, + staleReferences, + resolutionErrors, + resolvedVersions, + }); + const markdownReport = buildMarkdownReport(jsonReport); + + const fileOutput = writeReportFiles({ + reportPath: options.reportPath, + jsonPath: options.jsonPath, + markdownReport, + jsonReport, + }); + + writeStepSummary(markdownReport); + + console.log(`[actions-freshness] report: ${fileOutput.reportAbsolutePath}`); + console.log(`[actions-freshness] json: ${fileOutput.jsonAbsolutePath}`); + console.log(`[actions-freshness] stale pinned references: ${jsonReport.staleCount}`); + + if (options.manageIssue) { + if (!token) { + throw new Error('Issue management requested, but GITHUB_TOKEN/GH_TOKEN is not set.'); + } + + const repositoryMetadata = parseRepositoryFromEnvironment(); + if (!repositoryMetadata) { + throw new Error( + 'Issue management requested, but GITHUB_REPOSITORY is not set to owner/repository.' + ); + } + + await upsertTrackingIssue({ + token, + owner: repositoryMetadata.owner, + repository: repositoryMetadata.repository, + issueTitle: options.issueTitle, + staleCount: jsonReport.staleCount, + reportMarkdown: markdownReport, + }); + } + + if (options.failOnStale && jsonReport.staleCount > 0) { + process.exitCode = 1; + } +} + +run().catch((error) => { + console.error(`[actions-freshness] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/lib/actions-freshness.js b/scripts/lib/actions-freshness.js new file mode 100644 index 0000000..c08b4b4 --- /dev/null +++ b/scripts/lib/actions-freshness.js @@ -0,0 +1,191 @@ +const USES_LINE_PATTERN = /^\s*(?:-\s*)?uses:\s*([^\s#]+)(?:\s+#.*)?\s*$/; +const ACTION_REFERENCE_PATTERN = + /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(\/[A-Za-z0-9_.\-\/]+)?@([^\s]+)$/; +const FULL_LENGTH_SHA_PATTERN = /^[a-f0-9]{40}$/i; + +function normalizeReferenceValue(referenceValue) { + const trimmed = referenceValue.trim(); + + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1); + } + + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +function isFullLengthSha(value) { + return FULL_LENGTH_SHA_PATTERN.test(value); +} + +function parseWorkflowContent(content, workflowPath) { + const references = []; + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const usesMatch = line.match(USES_LINE_PATTERN); + + if (!usesMatch) { + continue; + } + + const usesValue = normalizeReferenceValue(usesMatch[1]); + + if (usesValue.startsWith('./') || usesValue.startsWith('docker://')) { + continue; + } + + const actionMatch = usesValue.match(ACTION_REFERENCE_PATTERN); + + if (!actionMatch) { + continue; + } + + const owner = actionMatch[1]; + const repository = actionMatch[2]; + const subPath = actionMatch[3] || ''; + const ref = actionMatch[4]; + const repositoryKey = `${owner.toLowerCase()}/${repository.toLowerCase()}`; + + references.push({ + action: `${owner}/${repository}${subPath}`, + owner, + repository, + repositoryKey, + ref, + isPinned: isFullLengthSha(ref), + workflowPath, + lineNumber: index + 1, + }); + } + + return references; +} + +function collectWorkflowReferences(workflows) { + const references = []; + + for (const workflow of workflows) { + const parsed = parseWorkflowContent(workflow.content, workflow.path); + references.push(...parsed); + } + + return references; +} + +function splitReferencesByPinning(references) { + const pinned = []; + const unpinned = []; + + for (const reference of references) { + if (reference.isPinned) { + pinned.push(reference); + continue; + } + + unpinned.push(reference); + } + + return { pinned, unpinned }; +} + +function shortSha(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.slice(0, 12); +} + +function sortByLocation(left, right) { + if (left.workflowPath === right.workflowPath) { + return left.lineNumber - right.lineNumber; + } + + return left.workflowPath.localeCompare(right.workflowPath); +} + +function buildMarkdownReport(report) { + const lines = [ + '# GitHub Actions Freshness Report', + '', + `Generated at: ${report.generatedAt}`, + '', + `- Workflows scanned: ${report.workflowCount}`, + `- Action references found: ${report.totalReferences}`, + `- Full-SHA pinned references: ${report.pinnedCount}`, + `- Unpinned references: ${report.unpinnedCount}`, + `- Stale pinned references: ${report.staleCount}`, + '', + ]; + + if (report.staleCount > 0) { + lines.push('## Stale pinned references'); + lines.push(''); + lines.push('| Action | Pinned SHA | Latest tag | Latest SHA | Location |'); + lines.push('| --- | --- | --- | --- | --- |'); + + const staleSorted = [...report.staleReferences].sort(sortByLocation); + for (const stale of staleSorted) { + lines.push( + `| ${stale.action} | \`${shortSha(stale.ref)}\` | \`${stale.latestTag}\` | \`${shortSha(stale.latestSha)}\` | \`${stale.workflowPath}:${stale.lineNumber}\` |` + ); + } + + lines.push(''); + } + + if (report.unpinnedCount > 0) { + lines.push('## Unpinned references'); + lines.push(''); + lines.push('| Action | Ref | Location |'); + lines.push('| --- | --- | --- |'); + + const unpinnedSorted = [...report.unpinnedReferences].sort(sortByLocation); + for (const unpinned of unpinnedSorted) { + lines.push( + `| ${unpinned.action} | \`${unpinned.ref}\` | \`${unpinned.workflowPath}:${unpinned.lineNumber}\` |` + ); + } + + lines.push(''); + } + + if (report.resolutionErrors.length > 0) { + lines.push('## Resolution errors'); + lines.push(''); + lines.push('| Action repository | Error |'); + lines.push('| --- | --- |'); + + const errorsSorted = [...report.resolutionErrors].sort((left, right) => + left.repository.localeCompare(right.repository) + ); + for (const error of errorsSorted) { + lines.push(`| ${error.repository} | ${error.message} |`); + } + + lines.push(''); + } + + if (report.staleCount === 0 && report.resolutionErrors.length === 0) { + lines.push('All pinned GitHub Actions references are current against latest upstream releases.'); + lines.push(''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +module.exports = { + ACTION_REFERENCE_PATTERN, + FULL_LENGTH_SHA_PATTERN, + USES_LINE_PATTERN, + buildMarkdownReport, + collectWorkflowReferences, + isFullLengthSha, + parseWorkflowContent, + splitReferencesByPinning, +}; diff --git a/tests/unit/scripts/actions-freshness.test.js b/tests/unit/scripts/actions-freshness.test.js new file mode 100644 index 0000000..a777410 --- /dev/null +++ b/tests/unit/scripts/actions-freshness.test.js @@ -0,0 +1,106 @@ +const { + buildMarkdownReport, + collectWorkflowReferences, + parseWorkflowContent, + splitReferencesByPinning, +} = require('../../../scripts/lib/actions-freshness'); + +describe('actions freshness helpers', () => { + test('parseWorkflowContent captures reusable action references and pinning state', () => { + const content = ` +name: sample +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@v4 + - uses: ./local-action + - uses: docker://alpine:3 + - uses: github/codeql-action/upload-sarif@b5ebac6f4c00c8ccddb7cdcd45fdb248329f808a +`; + const references = parseWorkflowContent(content, '.github/workflows/sample.yml'); + + expect(references).toHaveLength(3); + expect(references[0]).toMatchObject({ + action: 'actions/checkout', + repositoryKey: 'actions/checkout', + isPinned: true, + ref: '34e114876b0b11c390a56381ad16ebd13914f8d5', + lineNumber: 7, + }); + expect(references[1]).toMatchObject({ + action: 'actions/setup-node', + repositoryKey: 'actions/setup-node', + isPinned: false, + ref: 'v4', + lineNumber: 8, + }); + expect(references[2]).toMatchObject({ + action: 'github/codeql-action/upload-sarif', + repositoryKey: 'github/codeql-action', + isPinned: true, + lineNumber: 11, + }); + }); + + test('collectWorkflowReferences and splitReferencesByPinning classify references across files', () => { + const workflows = [ + { + path: '.github/workflows/one.yml', + content: '- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', + }, + { + path: '.github/workflows/two.yml', + content: '- uses: actions/setup-node@v4', + }, + ]; + + const references = collectWorkflowReferences(workflows); + const { pinned, unpinned } = splitReferencesByPinning(references); + + expect(references).toHaveLength(2); + expect(pinned).toHaveLength(1); + expect(unpinned).toHaveLength(1); + expect(pinned[0].workflowPath).toBe('.github/workflows/one.yml'); + expect(unpinned[0].workflowPath).toBe('.github/workflows/two.yml'); + }); + + test('buildMarkdownReport renders stale and unpinned sections', () => { + const report = { + generatedAt: '2026-02-11T00:00:00.000Z', + workflowCount: 2, + totalReferences: 3, + pinnedCount: 2, + unpinnedCount: 1, + staleCount: 1, + staleReferences: [ + { + action: 'actions/checkout', + ref: '34e114876b0b11c390a56381ad16ebd13914f8d5', + latestTag: 'v5.0.0', + latestSha: '1111111111111111111111111111111111111111', + workflowPath: '.github/workflows/test.yml', + lineNumber: 12, + }, + ], + unpinnedReferences: [ + { + action: 'actions/setup-node', + ref: 'v4', + workflowPath: '.github/workflows/test.yml', + lineNumber: 24, + }, + ], + resolutionErrors: [], + }; + + const markdown = buildMarkdownReport(report); + + expect(markdown).toContain('# GitHub Actions Freshness Report'); + expect(markdown).toContain('## Stale pinned references'); + expect(markdown).toContain('actions/checkout'); + expect(markdown).toContain('## Unpinned references'); + expect(markdown).toContain('actions/setup-node'); + }); +}); From 34e2096ed19deac4a4ef2cc610da7b7a00875148 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Wed, 11 Feb 2026 18:13:28 +0000 Subject: [PATCH 2/2] ci: scope freshness permissions and harden uses parsing --- .github/workflows/actions-freshness.yml | 4 ++- scripts/lib/actions-freshness.js | 29 +++++++++++++++++--- tests/unit/scripts/actions-freshness.test.js | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/.github/workflows/actions-freshness.yml b/.github/workflows/actions-freshness.yml index a18aa5e..18a495f 100644 --- a/.github/workflows/actions-freshness.yml +++ b/.github/workflows/actions-freshness.yml @@ -7,7 +7,6 @@ on: permissions: contents: read - issues: write concurrency: group: actions-freshness-${{ github.ref }} @@ -18,6 +17,9 @@ jobs: name: Audit pinned GitHub Actions references runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + issues: write steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/scripts/lib/actions-freshness.js b/scripts/lib/actions-freshness.js index c08b4b4..a0cb9d9 100644 --- a/scripts/lib/actions-freshness.js +++ b/scripts/lib/actions-freshness.js @@ -1,4 +1,4 @@ -const USES_LINE_PATTERN = /^\s*(?:-\s*)?uses:\s*([^\s#]+)(?:\s+#.*)?\s*$/; +const USES_LINE_PATTERN = /^\s*(?:-\s*)?uses:\s*/; const ACTION_REFERENCE_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(\/[A-Za-z0-9_.\-\/]+)?@([^\s]+)$/; const FULL_LENGTH_SHA_PATTERN = /^[a-f0-9]{40}$/i; @@ -21,19 +21,40 @@ function isFullLengthSha(value) { return FULL_LENGTH_SHA_PATTERN.test(value); } +function extractUsesReferenceValue(line) { + const usesPrefixMatch = line.match(USES_LINE_PATTERN); + + if (!usesPrefixMatch) { + return null; + } + + const remainder = line.slice(usesPrefixMatch[0].length).trim(); + + if (remainder.length === 0) { + return null; + } + + const firstWhitespaceIndex = remainder.search(/\s/); + if (firstWhitespaceIndex === -1) { + return remainder; + } + + return remainder.slice(0, firstWhitespaceIndex); +} + function parseWorkflowContent(content, workflowPath) { const references = []; const lines = content.split(/\r?\n/); for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; - const usesMatch = line.match(USES_LINE_PATTERN); + const rawUsesValue = extractUsesReferenceValue(line); - if (!usesMatch) { + if (!rawUsesValue) { continue; } - const usesValue = normalizeReferenceValue(usesMatch[1]); + const usesValue = normalizeReferenceValue(rawUsesValue); if (usesValue.startsWith('./') || usesValue.startsWith('docker://')) { continue; diff --git a/tests/unit/scripts/actions-freshness.test.js b/tests/unit/scripts/actions-freshness.test.js index a777410..4f20926 100644 --- a/tests/unit/scripts/actions-freshness.test.js +++ b/tests/unit/scripts/actions-freshness.test.js @@ -66,6 +66,34 @@ jobs: expect(unpinned[0].workflowPath).toBe('.github/workflows/two.yml'); }); + test('parseWorkflowContent handles quoted references with inline comments', () => { + const content = ` +name: quoted +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: "actions/cache@v4" # cache + - uses: 'actions/upload-artifact@v4' # upload +`; + + const references = parseWorkflowContent(content, '.github/workflows/quoted.yml'); + + expect(references).toHaveLength(2); + expect(references[0]).toMatchObject({ + action: 'actions/cache', + ref: 'v4', + isPinned: false, + lineNumber: 7, + }); + expect(references[1]).toMatchObject({ + action: 'actions/upload-artifact', + ref: 'v4', + isPinned: false, + lineNumber: 8, + }); + }); + test('buildMarkdownReport renders stale and unpinned sections', () => { const report = { generatedAt: '2026-02-11T00:00:00.000Z',