diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5d89c59..3f2dded 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 @@ -36,10 +36,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 @@ -56,10 +56,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 diff --git a/.gitignore b/.gitignore index c11477c..fee7b77 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ yarn-error.log* cache/ artifacts/ +# Backup files +*.backup + # Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties` # should NOT be excluded as they contain compiler settings and other important # information for Eclipse / Flash Builder. diff --git a/README.md b/README.md index 7d0d366..b2a3235 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This project contains: - **SEQICO.sol**: The main ICO contract allowing token purchases with ETH, USDT, and USDC - **SEQToken.sol**: The ERC20 token contract - **Deployment scripts**: Two deployment scripts with different configurations +- **GitHub Actions Security**: Automated pinning of GitHub Actions to commit SHAs for improved security ## Features @@ -62,6 +63,64 @@ The deployment scripts include configurable parameters: - Token pricing for ETH, USDT, and USDC - Total supply (500,000 SEQ tokens) +## GitHub Actions Security + +This repository includes an automated script to pin GitHub Actions to their full-length commit SHAs for improved security. This prevents supply chain attacks where action tags could be moved to malicious commits. + +### Using the GitHub Actions Pinner + +The pinning script is located at `scripts/pin-github-actions.js` and can be used as follows: + +```bash +# Run in dry-run mode to see what would be changed +node scripts/pin-github-actions.js --dry-run --verbose + +# Pin all GitHub Actions to commit SHAs +node scripts/pin-github-actions.js --verbose + +# Get help +node scripts/pin-github-actions.js --help +``` + +### Features + +- **Automatic Detection**: Scans all workflow files in `.github/workflows/` +- **Smart Filtering**: Only processes GitHub-hosted actions, skips local and already-pinned actions +- **Safe Updates**: Validates YAML syntax after updates +- **Dry Run Mode**: Preview changes before applying them +- **Comprehensive Logging**: Detailed output with `--verbose` flag +- **Error Handling**: Graceful handling of non-existent actions or API failures + +### Environment Variables + +- `GITHUB_TOKEN`: Optional GitHub personal access token to avoid rate limits + +### Testing + +Run the test suite to validate the pinning functionality: + +```bash +node test/pin-github-actions.test.js +``` + +### Example Output + +``` +๐Ÿ” Scanning for GitHub Actions to pin... +๐Ÿ“ Found 2 unique actions to pin: + - actions/checkout@v3 + - actions/setup-node@v3 + +๐Ÿ”— Fetching latest commit SHAs... +โœ“ actions/checkout@v3 โ†’ f43a0e5ff2bd294095638e18286ca9a3d1956744 +โœ“ actions/setup-node@v3 โ†’ 3235b876344d2a9aa001b8d1453c930bba69e610 + +๐Ÿ“ Updating workflow files... +โœ“ Updated ci-cd.yml (6 changes) + +๐ŸŽฏ Successfully pinned 6 actions in 1 files. +``` + ## License MIT diff --git a/package-lock.json b/package-lock.json index 34ecc06..5d57d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "ISC", "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^6.1.0", + "@octokit/rest": "^22.0.0", "@openzeppelin/contracts": "^5.4.0", - "hardhat": "^3.0.3" + "hardhat": "^3.0.3", + "js-yaml": "^4.1.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -741,6 +743,172 @@ "node": ">= 12" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.3.tgz", + "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.1", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz", + "integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz", + "integrity": "sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz", + "integrity": "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz", + "integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.2", + "@octokit/plugin-paginate-rest": "^13.0.1", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, "node_modules/@openzeppelin/contracts": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", @@ -844,6 +1012,20 @@ "node": ">=8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", @@ -954,6 +1136,23 @@ "@scure/bip39": "1.3.0" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", @@ -1022,6 +1221,19 @@ "hardhat": "dist/src/cli.js" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-stream-stringify": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.6.tgz", @@ -1199,6 +1411,13 @@ "node": ">=18.17" } }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index d426f1a..0ce6649 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,19 @@ "description": "Deployment", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node test/pin-github-actions.test.js", + "pin-actions": "node scripts/pin-github-actions.js", + "pin-actions-dry": "node scripts/pin-github-actions.js --dry-run --verbose" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^6.1.0", + "@octokit/rest": "^22.0.0", "@openzeppelin/contracts": "^5.4.0", - "hardhat": "^3.0.3" + "hardhat": "^3.0.3", + "js-yaml": "^4.1.0" }, "type": "module" } diff --git a/scripts/pin-actions.sh b/scripts/pin-actions.sh new file mode 100755 index 0000000..258e02f --- /dev/null +++ b/scripts/pin-actions.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# GitHub Actions Security Pinning Script +# This script pins all GitHub Actions in workflow files to their commit SHAs + +set -e + +echo "๐Ÿ” GitHub Actions Security Pinning" +echo "==================================" + +# Check if Node.js is available +if ! command -v node &> /dev/null; then + echo "โŒ Error: Node.js is not installed or not in PATH" + exit 1 +fi + +# Check if we're in the right directory +if [ ! -f "scripts/pin-github-actions.js" ]; then + echo "โŒ Error: This script must be run from the repository root" + echo " Expected to find: scripts/pin-github-actions.js" + exit 1 +fi + +# Check if .github/workflows directory exists +if [ ! -d ".github/workflows" ]; then + echo "โ„น๏ธ No .github/workflows directory found. Nothing to pin." + exit 0 +fi + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + echo "๐Ÿ“ฆ Installing dependencies..." + npm install --legacy-peer-deps --silent +fi + +# Run the pinning script +echo "๐Ÿ” Scanning for unpinned GitHub Actions..." + +if [ "${1:-}" = "--dry-run" ]; then + echo "๐Ÿงช Running in dry-run mode..." + node scripts/pin-github-actions.js --dry-run --verbose +else + node scripts/pin-github-actions.js --verbose + + # Check if any changes were made + if git diff --quiet .github/workflows/; then + echo "โœ… No changes needed - all actions were already pinned" + else + echo "๐Ÿ“ Changes detected. Committing pinned actions..." + git add .github/workflows/ + git commit -m "Pin GitHub Actions to full-length commit SHAs for improved security + +- Updated workflow files to use commit SHAs instead of tags +- This prevents supply chain attacks where tags could be moved to malicious commits +- Generated by: scripts/pin-github-actions.js" + echo "โœ… Changes committed successfully" + fi +fi + +echo "๐ŸŽฏ Done!" \ No newline at end of file diff --git a/scripts/pin-github-actions.js b/scripts/pin-github-actions.js new file mode 100644 index 0000000..01bab5a --- /dev/null +++ b/scripts/pin-github-actions.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { Octokit } from '@octokit/rest'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class GitHubActionsPinner { + constructor(options = {}) { + this.octokit = new Octokit({ + auth: options.token || process.env.GITHUB_TOKEN, + }); + this.workflowsDir = options.workflowsDir || path.join(process.cwd(), '.github', 'workflows'); + this.dryRun = options.dryRun || false; + this.verbose = options.verbose || false; + } + + /** + * Log verbose messages + */ + log(message) { + if (this.verbose) { + console.log(`[INFO] ${message}`); + } + } + + /** + * Find all workflow files in the .github/workflows directory + */ + async findWorkflowFiles() { + this.log(`Searching for workflow files in ${this.workflowsDir}`); + + if (!fs.existsSync(this.workflowsDir)) { + throw new Error(`Workflows directory not found: ${this.workflowsDir}`); + } + + const files = fs.readdirSync(this.workflowsDir) + .filter(file => file.endsWith('.yml') || file.endsWith('.yaml')) + .map(file => path.join(this.workflowsDir, file)); + + this.log(`Found ${files.length} workflow files: ${files.map(f => path.basename(f)).join(', ')}`); + return files; + } + + /** + * Parse a workflow file and extract GitHub Actions + */ + async parseWorkflowFile(filePath) { + this.log(`Parsing workflow file: ${path.basename(filePath)}`); + + const content = fs.readFileSync(filePath, 'utf8'); + const workflow = yaml.load(content); + const actions = []; + + if (!workflow || typeof workflow !== 'object' || !workflow.jobs) { + this.log(`No jobs found in workflow file: ${path.basename(filePath)}`); + return { content, workflow, actions }; + } + + // Extract actions from all jobs and steps + for (const [jobName, job] of Object.entries(workflow.jobs)) { + if (job.steps && Array.isArray(job.steps)) { + for (const [stepIndex, step] of job.steps.entries()) { + if (step.uses && typeof step.uses === 'string') { + const actionRef = this.parseActionReference(step.uses); + if (actionRef && !actionRef.isPinned) { + actions.push({ + jobName, + stepIndex, + stepName: step.name || 'Unnamed step', + originalUses: step.uses, + ...actionRef + }); + } + } + } + } + } + + this.log(`Found ${actions.length} unpinned actions in ${path.basename(filePath)}`); + return { content, workflow, actions }; + } + + /** + * Parse an action reference (e.g., "actions/checkout@v3") + */ + parseActionReference(uses) { + const actionPattern = /^([^@]+)@(.+)$/; + const match = uses.match(actionPattern); + + if (!match) { + this.log(`Skipping invalid action reference: ${uses}`); + return null; + } + + const [, actionPath, ref] = match; + + // Check if it's already pinned to a full SHA (40 characters) + const isPinned = /^[a-f0-9]{40}$/.test(ref); + + // Only process GitHub-hosted actions + if (!actionPath.includes('/') || actionPath.startsWith('./') || actionPath.startsWith('../')) { + this.log(`Skipping local/relative action: ${uses}`); + return null; + } + + return { + actionPath, + ref, + isPinned, + isGitHubAction: true + }; + } + + /** + * Fetch the latest commit SHA for a GitHub Action + */ + async fetchLatestCommitSHA(actionPath, ref) { + this.log(`Fetching latest commit SHA for ${actionPath}@${ref}`); + + const [owner, repo] = actionPath.split('/'); + + try { + // First, try to get the specific ref (tag/branch) + let response; + try { + response = await this.octokit.rest.repos.getBranch({ + owner, + repo, + branch: ref + }); + this.log(`Found branch ${ref} for ${actionPath}: ${response.data.commit.sha}`); + return response.data.commit.sha; + } catch (branchError) { + // If it's not a branch, try as a tag + try { + response = await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `tags/${ref}` + }); + this.log(`Found tag ${ref} for ${actionPath}: ${response.data.object.sha}`); + return response.data.object.sha; + } catch (tagError) { + throw new Error(`Could not find ref '${ref}' for ${actionPath}. It might not exist or be accessible.`); + } + } + } catch (error) { + throw new Error(`Failed to fetch commit SHA for ${actionPath}@${ref}: ${error.message}`); + } + } + + /** + * Update workflow file with pinned actions + */ + async updateWorkflowFile(filePath, content, actions, commitSHAs) { + this.log(`Updating workflow file: ${path.basename(filePath)}`); + + let updatedContent = content; + let totalUpdatesCount = 0; + + // Group actions by their original reference to avoid double replacements + const uniqueActions = new Map(); + for (const action of actions) { + const key = action.originalUses; + if (!uniqueActions.has(key)) { + uniqueActions.set(key, action); + } + } + + for (const action of uniqueActions.values()) { + const commitSHA = commitSHAs[`${action.actionPath}@${action.ref}`]; + if (commitSHA) { + const newUses = `${action.actionPath}@${commitSHA}`; + const oldPattern = new RegExp(`uses:\\s*${action.originalUses.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); + + // Count how many instances we're replacing + const matches = updatedContent.match(oldPattern); + const instanceCount = matches ? matches.length : 0; + + if (instanceCount > 0) { + updatedContent = updatedContent.replace(oldPattern, `uses: ${newUses}`); + totalUpdatesCount += instanceCount; + this.log(`Updated ${action.originalUses} โ†’ ${newUses} (${instanceCount} instances)`); + } + } + } + + if (totalUpdatesCount > 0) { + if (!this.dryRun) { + fs.writeFileSync(filePath, updatedContent, 'utf8'); + this.log(`Saved ${totalUpdatesCount} updates to ${path.basename(filePath)}`); + } else { + this.log(`[DRY RUN] Would save ${totalUpdatesCount} updates to ${path.basename(filePath)}`); + } + } + + return { updatedContent, updatesCount: totalUpdatesCount }; + } + + /** + * Find the line number of an action in the content + */ + findActionLineNumber(content, uses) { + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(uses)) { + return i; + } + } + return 0; + } + + /** + * Validate that the updated workflow file is still valid YAML + */ + validateWorkflowFile(filePath, content) { + try { + yaml.load(content); + this.log(`Validated workflow file: ${path.basename(filePath)}`); + return true; + } catch (error) { + console.error(`Invalid YAML in ${path.basename(filePath)}: ${error.message}`); + return false; + } + } + + /** + * Main method to pin all GitHub Actions + */ + async pinActions() { + try { + console.log('๐Ÿ” Scanning for GitHub Actions to pin...'); + + const workflowFiles = await this.findWorkflowFiles(); + if (workflowFiles.length === 0) { + console.log('โœ… No workflow files found.'); + return { success: true, updates: 0, files: 0 }; + } + + let totalUpdates = 0; + let filesUpdated = 0; + const commitSHAs = {}; + + // Parse all workflow files + const workflowData = []; + for (const filePath of workflowFiles) { + const data = await this.parseWorkflowFile(filePath); + workflowData.push({ filePath, ...data }); + } + + // Collect all unique actions that need pinning + const uniqueActions = new Set(); + for (const { actions } of workflowData) { + for (const action of actions) { + uniqueActions.add(`${action.actionPath}@${action.ref}`); + } + } + + if (uniqueActions.size === 0) { + console.log('โœ… All GitHub Actions are already pinned to commit SHAs.'); + return { success: true, updates: 0, files: 0 }; + } + + console.log(`๐Ÿ“ Found ${uniqueActions.size} unique actions to pin:`); + for (const actionRef of uniqueActions) { + console.log(` - ${actionRef}`); + } + + // Fetch commit SHAs for all actions + console.log('\n๐Ÿ”— Fetching latest commit SHAs...'); + for (const actionRef of uniqueActions) { + const [actionPath, ref] = actionRef.split('@'); + try { + const sha = await this.fetchLatestCommitSHA(actionPath, ref); + commitSHAs[actionRef] = sha; + console.log(`โœ“ ${actionRef} โ†’ ${sha}`); + } catch (error) { + console.error(`โœ— Failed to fetch SHA for ${actionRef}: ${error.message}`); + return { success: false, error: error.message }; + } + } + + // Update all workflow files + console.log('\n๐Ÿ“ Updating workflow files...'); + for (const { filePath, content, actions } of workflowData) { + if (actions.length > 0) { + const { updatedContent, updatesCount } = await this.updateWorkflowFile( + filePath, content, actions, commitSHAs + ); + + if (updatesCount > 0) { + if (this.validateWorkflowFile(filePath, updatedContent)) { + totalUpdates += updatesCount; + filesUpdated++; + console.log(`โœ“ Updated ${path.basename(filePath)} (${updatesCount} changes)`); + } else { + console.error(`โœ— Validation failed for ${path.basename(filePath)}`); + return { success: false, error: 'Workflow validation failed' }; + } + } + } + } + + if (this.dryRun) { + console.log(`\n๐ŸŽฏ [DRY RUN] Would update ${totalUpdates} actions in ${filesUpdated} files.`); + } else { + console.log(`\n๐ŸŽฏ Successfully pinned ${totalUpdates} actions in ${filesUpdated} files.`); + } + + return { success: true, updates: totalUpdates, files: filesUpdated }; + } catch (error) { + console.error(`โŒ Error: ${error.message}`); + return { success: false, error: error.message }; + } + } +} + +// CLI handling +async function main() { + const args = process.argv.slice(2); + const options = { + dryRun: args.includes('--dry-run'), + verbose: args.includes('--verbose') || args.includes('-v'), + token: process.env.GITHUB_TOKEN + }; + + if (args.includes('--help') || args.includes('-h')) { + console.log(` +GitHub Actions Pinner + +Usage: node pin-github-actions.js [options] + +Options: + --dry-run Show what would be changed without making actual changes + --verbose, -v Show detailed logging + --help, -h Show this help message + +Environment Variables: + GITHUB_TOKEN GitHub personal access token (optional, but recommended to avoid rate limits) + +Examples: + node pin-github-actions.js --dry-run + GITHUB_TOKEN=your_token node pin-github-actions.js --verbose +`); + return; + } + + const pinner = new GitHubActionsPinner(options); + const result = await pinner.pinActions(); + + if (!result.success) { + process.exit(1); + } +} + +// Export for testing +export { GitHubActionsPinner }; + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/test/pin-github-actions.test.js b/test/pin-github-actions.test.js new file mode 100644 index 0000000..c2fbea9 --- /dev/null +++ b/test/pin-github-actions.test.js @@ -0,0 +1,97 @@ +import { strict as assert } from 'assert'; +import { GitHubActionsPinner } from '../scripts/pin-github-actions.js'; + +// Simple test runner +async function runTests() { + console.log('๐Ÿงช Running tests for GitHub Actions Pinner...\n'); + + const tests = [ + { + name: 'parseActionReference - unpinned actions', + test: () => { + const pinner = new GitHubActionsPinner(); + const result = pinner.parseActionReference('actions/checkout@v3'); + + assert.equal(result.actionPath, 'actions/checkout'); + assert.equal(result.ref, 'v3'); + assert.equal(result.isPinned, false); + } + }, + { + name: 'parseActionReference - pinned actions', + test: () => { + const pinner = new GitHubActionsPinner(); + const sha = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; + const result = pinner.parseActionReference(`actions/checkout@${sha}`); + + assert.equal(result.isPinned, true); + } + }, + { + name: 'parseActionReference - local actions', + test: () => { + const pinner = new GitHubActionsPinner(); + const result = pinner.parseActionReference('./local-action'); + + assert.equal(result, null); + } + }, + { + name: 'parseActionReference - invalid actions', + test: () => { + const pinner = new GitHubActionsPinner(); + const result = pinner.parseActionReference('invalid-action'); + + assert.equal(result, null); + } + }, + { + name: 'validateWorkflowFile - valid YAML', + test: () => { + const pinner = new GitHubActionsPinner(); + const validYaml = 'name: Test\non: push'; + + assert.equal(pinner.validateWorkflowFile('test.yml', validYaml), true); + } + }, + { + name: 'validateWorkflowFile - invalid YAML', + test: () => { + const pinner = new GitHubActionsPinner(); + const invalidYaml = 'name: Test\non: push\ninvalid: - yaml: syntax'; + + assert.equal(pinner.validateWorkflowFile('test.yml', invalidYaml), false); + } + } + ]; + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + test.test(); + console.log(`โœ… ${test.name}`); + passed++; + } catch (error) { + console.log(`โŒ ${test.name}: ${error.message}`); + failed++; + } + } + + console.log(`\n๐Ÿ“Š Test Results: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + runTests().catch(error => { + console.error('Test runner error:', error); + process.exit(1); + }); +} + +export { runTests }; \ No newline at end of file