Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix regression, auto readlink on symlinks again #1830

Merged
merged 5 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions packages/artifact/RELEASES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# @actions/artifact Releases

### 2.1.10

- Fixed a regression with symlinks not being automatically resolved [#1830](https://github.com/actions/toolkit/pull/1830)
- Fixed a regression with chunk timeout [#1786](https://github.com/actions/toolkit/pull/1786)

### 2.1.9

- Fixed artifact upload chunk timeout logic [#1774](https://github.com/actions/toolkit/pull/1774)
Expand Down
45 changes: 36 additions & 9 deletions packages/artifact/__tests__/upload-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ jest.mock('@azure/storage-blob', () => ({
const fixtures = {
uploadDirectory: path.join(__dirname, '_temp', 'plz-upload'),
files: [
['file1.txt', 'test 1 file content'],
['file2.txt', 'test 2 file content'],
['file3.txt', 'test 3 file content']
{name: 'file1.txt', content: 'test 1 file content'},
{name: 'file2.txt', content: 'test 2 file content'},
{name: 'file3.txt', content: 'test 3 file content'},
{
name: 'from_symlink.txt',
content: 'from a symlink',
symlink: '../symlinked.txt'
}
],
backendIDs: {
workflowRunBackendId: '67dbcc20-e851-4452-a7c3-2cc0d2e0ec67',
Expand All @@ -54,8 +59,23 @@ describe('upload-artifact', () => {
fs.mkdirSync(fixtures.uploadDirectory, {recursive: true})
}

for (const [file, content] of fixtures.files) {
fs.writeFileSync(path.join(fixtures.uploadDirectory, file), content)
for (const file of fixtures.files) {
if (file.symlink) {
const symlinkPath = path.join(fixtures.uploadDirectory, file.symlink)
fs.writeFileSync(symlinkPath, file.content)
if (!fs.existsSync(path.join(fixtures.uploadDirectory, file.name))) {
fs.symlinkSync(
symlinkPath,
path.join(fixtures.uploadDirectory, file.name),
'file'
)
}
} else {
fs.writeFileSync(
path.join(fixtures.uploadDirectory, file.name),
file.content
)
}
}
})

Expand All @@ -71,8 +91,9 @@ describe('upload-artifact', () => {
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockReturnValue(
fixtures.files.map(file => ({
sourcePath: path.join(fixtures.uploadDirectory, file[0]),
destinationPath: file[0]
sourcePath: path.join(fixtures.uploadDirectory, file.name),
destinationPath: file.name,
stats: new fs.Stats()
}))
)
jest.spyOn(config, 'getRuntimeToken').mockReturnValue(fixtures.runtimeToken)
Expand Down Expand Up @@ -185,6 +206,10 @@ describe('upload-artifact', () => {
})

it('should successfully upload an artifact', async () => {
jest
.spyOn(uploadZipSpecification, 'getUploadZipSpecification')
.mockRestore()

jest
.spyOn(ArtifactServiceClientJSON.prototype, 'CreateArtifact')
.mockReturnValue(
Expand Down Expand Up @@ -228,8 +253,10 @@ describe('upload-artifact', () => {

const {id, size} = await uploadArtifact(
fixtures.inputs.artifactName,
fixtures.inputs.files,
fixtures.inputs.rootDirectory
fixtures.files.map(file =>
path.join(fixtures.uploadDirectory, file.name)
),
fixtures.uploadDirectory
)

expect(id).toBe(1)
Expand Down
18 changes: 18 additions & 0 deletions packages/artifact/__tests__/upload-zip-specification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,22 @@ describe('Search', () => {
}
}
})

it('Upload Specification - Includes symlinks', async () => {
const targetPath = path.join(root, 'link-dir', 'symlink-me.txt')
await fs.mkdir(path.dirname(targetPath), {recursive: true})
await fs.writeFile(targetPath, 'symlink file content')

const uploadPath = path.join(root, 'upload-dir', 'symlink.txt')
await fs.mkdir(path.dirname(uploadPath), {recursive: true})
await fs.symlink(targetPath, uploadPath, 'file')

const specifications = getUploadZipSpecification([uploadPath], root)
expect(specifications.length).toEqual(1)
expect(specifications[0].sourcePath).toEqual(uploadPath)
expect(specifications[0].destinationPath).toEqual(
path.join('/upload-dir', 'symlink.txt')
)
expect(specifications[0].stats.isSymbolicLink()).toBe(true)
})
})
8 changes: 4 additions & 4 deletions packages/artifact/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/artifact/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "2.1.9",
"version": "2.1.10",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface UploadZipSpecification {
* The destination path in a zip for a file
*/
destinationPath: string

/**
* Information about the file
* https://nodejs.org/api/fs.html#class-fsstats
*/
stats: fs.Stats
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is in the src/internal directory, does that mean this interface won't be used by consumers of this package?

Would it make sense to export the minimal information we need rather than the entire fs.Stats interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why I added the fs.Stats interface is if we eventually want to add file permissions, see this PR:

The stats and mode can eventually be passed to the archiver library. I didn't want to do that yet to avoid additional changes.

}

/**
Expand Down Expand Up @@ -75,10 +81,11 @@ export function getUploadZipSpecification(
- file3.txt
*/
for (let file of filesToZip) {
if (!fs.existsSync(file)) {
const stats = fs.lstatSync(file, {throwIfNoEntry: false})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mildly important part: we switched from statSync to lstatSync

similar to stat unless path refers to a symbolic link, in which case the link itself is stat-ed, not the file that it refers to

we'll use these stats to check isSymbolicLink when we add the file to the zip, and resolve the target's contents.

if (!stats) {
throw new Error(`File ${file} does not exist`)
}
if (!fs.statSync(file).isDirectory()) {
if (!stats.isDirectory()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you have a symbolic link to a directory?
Do we need a normal stat to check this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you have a symbolic link to a directory?

Yes you can!

Do we need a normal stat to check this?

I don't think so. As long as we know it's a directory that's all that matters. We add the symlink's path as the destinationPath below will a sourcePath of null, so when we create the directory in the zip, it's just a normal directory.

Directory symlinks don't need to be explicitly followed since our globber will do that for us when traversing the files. So as long as we know that it's a directory (symlink or not), that's all that matters.

// Normalize and resolve, this allows for either absolute or relative paths to be used
file = normalize(file)
file = resolve(file)
Expand All @@ -94,7 +101,8 @@ export function getUploadZipSpecification(

specification.push({
sourcePath: file,
destinationPath: uploadPath
destinationPath: uploadPath,
stats
})
} else {
// Empty directory
Expand All @@ -103,7 +111,8 @@ export function getUploadZipSpecification(

specification.push({
sourcePath: null,
destinationPath: directoryPath
destinationPath: directoryPath,
stats
})
}
}
Expand Down
11 changes: 9 additions & 2 deletions packages/artifact/src/internal/upload/zip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as stream from 'stream'
import {readlink} from 'fs/promises'
import * as archiver from 'archiver'
import * as core from '@actions/core'
import {UploadZipSpecification} from './upload-zip-specification'
Expand Down Expand Up @@ -42,8 +43,14 @@ export async function createZipUploadStream(

for (const file of uploadSpecification) {
if (file.sourcePath !== null) {
// Add a normal file to the zip
zip.file(file.sourcePath, {
// Check if symlink and resolve the source path
let sourcePath = file.sourcePath
if (file.stats.isSymbolicLink()) {
sourcePath = await readlink(file.sourcePath)
}

// Add the file to the zip
zip.file(sourcePath, {
name: file.destinationPath
})
} else {
Expand Down
Loading