This commit is contained in:
awalsh128 2026-06-30 00:17:51 -07:00
parent 1d921eeba9
commit 3f70aca939
6 changed files with 370 additions and 32 deletions

View file

@ -1,5 +1,5 @@
name: "Verify code health and quality"
description: "Runs build, linters, type, tests, and generates API docs to verify the code is in a good state"
description: "Runs linters, type checks, build, tests or coverage, optional CodeQL analysis, and API docs generation"
inputs:
run_tests:
@ -7,9 +7,29 @@ inputs:
required: false
default: "true"
run_coverage:
description: "Whether to run coverage checks (default: false). When true, coverage run replaces normal tests."
required: false
default: "false"
run_codeql:
description: "Whether to run CodeQL analysis (default: false)"
required: false
default: "false"
runs:
using: "composite"
steps:
- name: Initialize CodeQL
if: ${{ inputs.run_codeql == 'true' }}
uses: "github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357" # v3.36.2
with:
languages: javascript-typescript
- name: Autobuild CodeQL
if: ${{ inputs.run_codeql == 'true' }}
uses: "github/codeql-action/autobuild@dd903d2e4f5405488e5ef1422510ee31c8b32357" # v3.36.2
- name: Lint
shell: bash
run: npm run lint
@ -49,6 +69,17 @@ runs:
run: npm run docs:api
- name: Tests
if: ${{ inputs.run_tests == 'true' }}
if: ${{ inputs.run_tests == 'true' && inputs.run_coverage != 'true' }}
shell: bash
run: npm test
- name: Coverage
if: ${{ inputs.run_coverage == 'true' }}
shell: bash
run: npm run test:coverage
- name: Analyze CodeQL
if: ${{ inputs.run_codeql == 'true' }}
uses: "github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357" # v3.36.2
with:
category: "/language:javascript-typescript"

View file

@ -17,6 +17,8 @@ on:
env:
DEBUG: ${{ github.event.inputs.debug || false }}
permissions:
actions: read
contents: read
security-events: write
jobs:
@ -33,36 +35,12 @@ jobs:
- name: Setup
uses: ./.github/actions/setup-env
- name: Initialize CodeQL
uses: "github/codeql-action/init@1a818fd5f97ed0ee9a823421bd5b171add01227f" # v4.36.2
with:
languages: javascript-typescript
queries: security-extended,security-and-quality
- name: Check code health
uses: ./.github/actions/codehealth
with:
run_tests: "false" # Run full testing with coverage below
- name: Run tests with coverage
shell: bash
run: npm run test:coverage
- name: Upload coverage info
uses: "codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1" # v7.0.0
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
- name: Annotate coverage on changed files
uses: "jgillick/test-coverage-annotations@74fb1a9d4dbe8048001f4334c60095f65060c62a" # v1.0.3
with:
access-token: ${{ secrets.GITHUB_TOKEN }}
coverage: ./coverage/coverage-final.json
only-changed-files: true
- name: Analyze with CodeQL
uses: "github/codeql-action/analyze@1a818fd5f97ed0ee9a823421bd5b171add01227f" # v4.36.2
run_tests: "false"
run_coverage: "true"
run_codeql: "true"
# All other jobs should depend on build_binaries (if run) or check_distribute (if not needed)
list_all_versions:

View file

@ -0,0 +1,327 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
function usage() {
console.error(
"usage: node dev_scripts/check_latest_action_pin.mjs <owner/repo[/path]@ref | owner/repo[/path] ref | --scan [directory]>",
);
}
function parseInput(argv) {
if (argv[0] === "--scan") {
return {
mode: "scan",
rootDir: argv[1] ?? ".github",
};
}
if (argv.length === 1) {
const atIndex = argv[0].lastIndexOf("@");
if (atIndex <= 0 || atIndex === argv[0].length - 1) {
usage();
process.exit(1);
}
return {
mode: "single",
actionPath: argv[0].slice(0, atIndex),
currentRef: argv[0].slice(atIndex + 1),
};
}
if (argv.length === 2) {
return {
mode: "single",
actionPath: argv[0],
currentRef: argv[1],
};
}
usage();
process.exit(1);
}
function parseRepoSlug(actionPath) {
const parts = actionPath.split("/").filter(Boolean);
if (parts.length < 2) {
throw new Error(
`Action path '${actionPath}' must include at least owner/repo.`,
);
}
return `${parts[0]}/${parts[1]}`;
}
function git(args) {
return execFileSync("git", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function buildTagMap(repoSlug) {
const remoteUrl = `https://github.com/${repoSlug}.git`;
const output = git(["ls-remote", "--tags", remoteUrl]);
const tagMap = new Map();
for (const line of output.split("\n")) {
if (!line.trim()) {
continue;
}
const [sha, ref] = line.split(/\s+/);
const prefix = "refs/tags/";
if (!sha || !ref || !ref.startsWith(prefix)) {
continue;
}
const rawTag = ref.slice(prefix.length);
const isPeeled = rawTag.endsWith("^{}");
const tag = isPeeled ? rawTag.slice(0, -3) : rawTag;
if (isPeeled || !tagMap.has(tag)) {
tagMap.set(tag, sha.toLowerCase());
}
}
return tagMap;
}
function resolveRefSha(repoSlug, ref, tagMap) {
const normalizedRef = ref.toLowerCase();
if (/^[0-9a-f]{40}$/.test(normalizedRef)) {
return normalizedRef;
}
if (tagMap.has(ref)) {
return tagMap.get(ref);
}
const remoteUrl = `https://github.com/${repoSlug}.git`;
const output = git(["ls-remote", remoteUrl, ref]);
const firstLine = output.split("\n")[0] ?? "";
const [sha] = firstLine.split(/\s+/);
if (!sha) {
throw new Error(`Unable to resolve ref '${ref}' for ${repoSlug}.`);
}
return sha.toLowerCase();
}
async function fetchJson(url) {
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "cache-apt-pkgs-action-pin-checker",
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const response = await fetch(url, { headers });
if (response.status === 404) {
return null;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`GitHub API request failed (${response.status}): ${body}`);
}
return response.json();
}
async function getLatestRelease(repoSlug) {
return fetchJson(`https://api.github.com/repos/${repoSlug}/releases/latest`);
}
async function getReleases(repoSlug) {
const releases = await fetchJson(
`https://api.github.com/repos/${repoSlug}/releases?per_page=100`,
);
return Array.isArray(releases) ? releases : [];
}
function findReleaseBySha(releases, tagMap, sha) {
return (
releases.find(
(release) => tagMap.get(release.tag_name)?.toLowerCase() === sha,
) ?? null
);
}
function printField(label, value) {
console.log(`${label}: ${value ?? "none"}`);
}
function walkFiles(rootDir) {
const results = [];
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
results.push(...walkFiles(fullPath));
continue;
}
if (entry.name.endsWith(".yml") || entry.name.endsWith(".yaml")) {
results.push(fullPath);
}
}
return results.sort((a, b) => a.localeCompare(b));
}
function findActionPins(rootDir) {
const results = [];
const usesPattern = /^\s*uses:\s*["']?([^"'\s#]+)["']?/;
for (const filePath of walkFiles(rootDir)) {
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const match = usesPattern.exec(line);
if (!match) {
continue;
}
const spec = match[1] ?? "";
if (
!spec.includes("@") ||
spec.startsWith("./") ||
spec.startsWith("docker://")
) {
continue;
}
const atIndex = spec.lastIndexOf("@");
const actionPath = spec.slice(0, atIndex);
const currentRef = spec.slice(atIndex + 1);
if (!actionPath || !currentRef) {
continue;
}
results.push({
filePath,
lineNumber: index + 1,
actionPath,
currentRef,
});
}
}
return results;
}
const repoCache = new Map();
async function getRepoData(repoSlug) {
const cached = repoCache.get(repoSlug);
if (cached) {
return cached;
}
const promise = (async () => {
const tagMap = buildTagMap(repoSlug);
const [latestRelease, releases] = await Promise.all([
getLatestRelease(repoSlug),
getReleases(repoSlug),
]);
return { tagMap, latestRelease, releases };
})();
repoCache.set(repoSlug, promise);
return promise;
}
async function analyzeAction(actionPath, currentRef) {
const repoSlug = parseRepoSlug(actionPath);
const { tagMap, latestRelease, releases } = await getRepoData(repoSlug);
const currentSha = resolveRefSha(repoSlug, currentRef, tagMap);
const currentRelease = findReleaseBySha(releases, tagMap, currentSha);
const latestSha = latestRelease
? (tagMap.get(latestRelease.tag_name) ?? null)
: null;
const isCurrent = latestSha !== null && latestSha === currentSha;
return {
actionPath,
repoSlug,
currentRef,
currentSha,
currentRelease,
latestRelease,
latestSha,
isCurrent,
};
}
function printAnalysis(analysis) {
console.log(`Action: ${analysis.actionPath}`);
printField("Repository", analysis.repoSlug);
printField("Current ref", analysis.currentRef);
printField("Current sha", analysis.currentSha);
printField("Current release", analysis.currentRelease?.tag_name ?? null);
printField("Current release URL", analysis.currentRelease?.html_url ?? null);
printField("Latest release", analysis.latestRelease?.tag_name ?? null);
printField(
"Latest pin",
analysis.latestSha ? `${analysis.actionPath}@${analysis.latestSha}` : null,
);
printField("Latest release URL", analysis.latestRelease?.html_url ?? null);
printField(
"Status",
analysis.latestSha === null
? "no release found"
: analysis.isCurrent
? "up to date"
: "update available",
);
}
async function main() {
const input = parseInput(process.argv.slice(2));
if (input.mode === "scan") {
const rootDir = path.resolve(process.cwd(), input.rootDir);
const pins = findActionPins(rootDir);
if (pins.length === 0) {
console.log(`No pinned GitHub Actions found under ${input.rootDir}.`);
return;
}
let updateCount = 0;
for (const pin of pins) {
const analysis = await analyzeAction(pin.actionPath, pin.currentRef);
console.log(`File: ${pin.filePath}:${pin.lineNumber}`);
printAnalysis(analysis);
console.log("");
if (analysis.latestSha && !analysis.isCurrent) {
updateCount += 1;
}
}
console.log(`Scanned ${pins.length} pinned action reference(s).`);
console.log(`Updates available: ${updateCount}`);
if (updateCount > 0) {
process.exitCode = 2;
}
return;
}
const analysis = await analyzeAction(input.actionPath, input.currentRef);
printAnalysis(analysis);
if (analysis.latestSha && !analysis.isCurrent) {
process.exitCode = 2;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

0
dev_scripts/release-preflight.mjs Normal file → Executable file
View file

4
dev_scripts/switch_tsapt_local.sh Normal file → Executable file
View file

@ -15,9 +15,9 @@ function is_local_switched() {
function switch() {
if ! is_local_switched; then
echo "Switching ts-apt dependency to local path '${LOCAL_VAL}'..."
if [[ ! -d "${SCRIPT_DIR}/../ts-apt" ]]; then
if [[ ! -d "${SCRIPT_DIR}/../../ts-apt" ]]; then
echo "Local ts-apt path '${LOCAL_VAL}' does not exist. Checking out..."
clone_repo "ts-apt" "${SCRIPT_DIR}/../ts-apt"
clone_repo "ts-apt" "${SCRIPT_DIR}/../../ts-apt"
fi
sed -i "s#\"ts-apt\": \".*\",#\"ts-apt\": \"${LOCAL_VAL}\",#" "${PACKAGE_PATH}"
echo "Switched to local ts-apt. Run 'npm install --ignore-scripts' to refresh node_modules and package-lock.json."

View file

@ -10,6 +10,8 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"check:latest-action-pin": "node ./dev_scripts/check_latest_action_pin.mjs",
"check:latest-action-pins": "node ./dev_scripts/check_latest_action_pin.mjs --scan",
"clean": "rm -rf dist coverage",
"lint": "eslint src test --ext .ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
@ -23,7 +25,7 @@
"@actions/cache": "^6.1.0",
"@actions/core": "^1.11.1",
"tar": "^7.4.3",
"ts-apt": "^0.1.0",
"ts-apt": "file:../ts-apt",
"winston": "^3.19.0"
},
"devDependencies": {