Skip to content

Commit bea2d86

Browse files
committed
feat(github-actions): add gha-sha-enforcer lint action
1 parent bc6edea commit bea2d86

10 files changed

Lines changed: 19481 additions & 54 deletions

File tree

.github/workflows/pr.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ jobs:
4040
uses: ./github-actions/linting/licenses
4141
with:
4242
allow-dependencies-licenses: 'pkg:npm/renovate, pkg:npm/@renovatebot/detect-tools'
43+
- name: Enforce GHA SHAs
44+
uses: ./github-actions/linting/gha-sha-enforcer
4345

4446
test:
4547
name: ${{ matrix.name }}

.ng-dev/commit-message.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const commitMessage = {
2929
...buildScopesFor('github-actions', [
3030
'pull-request-labeling',
3131
'feature-request',
32+
'gha-sha-enforcer',
3233
'lock-closed',
3334
'rebase',
3435
]),

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ github-actions/branch-manager/main.js
1010
github-actions/browserstack/set-browserstack-env.js
1111
github-actions/labeling/pull-request/main.js
1212
github-actions/google-internal-tests/main.js
13+
github-actions/linting/gha-sha-enforcer/main.js
1314
github-actions/org-file-sync/main.js
1415
github-actions/post-approval-changes/main.js
1516
github-actions/release/publish/main.js
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
2+
load("//tools:defaults.bzl", "esbuild_checked_in", "ts_project")
3+
4+
package(default_visibility = ["//github-actions/linting/gha-sha-enforcer:__subpackages__"])
5+
6+
npm_link_all_packages()
7+
8+
ts_project(
9+
name = "lib",
10+
srcs = glob(["lib/*.ts"]),
11+
tsconfig = "//github-actions:tsconfig",
12+
deps = [
13+
":node_modules/@actions/core",
14+
":node_modules/@types/node",
15+
],
16+
)
17+
18+
esbuild_checked_in(
19+
name = "main",
20+
srcs = [
21+
":lib",
22+
],
23+
entry_point = "lib/main.ts",
24+
format = "esm",
25+
platform = "node",
26+
target = "node24",
27+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name: 'GHA SHA Enforcer'
2+
description: 'Enforce full-length SHAs and version comments for external GitHub Actions'
3+
author: 'Angular'
4+
runs:
5+
using: 'node24'
6+
main: 'main.js'
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as core from '@actions/core';
4+
5+
// Regex to match uses: lines
6+
const USES_REGEX = /^\s*(?:-\s*)?uses:\s*(.+)$/;
7+
const BLOCK_SCALAR_REGEX = /^\s*(?:-\s*)?[a-zA-Z0-9_-]+\s*:\s*[|>]/;
8+
9+
function checkWorkflowFile(filePath: string): boolean {
10+
const content = fs.readFileSync(filePath, 'utf8');
11+
const lines = content.split('\n');
12+
let isValid = true;
13+
let inBlockScalar = false;
14+
let blockIndent = 0;
15+
16+
lines.forEach((line, index) => {
17+
if (inBlockScalar) {
18+
const currentIndent = line.search(/\S/);
19+
if (currentIndent !== -1 && currentIndent <= blockIndent) {
20+
inBlockScalar = false;
21+
} else {
22+
return;
23+
}
24+
}
25+
26+
if (line.match(BLOCK_SCALAR_REGEX)) {
27+
inBlockScalar = true;
28+
blockIndent = line.search(/\S/);
29+
}
30+
31+
const match = line.match(USES_REGEX);
32+
if (match) {
33+
const fullUses = match[1].trim();
34+
35+
// Split by '#' to separate comment
36+
const hashIndex = fullUses.indexOf('#');
37+
let usesPart = fullUses;
38+
let commentPart = '';
39+
40+
if (hashIndex !== -1) {
41+
usesPart = fullUses.substring(0, hashIndex).trim();
42+
commentPart = fullUses.substring(hashIndex + 1).trim();
43+
}
44+
45+
// Remove quotes from usesPart
46+
const uses = usesPart.replace(/^['"]|['"]$/g, '').trim();
47+
48+
// Skip local actions
49+
if (uses.startsWith('./')) {
50+
return;
51+
}
52+
53+
// Now verify `uses` has SHA and `commentPart` has version
54+
const atIndex = uses.indexOf('@');
55+
if (atIndex === -1) {
56+
core.error(
57+
`${filePath}:${index + 1}: Action "${uses}" should use "action-name@SHA # version" format (missing @).`,
58+
);
59+
isValid = false;
60+
return;
61+
}
62+
63+
const action = uses.substring(0, atIndex);
64+
const version = uses.substring(atIndex + 1);
65+
66+
const isSha = /^[a-fA-F0-9]{40}$/.test(version);
67+
if (!isSha) {
68+
core.error(
69+
`${filePath}:${index + 1}: Action "${action}" should use a 40-character SHA for version, but got "${version}".`,
70+
);
71+
isValid = false;
72+
}
73+
74+
if (!commentPart) {
75+
core.error(
76+
`${filePath}:${index + 1}: Action "${action}" is missing a version comment (e.g. "# v1.0.0").`,
77+
);
78+
isValid = false;
79+
} else {
80+
const isVersionComment = /^v\d+/.test(commentPart);
81+
if (!isVersionComment) {
82+
core.error(
83+
`${filePath}:${index + 1}: Action "${action}" has comment "${commentPart}" which does not look like a version (should start with 'v', e.g. '# v1.0.0').`,
84+
);
85+
isValid = false;
86+
}
87+
}
88+
}
89+
});
90+
91+
return isValid;
92+
}
93+
94+
function run() {
95+
try {
96+
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
97+
const workflowsDir = path.join(workspace, '.github/workflows');
98+
if (!fs.existsSync(workflowsDir)) {
99+
core.setFailed(`Workflows directory not found: ${workflowsDir}`);
100+
return;
101+
}
102+
103+
const files = fs
104+
.readdirSync(workflowsDir, {withFileTypes: true})
105+
.filter(
106+
(entry) => entry.isFile() && (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')),
107+
)
108+
.map((entry) => entry.name);
109+
let allValid = true;
110+
111+
for (const file of files) {
112+
const filePath = path.join(workflowsDir, file);
113+
core.info(`Checking ${file}...`);
114+
if (!checkWorkflowFile(filePath)) {
115+
allValid = false;
116+
}
117+
}
118+
119+
if (!allValid) {
120+
core.setFailed('Some workflows are using unpinned actions or missing version comments.');
121+
} else {
122+
core.info('All workflows are valid!');
123+
}
124+
} catch (error: any) {
125+
core.setFailed(error.message);
126+
}
127+
}
128+
129+
run();

0 commit comments

Comments
 (0)