Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
uses: ./github-actions/linting/licenses
with:
allow-dependencies-licenses: 'pkg:npm/renovate, pkg:npm/@renovatebot/detect-tools'
- name: Enforce GHA SHAs
uses: ./github-actions/linting/gha-sha-enforcer

test:
name: ${{ matrix.name }}
Expand Down
1 change: 1 addition & 0 deletions .ng-dev/commit-message.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const commitMessage = {
...buildScopesFor('github-actions', [
'pull-request-labeling',
'feature-request',
'gha-sha-enforcer',
'lock-closed',
'rebase',
]),
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ github-actions/branch-manager/main.js
github-actions/browserstack/set-browserstack-env.js
github-actions/labeling/pull-request/main.js
github-actions/google-internal-tests/main.js
github-actions/linting/gha-sha-enforcer/main.js
github-actions/org-file-sync/main.js
github-actions/post-approval-changes/main.js
github-actions/release/publish/main.js
Expand Down
27 changes: 27 additions & 0 deletions github-actions/linting/gha-sha-enforcer/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
load("//tools:defaults.bzl", "esbuild_checked_in", "ts_project")

package(default_visibility = ["//github-actions/linting/gha-sha-enforcer:__subpackages__"])

npm_link_all_packages()

ts_project(
name = "lib",
srcs = glob(["lib/*.ts"]),
tsconfig = "//github-actions:tsconfig",
deps = [
":node_modules/@actions/core",
":node_modules/@types/node",
],
)

esbuild_checked_in(
name = "main",
srcs = [
":lib",
],
entry_point = "lib/main.ts",
format = "esm",
platform = "node",
target = "node24",
)
6 changes: 6 additions & 0 deletions github-actions/linting/gha-sha-enforcer/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: 'GHA SHA Enforcer'
description: 'Enforce full-length SHAs and version comments for external GitHub Actions'
author: 'Angular'
runs:
using: 'node24'
main: 'main.js'
129 changes: 129 additions & 0 deletions github-actions/linting/gha-sha-enforcer/lib/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as core from '@actions/core';

// Regex to match uses: lines
const USES_REGEX = /^\s*(?:-\s*)?uses:\s*(.+)$/;
const BLOCK_SCALAR_REGEX = /^\s*(?:-\s*)?[a-zA-Z0-9_-]+\s*:\s*[|>]/;

function checkWorkflowFile(filePath: string): boolean {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let isValid = true;
let inBlockScalar = false;
let blockIndent = 0;

lines.forEach((line, index) => {
if (inBlockScalar) {
const currentIndent = line.search(/\S/);
if (currentIndent !== -1 && currentIndent <= blockIndent) {
inBlockScalar = false;
} else {
return;
}
}

if (line.match(BLOCK_SCALAR_REGEX)) {
inBlockScalar = true;
blockIndent = line.search(/\S/);
}

const match = line.match(USES_REGEX);
Comment thread
josephperrott marked this conversation as resolved.
if (match) {
const fullUses = match[1].trim();

// Split by '#' to separate comment
const hashIndex = fullUses.indexOf('#');
let usesPart = fullUses;
let commentPart = '';

if (hashIndex !== -1) {
usesPart = fullUses.substring(0, hashIndex).trim();
commentPart = fullUses.substring(hashIndex + 1).trim();
}

// Remove quotes from usesPart
const uses = usesPart.replace(/^['"]|['"]$/g, '').trim();

// Skip local actions
if (uses.startsWith('./')) {
return;
}

// Now verify `uses` has SHA and `commentPart` has version
const atIndex = uses.indexOf('@');
if (atIndex === -1) {
core.error(
`${filePath}:${index + 1}: Action "${uses}" should use "action-name@SHA # version" format (missing @).`,
);
isValid = false;
return;
}

const action = uses.substring(0, atIndex);
const version = uses.substring(atIndex + 1);

const isSha = /^[a-fA-F0-9]{40}$/.test(version);
if (!isSha) {
core.error(
`${filePath}:${index + 1}: Action "${action}" should use a 40-character SHA for version, but got "${version}".`,
);
isValid = false;
}

if (!commentPart) {
core.error(
`${filePath}:${index + 1}: Action "${action}" is missing a version comment (e.g. "# v1.0.0").`,
);
isValid = false;
} else {
const isVersionComment = /^v\d+/.test(commentPart);
if (!isVersionComment) {
core.error(
`${filePath}:${index + 1}: Action "${action}" has comment "${commentPart}" which does not look like a version (should start with 'v', e.g. '# v1.0.0').`,
);
isValid = false;
}
}
}
});

return isValid;
}

function run() {
try {
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const workflowsDir = path.join(workspace, '.github/workflows');
if (!fs.existsSync(workflowsDir)) {
core.setFailed(`Workflows directory not found: ${workflowsDir}`);
return;
}

const files = fs
.readdirSync(workflowsDir, {withFileTypes: true})
.filter(
(entry) => entry.isFile() && (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')),
)
.map((entry) => entry.name);
let allValid = true;

for (const file of files) {
const filePath = path.join(workflowsDir, file);
core.info(`Checking ${file}...`);
if (!checkWorkflowFile(filePath)) {
allValid = false;
}
}

if (!allValid) {
core.setFailed('Some workflows are using unpinned actions or missing version comments.');
} else {
core.info('All workflows are valid!');
}
} catch (error: any) {
core.setFailed(error.message);
}
}

run();
Loading
Loading