feat: 初始化文件哈希 GitHub Action 项目

This commit is contained in:
ren
2025-10-14 12:22:24 +08:00
commit 7f864dbb36
22 changed files with 4955 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.{ts,js}]
indent_size = 2
[*.{yaml,yml}]
indent_size = 2

63
.eslintignore Normal file
View File

@@ -0,0 +1,63 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
build/
coverage/
# Logs
*.log
logs/
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
.nyc_output/
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
.env.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
tmp/
temp/

32
.eslintrc.js Normal file
View File

@@ -0,0 +1,32 @@
module.exports = {
env: {
browser: false,
es2021: true,
node: true,
},
extends: ['eslint:recommended', 'prettier', 'plugin:jsdoc/recommended'],
plugins: ['prettier', 'jsdoc'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
},
rules: {
'prettier/prettier': 'error',
'no-console': 'off',
'no-unused-vars': 'error',
'no-undef': 'error',
'jsdoc/require-description': 'error',
'jsdoc/require-param-description': 'error',
'jsdoc/require-returns-description': 'error',
'jsdoc/require-description-complete-sentence': 'off',
'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }],
},
overrides: [
{
files: ['vitest.config.js', 'tests/**/*.js'],
parserOptions: {
sourceType: 'module',
},
},
],
};

46
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
name: Lint and Test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check JavaScript Syntax
run: |
echo "🔍 Checking JavaScript syntax..."
node -c index.js
echo "✅ All JavaScript files have valid syntax"
- name: Run Tests
run: |
echo "🧪 Running test suite..."
pnpm test
- name: Test Action Execution
uses: ./
with:
files: 'examples/*'
algorithm: 'sha256'

157
.github/workflows/examples.yml vendored Normal file
View File

@@ -0,0 +1,157 @@
name: Usage Examples
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
jobs:
example-basic:
runs-on: ubuntu-latest
name: Basic Usage Example
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Checkout
uses: actions/checkout@v4
- name: Calculate hash of a single file
id: single-file
uses: ./
with:
files: 'README.md'
algorithm: 'sha256'
- name: Display result
run: |
echo "📄 Single file hash:"
echo "Hash: ${{ steps.single-file.outputs.hash }}"
echo "Files processed: ${{ steps.single-file.outputs.file-count }}"
example-multiple:
runs-on: ubuntu-latest
name: Multiple Files Example
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Calculate hash of multiple files
id: multiple-files
uses: ./
with:
files: 'examples/*'
algorithm: 'md5'
- name: Display result
run: |
echo "📁 Multiple files hash:"
echo "Hash: ${{ steps.multiple-files.outputs.hash }}"
echo "Files processed: ${{ steps.multiple-files.outputs.file-count }}"
example-patterns:
runs-on: ubuntu-latest
name: Pattern Matching Example
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Calculate hash of JavaScript files
id: js-files
uses: ./
with:
files: '**/*.js'
algorithm: 'sha1'
- name: Display result
run: |
echo "🔧 JavaScript files hash:"
echo "Hash: ${{ steps.js-files.outputs.hash }}"
echo "Files processed: ${{ steps.js-files.outputs.file-count }}"
- name: Calculate hash of JSON files
id: json-files
uses: ./
with:
files: '**/*.json'
algorithm: 'sha256'
- name: Display result
run: |
echo "📋 JSON files hash:"
echo "Hash: ${{ steps.json-files.outputs.hash }}"
echo "Files processed: ${{ steps.json-files.outputs.file-count }}"
example-algorithms:
runs-on: ubuntu-latest
name: Different Algorithms Example
steps:
- name: Checkout
uses: actions/checkout@v4
- name: MD5 Hash
id: md5
uses: ./
with:
files: 'action.yml'
algorithm: 'md5'
- name: SHA1 Hash
id: sha1
uses: ./
with:
files: 'action.yml'
algorithm: 'sha1'
- name: SHA256 Hash
id: sha256
uses: ./
with:
files: 'action.yml'
algorithm: 'sha256'
- name: SHA512 Hash
id: sha512
uses: ./
with:
files: 'action.yml'
algorithm: 'sha512'
- name: Display all results
run: |
echo "🔐 Different algorithm results for action.yml:"
echo "MD5: ${{ steps.md5.outputs.hash }}"
echo "SHA1: ${{ steps.sha1.outputs.hash }}"
echo "SHA256: ${{ steps.sha256.outputs.hash }}"
echo "SHA512: ${{ steps.sha512.outputs.hash }}"
example-error-handling:
runs-on: ubuntu-latest
name: Error Handling Example
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Handle missing files (expected to fail)
id: graceful
continue-on-error: true
uses: ./
with:
files: 'missing-*.txt'
algorithm: 'sha256'
- name: Check graceful result
run: |
if [ "${{ steps.graceful.outcome }}" == "failure" ]; then
echo "✅ Correctly failed when files are missing"
else
echo "❌ Should have failed but didn't"
exit 1
fi

148
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: Test Files Hash Action
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test Action
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Unit Tests
run: node run-tests.js
- name: Test Action - Single File
id: test-single
uses: ./
with:
files: 'examples/sample1.txt'
algorithm: 'sha256'
- name: Verify Single File Output
run: |
echo "Hash: ${{ steps.test-single.outputs.hash }}"
echo "File Count: ${{ steps.test-single.outputs.file-count }}"
if [ "${{ steps.test-single.outputs.file-count }}" != "1" ]; then
echo "❌ Expected file count 1, got ${{ steps.test-single.outputs.file-count }}"
exit 1
fi
if [ -z "${{ steps.test-single.outputs.hash }}" ]; then
echo "❌ Hash output is empty"
exit 1
fi
echo "✅ Single file test passed"
- name: Test Action - Multiple Files
id: test-multiple
uses: ./
with:
files: 'examples/*.txt'
algorithm: 'md5'
- name: Verify Multiple Files Output
run: |
echo "Hash: ${{ steps.test-multiple.outputs.hash }}"
echo "File Count: ${{ steps.test-multiple.outputs.file-count }}"
if [ "${{ steps.test-multiple.outputs.file-count }}" != "1" ]; then
echo "❌ Expected file count 1, got ${{ steps.test-multiple.outputs.file-count }}"
exit 1
fi
echo "✅ Multiple files test passed"
- name: Test Action - All Examples
id: test-all
uses: ./
with:
files: 'examples/*'
algorithm: 'sha1'
- name: Verify All Files Output
run: |
echo "Hash: ${{ steps.test-all.outputs.hash }}"
echo "File Count: ${{ steps.test-all.outputs.file-count }}"
if [ "${{ steps.test-all.outputs.file-count }}" != "3" ]; then
echo "❌ Expected file count 3, got ${{ steps.test-all.outputs.file-count }}"
exit 1
fi
echo "✅ All files test passed"
- name: Test Action - Different Algorithm
id: test-sha512
uses: ./
with:
files: 'README.md'
algorithm: 'sha512'
- name: Verify SHA512 Output
run: |
echo "Hash: ${{ steps.test-sha512.outputs.hash }}"
echo "File Count: ${{ steps.test-sha512.outputs.file-count }}"
if [ "${{ steps.test-sha512.outputs.file-count }}" != "1" ]; then
echo "❌ Expected file count 1, got ${{ steps.test-sha512.outputs.file-count }}"
exit 1
fi
# SHA512 hash should be 128 characters long
hash_length=$(echo "${{ steps.test-sha512.outputs.hash }}" | wc -c)
if [ $hash_length -ne 129 ]; then # 128 + 1 for newline
echo "❌ SHA512 hash length should be 128, got $((hash_length-1))"
exit 1
fi
echo "✅ SHA512 test passed"
- name: Test Action - Missing Files (should fail)
id: test-missing
continue-on-error: true
uses: ./
with:
files: 'non-existent-file.txt'
algorithm: 'sha256'
- name: Verify Missing Files Handling
run: |
if [ "${{ steps.test-missing.outcome }}" == "success" ]; then
echo "❌ Expected action to fail on missing file"
exit 1
fi
echo "✅ Missing files test passed"
test-matrix:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: ['18', '20']
name: Test on ${{ matrix.os }} with Node ${{ matrix.node-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Test Action
uses: ./
with:
files: 'examples/sample1.txt'
algorithm: 'sha256'

147
.gitignore vendored Normal file
View File

@@ -0,0 +1,147 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
# Editors
.idea
.vscode
.cursor
.trae

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
package-manager=pnpm
engine-strict=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19

51
.prettierignore Normal file
View File

@@ -0,0 +1,51 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
build/
coverage/
# Logs
*.log
logs/
# Generated files
*.min.js
*.min.css
# Binary files
*.jpg
*.jpeg
*.png
*.gif
*.ico
*.svg
*.woff
*.woff2
*.ttf
*.eot
# Documentation that should not be formatted
CHANGELOG.md
LICENSE
# IDE files
.vscode/
.idea/
# OS generated files
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
# Environment files
.env*
# Lock files
yarn.lock
pnpm-lock.yaml

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": true,
"embeddedLanguageFormatting": "auto"
}

201
README.md Normal file
View File

@@ -0,0 +1,201 @@
# 文件哈希 GitHub Action
一个轻量级的 GitHub Action用于计算指定文件或文件组的哈希值。非常适合需要文件完整性验证、变更检测或文件指纹识别的 CI/CD 工作流。
## 使用方法
### 基本示例
```yaml
name: Calculate File Hashes
on: [push]
jobs:
hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Calculate hash for source files
uses: actions/files-hash@v0.1
id: hash-step
with:
files: |
src/**/*.js
package.json
algorithm: sha256
- name: Use the hash
run: |
echo "Hash: ${{ steps.hash-step.outputs.hash }}"
echo "Files processed: ${{ steps.hash-step.outputs.file-count }}"
```
### 高级示例
#### 多种文件模式
```yaml
- name: Hash multiple file types
uses: actions/files-hash@v0.1
with:
files: |
src/**/*.js
src/**/*.ts
*.json
!node_modules/**
algorithm: sha512
```
#### 不同的哈希算法
```yaml
- name: MD5 hash for quick comparison
uses: actions/files-hash@v0.1
with:
files: dist/*
algorithm: md5
```
#### 条件处理
```yaml
- name: Hash only if files exist
uses: actions/files-hash@v0.1
with:
files: |
build/**/*
dist/**/*
algorithm: sha256
continue-on-error: true
```
## 输入参数
| 输入参数 | 描述 | 必需 | 默认值 |
| ----------------- | ------------------------------------------- | ----- | -------- |
| `files` | 文件路径或 glob 模式(每行一个) | ✅ 是 | - |
| `algorithm` | 哈希算法:`md5``sha1``sha256``sha512` | ❌ 否 | `sha256` |
### 文件模式
`files` 输入支持多种模式:
- **单个文件**: `package.json`
- **多个文件**:
```yaml
files: |
file1.txt
file2.txt
```
- **Glob 模式**: `src/**/*.js`src 目录及子目录中的所有 JS 文件)
- **通配符**: `*.json`(当前目录中的所有 JSON 文件)
- **目录**: `src/`(递归处理 src 目录中的所有文件)
## 输出结果
| 输出 | 描述 | 类型 |
| ------------ | ------------------------ | ------ |
| `hash` | 所有处理文件的组合哈希值 | string |
| `file-count` | 成功处理的文件数量 | number |
## 工作原理
1. **文件发现**: 解析输入模式并查找匹配的文件
2. **验证**: 检查文件存在性和可读性
3. **哈希计算**: 使用指定算法计算各个文件的哈希值
4. **组合**: 从所有单个文件哈希值创建组合哈希值
5. **输出**: 设置 GitHub Actions 输出供后续步骤使用
组合哈希值的计算方式:
1. 按字母顺序对所有文件路径进行排序(确保一致性)
2. 计算每个文件的哈希值
3. 从所有单个哈希值的连接创建新的哈希值
这确保了相同的文件集始终产生相同的组合哈希值,无论它们的处理顺序如何。
## 支持的算法
| 算法 | 输出长度 | 使用场景 |
| -------- | -------- | --------------------------------- |
| `md5` | 32 字符 | 快速比较、传统系统 |
| `sha1` | 40 字符 | Git 兼容性、通用用途 |
| `sha256` | 64 字符 | **推荐** - 安全性和性能的良好平衡 |
| `sha512` | 128 字符 | 最高安全性、加密应用 |
## 错误处理
该 Action 优雅地处理各种错误场景:
- **文件缺失**: 直接抛出错误
- **权限错误**: 提供包含文件路径的清晰错误信息
- **无效算法**: 列出支持的算法
- **空文件集**: 失败并提供描述性消息
## 性能
- **并发处理**: 同时处理多个文件
- **流式处理**: 高效处理大文件,无需加载到内存中
- **内存管理**: 针对大文件集和大文件进行优化
- **进度日志**: 为长时间运行的操作提供清晰的进度指示器
## 实际项目示例
### 缓存键生成
```yaml
- name: Generate cache key
uses: actions/files-hash@v0.1
id: cache-key
with:
files: |
package-lock.json
yarn.lock
pnpm-lock.yaml
algorithm: sha256
- name: Cache dependencies
uses: actions/cache@v3
with:
path: node_modules
key: deps-${{ steps.cache-key.outputs.hash }}
```
### 构建产物验证
```yaml
- name: Build application
run: npm run build
- name: Calculate build hash
uses: actions/files-hash@v0.1
id: build-hash
with:
files: dist/**/*
algorithm: sha256
- name: Upload artifacts with hash
uses: actions/upload-artifact@v3
with:
name: build-${{ steps.build-hash.outputs.hash }}
path: dist/
```
### 配置变更检测
```yaml
- name: Check config changes
uses: actions/files-hash@v0.1
id: config-hash
with:
algorithm: sha1
files: |
.github/workflows/**
config/**
*.config.js
- name: Notify on config changes
if: steps.config-hash.outputs.hash != env.LAST_CONFIG_HASH
run: echo "Configuration files have changed!"
```

25
action.yml Normal file
View File

@@ -0,0 +1,25 @@
name: 'Files Hash'
description: 'Calculate hash values for specified files or file groups'
author: 'files-hash'
branding:
icon: 'hash'
color: 'blue'
inputs:
files:
description: 'File paths or glob patterns (one per line)'
required: true
algorithm:
description: 'Hash algorithm to use (md5, sha1, sha256, sha512)'
required: false
default: 'sha256'
outputs:
hash:
description: 'Combined hash of all files'
file-count:
description: 'Number of files processed'
runs:
using: 'node20'
main: 'index.js'

11
examples/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "files-hash-example",
"version": "1.0.0",
"description": "Example configuration file for testing",
"settings": {
"debug": false,
"timeout": 5000,
"retries": 3
},
"features": ["hash-calculation", "glob-matching", "error-handling"]
}

6
examples/sample1.txt Normal file
View File

@@ -0,0 +1,6 @@
This is a sample text file for testing the Files Hash Action.
It contains multiple lines of text to ensure proper hash calculation.
Line 3
Line 4
Final line.

26
examples/sample2.js Normal file
View File

@@ -0,0 +1,26 @@
// Sample JavaScript file for testing
/**
* 测试函数
*
* @param {string} name 姓名
* @returns {string} 问候语
*/
function greet(name) {
return `Hello, ${name}!`;
}
/**
* 计算两个数的和.
*
* @param {number} a 第一个数
* @param {number} b 第二个数
* @returns {number} 两个数的和
*/
function calculateSum(a, b) {
return a + b;
}
module.exports = {
greet,
calculateSum,
};

689
index.js Normal file
View File

@@ -0,0 +1,689 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 错误类型常量.
*
* @readonly
* @enum {string}
*/
const ErrorType = {
/**
* 文件未找到错误.
*/
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
/**
* 权限被拒绝错误.
*/
PERMISSION_DENIED: 'PERMISSION_DENIED',
/**
* 无效的哈希算法错误.
*/
INVALID_ALGORITHM: 'INVALID_ALGORITHM',
/**
* 哈希计算失败错误.
*/
HASH_CALCULATION_FAILED: 'HASH_CALCULATION_FAILED',
};
/**
* 自定义错误类,用于处理 GitHub Action 执行过程中的各种错误.
*
* @augments Error
*/
class ActionError extends Error {
/**
* 创建一个新的 ActionError 实例
*
* @param {string} type - 错误类型,应该是 ErrorType 中的一个值
* @param {string} message - 错误消息
* @param {string|null} [file] - 相关的文件路径(可选)
* @param {string|null} [details] - 额外的错误详情(可选)
*/
constructor(type, message, file = null, details = null) {
super(message);
this.type = type;
this.file = file;
this.details = details;
this.name = 'ActionError';
}
}
/**
* GitHub Actions 输入处理类
* 提供从环境变量中读取 GitHub Actions 输入参数的静态方法.
*/
class ActionInputs {
/**
* 获取字符串类型的输入参数
*
* @param {string} name - 输入参数名称
* @param {boolean} [required] - 是否为必需参数
* @returns {string} 输入参数的值
* @throws {ActionError} 当必需参数未提供时抛出错误
*/
static getInput(name, required = false) {
const envName = `INPUT_${name.toUpperCase().replace(/-/g, '_')}`;
const value = process.env[envName] || '';
if (required && !value) {
throw new ActionError(ErrorType.INVALID_INPUT, `Input required and not supplied: ${name}`);
}
return value;
}
/**
* 获取布尔类型的输入参数
*
* @param {string} name - 输入参数名称
* @param {boolean} [required] - 是否为必需参数
* @returns {boolean} 转换后的布尔值
* @throws {ActionError} 当必需参数未提供时抛出错误
*/
static getBooleanInput(name, required = false) {
const value = this.getInput(name, required).toLowerCase();
return value === 'true' || value === '1';
}
/**
* 获取多行字符串类型的输入参数
*
* @param {string} name - 输入参数名称
* @param {boolean} [required] - 是否为必需参数
* @returns {string[]} 按行分割并过滤空行后的字符串数组
* @throws {ActionError} 当必需参数未提供时抛出错误
*/
static getMultilineInput(name, required = false) {
const value = this.getInput(name, required);
return value.split('\n').filter(line => line.trim());
}
}
/**
* GitHub Actions 输出处理类
* 提供设置 GitHub Actions 输出和日志记录的静态方法.
*/
class ActionOutputs {
/**
* 设置 GitHub Actions 输出变量
*
* @param {string} name - 输出变量名称
* @param {string} value - 输出变量值
*/
static setOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const fs = require('fs');
fs.appendFileSync(outputFile, `${name}=${value}\n`);
} else {
console.log(`::set-output name=${name}::${value}`);
}
}
/**
* 设置 Action 失败状态并退出
*
* @param {string} message - 失败消息
*/
static setFailed(message) {
console.log(`::error::${message}`);
process.exit(1);
}
/**
* 输出信息日志
*
* @param {string} message - 信息内容
*/
static info(message) {
console.log(message);
}
/**
* 输出警告日志
*
* @param {string} message - 警告内容
*/
static warning(message) {
console.log(`::warning::${message}`);
}
/**
* 输出错误日志
*
* @param {string} message - 错误内容
*/
static error(message) {
console.log(`::error::${message}`);
}
}
/**
* 文件发现引擎:提供文件模式解析、文件查找和验证功能,支持 glob 模式匹配
*/
class FileDiscovery {
/**
* 解析输入的文件路径模式
*
* @param {string|string[]} input - 文件路径模式,可以是字符串或字符串数组
* @returns {string[]} 解析后的文件路径模式数组
*/
parseFilePatterns(input) {
if (Array.isArray(input)) {
return input.map(line => line.trim()).filter(line => line);
}
return input
.split('\n')
.map(line => line.trim())
.filter(line => line);
}
/**
* 查找匹配的文件
*
* @param {string[]} patterns - 文件路径模式数组
* @returns {Promise<string[]>} 匹配的文件路径数组(已排序且去重)
*/
async findFiles(patterns) {
const files = new Set();
for (const pattern of patterns) {
const matchedFiles = await this.expandGlob(pattern.trim());
matchedFiles.forEach(file => files.add(file));
}
return Array.from(files).sort();
}
/**
* 验证文件存在性和可读性
*
* @param {string[]} files - 文件路径数组
* @returns {Promise<object[]>} 验证结果数组,包含文件状态信息
*/
async validateFiles(files) {
const results = [];
for (const file of files) {
try {
const stats = await fs.stat(file);
if (stats.isFile()) {
results.push({
file,
exists: true,
readable: true,
size: stats.size,
});
}
} catch (error) {
results.push({
file,
exists: false,
readable: false,
error: error.message,
});
}
}
return results;
}
/**
* 自实现的 glob 模式匹配
*
* @param {string} pattern - Glob 模式字符串
* @returns {Promise<string[]>} 匹配的文件路径数组
*/
async expandGlob(pattern) {
// 如果是普通文件路径,直接返回
if (!pattern.includes('*') && !pattern.includes('?')) {
try {
const stats = await fs.stat(pattern);
if (stats.isFile()) {
return [pattern];
} else if (stats.isDirectory()) {
// 如果是目录,返回目录下所有文件
return await this.getAllFilesInDirectory(pattern);
}
} catch (error) {
return [];
}
}
// 处理 glob 模式
return await this.matchGlobPattern(pattern);
}
/**
* 获取目录下所有文件(递归)
*
* @param {string} dirPath - 目录路径
* @param {boolean} [includeHidden] - 是否包含隐藏文件
* @returns {Promise<string[]>} 目录下所有文件的路径数组
*/
async getAllFilesInDirectory(dirPath, includeHidden = false) {
const files = [];
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
// 默认忽略隐藏文件(以.开头的文件和目录)
if (!includeHidden && entry.name.startsWith('.')) {
continue;
}
if (entry.isFile()) {
files.push(fullPath);
} else if (entry.isDirectory()) {
const subFiles = await this.getAllFilesInDirectory(fullPath, includeHidden);
files.push(...subFiles);
}
}
} catch (error) {
// 忽略无法访问的目录
}
return files;
}
/**
* 匹配 glob 模式
*
* @param {string} pattern - Glob 模式字符串
* @returns {Promise<string[]>} 匹配的文件路径数组
* @private
*/
async matchGlobPattern(pattern) {
const files = [];
// 分离目录部分和文件名部分
const dir = path.dirname(pattern);
const filename = path.basename(pattern);
// 如果目录部分包含通配符,需要递归处理
if (dir.includes('*')) {
return await this.matchGlobPatternRecursive(pattern);
}
// 处理简单的文件名通配符
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
// 默认忽略隐藏文件(以.开头的文件)
if (entry.name.startsWith('.')) {
continue;
}
if (entry.isFile() && this.matchPattern(entry.name, filename)) {
files.push(path.join(dir, entry.name));
}
}
} catch (error) {
// 目录不存在或无法访问
}
return files;
}
/**
* 递归匹配 glob 模式
*
* @param {string} pattern - Glob 模式字符串
* @returns {Promise<string[]>} 匹配的文件路径数组
* @private
*/
async matchGlobPatternRecursive(pattern) {
const files = [];
// 处理 ** 通配符
if (pattern.includes('**')) {
const parts = pattern.split('**');
const basePath = parts[0].replace(/\*+$/, '').replace(/\/$/, '') || '.';
const remainingPattern = parts[1].replace(/^\//, '');
try {
const allFiles = await this.getAllFilesInDirectory(basePath);
for (const file of allFiles) {
const relativePath = path.relative(basePath, file);
if (this.matchPattern(relativePath, remainingPattern)) {
files.push(file);
}
}
} catch (error) {
// 忽略错误
}
} else {
// 处理单层通配符
const parts = pattern.split('/');
files.push(...(await this.matchPatternParts(parts, '.')));
}
return files;
}
/**
* 匹配模式部分
*
* @param {string[]} parts - 模式部分数组
* @param {string} currentPath - 当前路径
* @returns {Promise<string[]>} 匹配的文件路径数组
* @private
*/
async matchPatternParts(parts, currentPath) {
if (parts.length === 0) {
return [];
}
const [currentPart, ...remainingParts] = parts;
const files = [];
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
// 默认忽略隐藏文件和目录(以.开头的)
if (entry.name.startsWith('.')) {
continue;
}
const fullPath = path.join(currentPath, entry.name);
if (this.matchPattern(entry.name, currentPart)) {
if (remainingParts.length === 0) {
// 最后一部分,检查是否为文件
if (entry.isFile()) {
files.push(fullPath);
}
} else {
// 还有更多部分,递归处理
if (entry.isDirectory()) {
const subFiles = await this.matchPatternParts(remainingParts, fullPath);
files.push(...subFiles);
}
}
}
}
} catch (error) {
// 忽略错误
}
return files;
}
/**
* 简单的模式匹配函数
*
* @param {string} text - 要匹配的文本
* @param {string} pattern - Glob 模式
* @returns {boolean} 是否匹配
* @private
*/
matchPattern(text, pattern) {
// 将 glob 模式转换为正则表达式
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(text);
}
}
/**
* 哈希计算器
* 提供文件哈希计算功能,支持多种哈希算法
*/
class HashCalculator {
/**
* 构造函数
* 初始化支持的哈希算法列表
*/
constructor() {
this.supportedAlgorithms = ['md5', 'sha1', 'sha256', 'sha512'];
}
/**
* 验证算法是否支持
*
* @param {string} algorithm - 哈希算法名称
* @throws {ActionError} 当算法不支持时抛出错误
*/
validateAlgorithm(algorithm) {
if (!this.supportedAlgorithms.includes(algorithm.toLowerCase())) {
throw new ActionError(
ErrorType.INVALID_ALGORITHM,
`Unsupported algorithm: ${algorithm}. Supported algorithms: ${this.supportedAlgorithms.join(', ')}`
);
}
}
/**
* 计算单个文件的哈希
*
* @param {string} filePath - 文件路径
* @param {string} algorithm - 哈希算法
* @returns {Promise<string>} 文件的哈希值(十六进制字符串)
* @throws {ActionError} 当文件读取或哈希计算失败时抛出错误
*/
async calculateFileHash(filePath, algorithm) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(algorithm);
const stream = fsSync.createReadStream(filePath);
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', error =>
reject(
new ActionError(
ErrorType.HASH_CALCULATION_FAILED,
`Failed to calculate hash for ${filePath}: ${error.message}`,
filePath,
error
)
)
);
});
}
/**
* 计算多个文件的组合哈希
*
* @param {string[]} files - 文件路径数组
* @param {string} algorithm - 哈希算法
* @returns {Promise<string>} 组合哈希值(十六进制字符串)
* @throws {ActionError} 当算法不支持或文件哈希计算失败时抛出错误
*/
async calculateCombinedHash(files, algorithm) {
this.validateAlgorithm(algorithm);
const hash = crypto.createHash(algorithm);
// 按文件路径排序确保一致性
const sortedFiles = [...files].sort();
ActionOutputs.info(`Processing ${sortedFiles.length} files with ${algorithm.toUpperCase()} algorithm...`);
for (const file of sortedFiles) {
try {
const fileHash = await this.calculateFileHash(file, algorithm);
hash.update(fileHash);
ActionOutputs.info(`${file}`);
} catch (error) {
ActionOutputs.warning(`${file}: ${error.message}`);
throw error;
}
}
const combinedHash = hash.digest('hex');
ActionOutputs.info(`Combined hash: ${combinedHash}`);
return combinedHash;
}
/**
* 获取支持的算法列表
*
* @returns {string[]} 支持的哈希算法数组的副本
*/
getSupportedAlgorithms() {
return [...this.supportedAlgorithms];
}
}
/**
* 输出格式化器
* 提供结果格式化和 GitHub Actions 输出设置功能
*/
class OutputFormatter {
/**
* 格式化输出
*
* @param {string|string[]} files - 文件列表或组合哈希值
* @param {string} algorithm - 哈希算法
* @param {string} combinedHash - 组合哈希值
* @returns {string|object} 格式化后的输出字符串或对象
*/
formatOutput(files, algorithm, combinedHash) {
// 如果第一个参数是数组,生成格式化的字符串输出
if (Array.isArray(files)) {
const fileCount = files.length;
let output = '';
if (fileCount === 0) {
output += 'No files found\n';
} else if (fileCount === 1) {
output += `Processing 1 file\n`;
output += `File: ${files[0]}\n`;
} else {
output += `Total files: ${fileCount}\n`;
files.forEach(file => {
output += ` ${file}\n`;
});
}
output += `Algorithm: ${algorithm.toLowerCase()}\n`;
output += `Combined Hash: ${combinedHash}`;
return output;
}
// 否则使用原来的对象格式(兼容模式)
return {
hash: files,
fileCount: algorithm,
algorithm: combinedHash.toUpperCase(),
};
}
/**
* 设置 GitHub Actions 输出
*
* @param {string} hash - 哈希值
* @param {number} fileCount - 文件数量
*/
setGitHubOutput(hash, fileCount) {
ActionOutputs.setOutput('hash', hash);
ActionOutputs.setOutput('file-count', fileCount);
}
}
/**
* 主要的 Action 类;协调文件发现、哈希计算和输出格式化的完整工作流程
*/
class FilesHashAction {
/**
* 构造函数。初始化所需的组件实例
*/
constructor() {
this.fileDiscovery = new FileDiscovery();
this.hashCalculator = new HashCalculator();
this.outputFormatter = new OutputFormatter();
}
/**
* 运行 Action 的主要方法。执行完整的文件哈希计算工作流程
*
* @throws {ActionError} 当输入无效、文件不存在或哈希计算失败时抛出错误
*/
async run() {
try {
// 读取输入参数
const filesInput = ActionInputs.getMultilineInput('files', true);
const algorithm = ActionInputs.getInput('algorithm') || 'sha256';
ActionOutputs.info(`Files Hash Action started`);
ActionOutputs.info(`Algorithm: ${algorithm.toUpperCase()}`);
// 解析文件模式
const patterns = this.fileDiscovery.parseFilePatterns(filesInput);
ActionOutputs.info(`File patterns: ${patterns.join(', ')}`);
// 查找文件
const foundFiles = await this.fileDiscovery.findFiles(patterns);
ActionOutputs.info(`Found ${foundFiles.length} potential files`);
// 验证文件
const validationResults = await this.fileDiscovery.validateFiles(foundFiles);
const validFiles = validationResults
.filter(result => result.exists && result.readable)
.map(result => result.file);
const missingFiles = validationResults.filter(result => !result.exists).map(result => result.file);
// 处理缺失文件 - 直接抛出错误
if (missingFiles.length > 0) {
const message = `Missing files: ${missingFiles.join(', ')}`;
throw new ActionError(ErrorType.FILE_NOT_FOUND, message);
}
// 检查是否有有效文件 - 直接抛出错误
if (validFiles.length === 0) {
throw new ActionError(ErrorType.FILE_NOT_FOUND, 'No valid files found to process');
}
ActionOutputs.info(`Processing ${validFiles.length} valid files`);
// 计算组合哈希
const combinedHash = await this.hashCalculator.calculateCombinedHash(validFiles, algorithm);
// 格式化输出
const result = this.outputFormatter.formatOutput(combinedHash, validFiles.length, algorithm);
// 设置 GitHub Actions 输出
this.outputFormatter.setGitHubOutput(result.hash, result.fileCount);
ActionOutputs.info(`Action completed successfully`);
ActionOutputs.info(`Hash: ${result.hash}`);
ActionOutputs.info(`File count: ${result.fileCount}`);
} catch (error) {
if (error instanceof ActionError) {
ActionOutputs.setFailed(`${error.type}: ${error.message}`);
} else {
ActionOutputs.setFailed(`Unexpected error: ${error.message}`);
}
}
}
}
// 运行 Action
if (require.main === module) {
const action = new FilesHashAction();
action.run();
}
module.exports = {
FilesHashAction,
FileDiscovery,
HashCalculator,
OutputFormatter,
ActionInputs,
ActionOutputs,
ActionError,
ErrorType,
};

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "files-hash-action",
"version": "0.1.0",
"description": "A lightweight GitHub Action to calculate hash of multiple files",
"main": "index.js",
"scripts": {
"test": "vitest run",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "pnpm run lint && pnpm run format:check",
"preinstall": "npx only-allow pnpm"
},
"devDependencies": {
"@vitest/coverage-v8": "^1.6.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsdoc": "^61.1.1",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"vitest": "^1.0.0"
},
"keywords": [
"github-action",
"hash",
"files",
"checksum",
"md5",
"sha1",
"sha256",
"sha512"
],
"author": "Files Hash Action",
"license": "MIT",
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "https://github.com/your-username/files-hash-action.git"
},
"bugs": {
"url": "https://github.com/your-username/files-hash-action/issues"
},
"homepage": "https://github.com/your-username/files-hash-action#readme"
}

2138
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1021
tests/index.test.js Normal file

File diff suppressed because it is too large Load Diff

53
tests/utils.js Normal file
View File

@@ -0,0 +1,53 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
/**
* 创建一个临时测试目录
*
* @returns {Promise<string>} 临时目录路径
*/
export async function createTestDir() {
return await fs.mkdtemp(path.join(os.tmpdir(), 'files-hash-test-'));
}
/**
* 创建一个测试文件
*
* @param {string} filePath - 要创建的文件路径
* @param {string} content - 文件内容
*/
export async function createTestFile(filePath, content) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
/**
* 清理测试文件
*
* @param {string[]} files - 要清理的文件路径数组
*/
export async function cleanupTestFiles(files) {
for (const file of files) {
try {
await fs.unlink(file);
} catch (error) {
// 忽略文件不存在的错误
}
}
}
/**
* 清理测试目录
*
* @param {string[]} dirs - 要清理的目录路径数组
*/
export async function cleanupTestDirs(dirs) {
for (const dir of dirs) {
try {
await fs.rmdir(dir, { recursive: true });
} catch (error) {
// 忽略目录不存在的错误
}
}
}

57
vitest.config.js Normal file
View File

@@ -0,0 +1,57 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 测试环境
environment: 'node',
// 测试文件匹配模式
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// 排除的文件
exclude: ['node_modules', 'dist', '.git', '.github'],
// 全局设置
globals: false,
// 测试超时时间(毫秒)
testTimeout: 10000,
// 钩子超时时间(毫秒)
hookTimeout: 10000,
// 并发运行测试
pool: 'threads',
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'coverage/**',
'dist/**',
'packages/*/test{,s}/**',
'**/*.d.ts',
'cypress/**',
'test{,s}/**',
'test{,-*}.{js,cjs,mjs,ts,tsx,jsx}',
'**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx}',
'**/*{.,-}spec.{js,cjs,mjs,ts,tsx,jsx}',
'**/__tests__/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
'**/.{eslint,mocha,prettier}rc.{js,cjs,yml}',
'run-tests.js',
'examples/**',
],
},
// 报告器
reporter: ['verbose'],
// 在测试失败时停止
bail: 0,
// 重试次数
retry: 0,
},
});