Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/glob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ for await (const file of globber.globGenerator()) {
}
```

## Hashing files (`hashFiles`)

`hashFiles` computes a hash of files matched by glob patterns.

By default, only files under the workspace (`GITHUB_WORKSPACE`) are eligible to be hashed.

To improve security, file eligibility is evaluated using each file's resolved (real) path to prevent symbolic link traversal outside the allowed root path(s).

### Options

- `roots?: string[]` — Allowlist of root paths. Only files that resolve under (or equal) one of these roots are hashed. Defaults to `[GITHUB_WORKSPACE]` (or `currentWorkspace` if provided).
- `allowFilesOutsideWorkspace?: boolean` — Explicit opt-in to include files outside the specified root path(s). Defaults to `false`.
- `exclude?: string[]` — Glob patterns to exclude from hashing. Defaults to `[]`.

If files match your patterns but are outside the allowed roots and `allowFilesOutsideWorkspace` is not enabled, those files are skipped and a warning is emitted. If no eligible files remain after filtering, `hashFiles` returns an empty string (`''`).

### Example

```js
const glob = require('@actions/glob')

const hash = await glob.hashFiles('**/*.json', process.env.GITHUB_WORKSPACE || '', {
roots: [process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH].filter(Boolean),
allowFilesOutsideWorkspace: true,
exclude: ['**/node_modules/**']
})

console.log(hash)
```

## Patterns

### Glob behavior
Expand Down
108 changes: 108 additions & 0 deletions packages/glob/__tests__/hash-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,114 @@ describe('globber', () => {
'4e911ea5824830b6a3ec096c7833d5af8381c189ffaa825c3503a5333a73eadc'
)
})

it('hashes files in allowed roots only', async () => {
const root = path.join(getTestTemp(), 'roots-hashfiles')
const dir1 = path.join(root, 'dir1')
const dir2 = path.join(root, 'dir2')
await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(dir2, {recursive: true})
await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(path.join(dir2, 'file2.txt'), 'test 2 file content')

const broadPattern = `${root}/**`

const hashDir1Only = await hashFiles(broadPattern, '', {roots: [dir1]})
expect(hashDir1Only).not.toEqual('')

const hashDir2Only = await hashFiles(broadPattern, '', {roots: [dir2]})
expect(hashDir2Only).not.toEqual('')

expect(hashDir1Only).not.toEqual(hashDir2Only)

const hashBoth = await hashFiles(broadPattern, '', {roots: [dir1, dir2]})
expect(hashBoth).not.toEqual(hashDir1Only)
expect(hashBoth).not.toEqual(hashDir2Only)

const hashDir1Again = await hashFiles(broadPattern, '', {roots: [dir1]})
expect(hashDir1Again).toEqual(hashDir1Only)
})

it('skips outside-root matches by default (hash unchanged)', async () => {
const root = path.join(getTestTemp(), 'default-skip-outside-roots')
const dir1 = path.join(root, 'dir1')
const outsideDir = path.join(root, 'outsideDir')

await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(outsideDir, {recursive: true})

await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(
path.join(outsideDir, 'fileOut.txt'),
'test outside file content'
)

const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]})
expect(insideOnly).not.toEqual('')

const patterns = `${dir1}/*\n${outsideDir}/*`
const defaultSkip = await hashFiles(patterns, '', {roots: [dir1]})

expect(defaultSkip).toEqual(insideOnly)
})

it('allows files outside roots if opted-in (hash changes)', async () => {
const root = path.join(getTestTemp(), 'allow-outside-roots')
const dir1 = path.join(root, 'dir1')
const outsideDir = path.join(root, 'outsideDir')
await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(outsideDir, {recursive: true})
await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(
path.join(outsideDir, 'fileOut.txt'),
'test outside file content'
)

const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]})
expect(insideOnly).not.toEqual('')

const patterns = `${dir1}/*\n${outsideDir}/*`
const withOptIn1 = await hashFiles(patterns, '', {
roots: [dir1],
allowFilesOutsideWorkspace: true
})
expect(withOptIn1).not.toEqual('')
expect(withOptIn1).not.toEqual(insideOnly)

const withOptIn2 = await hashFiles(patterns, '', {
roots: [dir1],
allowFilesOutsideWorkspace: true
})
expect(withOptIn2).toEqual(withOptIn1)
})

it('excludes files matching exclude patterns', async () => {
const root = path.join(getTestTemp(), 'exclude-hashfiles')
await fs.mkdir(root, {recursive: true})
await fs.writeFile(path.join(root, 'file1.txt'), 'test 1 file content')
await fs.writeFile(path.join(root, 'file2.log'), 'test 2 file content')

const all = await hashFiles(`${root}/*`, '', {roots: [root]})
expect(all).not.toEqual('')

// Exclude by exact filename and extension
const excluded = await hashFiles(`${root}/*`, '', {
roots: [root],
exclude: ['file2.log', '*.log']
})
expect(excluded).not.toEqual('')

const justIncluded = await hashFiles(
`${path.join(root, 'file1.txt')}`,
'',
{
roots: [root]
}
)

expect(excluded).toEqual(justIncluded)
expect(excluded).not.toEqual(all)
})
})

function getTestTemp(): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/glob/src/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ export async function hashFiles(
followSymbolicLinks = options.followSymbolicLinks
}
const globber = await create(patterns, {followSymbolicLinks})
return _hashFiles(globber, currentWorkspace, verbose)
return _hashFiles(globber, currentWorkspace, options, verbose)
}
23 changes: 23 additions & 0 deletions packages/glob/src/internal-hash-file-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,27 @@ export interface HashFileOptions {
* @default true
*/
followSymbolicLinks?: boolean

/**
* Array of allowed root directories. Only files that resolve under one of
* these roots will be included in the hash.
*
* @default [GITHUB_WORKSPACE]
*/
roots?: string[]

/**
* Indicates whether files outside the allowed roots should be included.
* If false, outside-root files are skipped with a warning.
*
* @default false
*/
allowFilesOutsideWorkspace?: boolean

/**
* Array of glob patterns for files to exclude from hashing.
*
* @default []
*/
exclude?: string[]
}
Loading
Loading