feat: 初始化文件哈希 GitHub Action 项目
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
63
.eslintignore
Normal 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
32
.eslintrc.js
Normal 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
46
.github/workflows/ci.yml
vendored
Normal 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
157
.github/workflows/examples.yml
vendored
Normal 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
148
.github/workflows/test.yml
vendored
Normal 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
147
.gitignore
vendored
Normal 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
|
||||
51
.prettierignore
Normal file
51
.prettierignore
Normal 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
15
.prettierrc
Normal 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
201
README.md
Normal 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
25
action.yml
Normal 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
11
examples/config.json
Normal 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
6
examples/sample1.txt
Normal 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
26
examples/sample2.js
Normal 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
689
index.js
Normal 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
49
package.json
Normal 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
2138
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1021
tests/index.test.js
Normal file
1021
tests/index.test.js
Normal file
File diff suppressed because it is too large
Load Diff
53
tests/utils.js
Normal file
53
tests/utils.js
Normal 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
57
vitest.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user