Skip to content

Commit

Permalink
add merge artifact sub-action
Browse files Browse the repository at this point in the history
  • Loading branch information
robherley committed Jan 23, 2024
1 parent 52899c8 commit 997fffa
Show file tree
Hide file tree
Showing 11 changed files with 1,100 additions and 61 deletions.
26 changes: 26 additions & 0 deletions .licenses/npm/minimatch.dep.yml

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

175 changes: 175 additions & 0 deletions __tests__/merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import * as core from '@actions/core'
import artifact from '@actions/artifact'
import {run} from '../src/merge/merge-artifacts'
import {Inputs} from '../src/merge/constants'
import * as search from '../src/shared/search'

const fixtures = {
artifactName: 'my-merged-artifact',
tmpDirectory: '/tmp/merge-artifact',
filesToUpload: [
'/some/artifact/path/file-a.txt',
'/some/artifact/path/file-b.txt',
'/some/artifact/path/file-c.txt'
],
artifacts: [
{
name: 'my-artifact-a',
id: 1,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
},
{
name: 'my-artifact-b',
id: 2,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
},
{
name: 'my-artifact-c',
id: 3,
size: 100,
createdAt: new Date('2024-01-01T00:00:00Z')
}
]
}

jest.mock('@actions/github', () => ({
context: {
repo: {
owner: 'actions',
repo: 'toolkit'
},
runId: 123,
serverUrl: 'https://github.com'
}
}))

jest.mock('@actions/core')

jest.mock('fs/promises', () => ({
mkdtemp: jest.fn().mockResolvedValue('/tmp/merge-artifact'),
rm: jest.fn().mockResolvedValue(undefined)
}))

/* eslint-disable no-unused-vars */
const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => {
const inputs = {
[Inputs.Name]: 'my-merged-artifact',
[Inputs.Pattern]: '*',
[Inputs.SeparateDirectories]: false,
[Inputs.RetentionDays]: 0,
[Inputs.CompressionLevel]: 6,
[Inputs.DeleteMerged]: false,
...overrides
}

;(core.getInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})
;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => {
return inputs[name]
})

return inputs
}

describe('merge', () => {
beforeEach(async () => {
mockInputs()

jest
.spyOn(artifact, 'listArtifacts')
.mockResolvedValue({artifacts: fixtures.artifacts})

jest.spyOn(artifact, 'downloadArtifact').mockResolvedValue({
downloadPath: fixtures.tmpDirectory
})

jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({
filesToUpload: fixtures.filesToUpload,
rootDirectory: fixtures.tmpDirectory
})

jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({
size: 123,
id: 1337
})

jest
.spyOn(artifact, 'deleteArtifact')
.mockImplementation(async artifactName => {
const artifact = fixtures.artifacts.find(a => a.name === artifactName)
if (!artifact) throw new Error(`Artifact ${artifactName} not found`)
return {id: artifact.id}
})
})

it('merges artifacts', async () => {
await run()

for (const a of fixtures.artifacts) {
expect(artifact.downloadArtifact).toHaveBeenCalledWith(a.id, {
path: fixtures.tmpDirectory
})
}

expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{compressionLevel: 6}
)
})

it('fails if no artifacts found', async () => {
mockInputs({[Inputs.Pattern]: 'this-does-not-match'})

expect(run()).rejects.toThrow()

expect(artifact.uploadArtifact).not.toBeCalled()
expect(artifact.downloadArtifact).not.toBeCalled()
})

it('supports custom compression level', async () => {
mockInputs({
[Inputs.CompressionLevel]: 2
})

await run()

expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{compressionLevel: 2}
)
})

it('supports custom retention days', async () => {
mockInputs({
[Inputs.RetentionDays]: 7
})

await run()

expect(artifact.uploadArtifact).toHaveBeenCalledWith(
fixtures.artifactName,
fixtures.filesToUpload,
fixtures.tmpDirectory,
{retentionDays: 7, compressionLevel: 6}
)
})

it('supports deleting artifacts after merge', async () => {
mockInputs({
[Inputs.DeleteMerged]: true
})

await run()

for (const a of fixtures.artifacts) {
expect(artifact.deleteArtifact).toHaveBeenCalledWith(a.name)
}
})
})
63 changes: 63 additions & 0 deletions merge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# `@actions/upload-artifact/merge`

Merge multiple [Actions Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) in Workflow Runs. Internally powered by [@actions/artifact](https://github.com/actions/toolkit/tree/main/packages/artifact) package.

- [`@actions/upload-artifact/merge`](#actionsupload-artifactmerge)
- [Usage](#usage)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Examples](#examples)

## Usage

> [!IMPORTANT]
> upload-artifact/merge@v4+ is not currently supported on GHES.
Note: this actions can only merge artifacts created with actions/upload-artifact@v4+

### Inputs

```yaml
- uses: actions/upload-artifact/merge@v4
with:
# The name of the artifact that the artifacts will be merged into
# Optional. Default is 'merged-artifacts'
name:

# A glob pattern matching the artifacts that should be merged.
# Optional. Default is '*'
pattern:

# If true, the artifacts will be merged into separate directories.
# If false, the artifacts will be merged into the root of the destination.
# Optional. Default is 'false'
separate-directories:

# If true, the artifacts that were merged will be deleted.
# If false, the artifacts will still exist.
# Optional. Default is 'false'
delete-merged:

# Duration after which artifact will expire in days. 0 means using default retention.
# Minimum 1 day.
# Maximum 90 days unless changed from the repository settings page.
# Optional. Defaults to repository settings.
retention-days:

# The level of compression for Zlib to be applied to the artifact archive.
# The value can range from 0 to 9.
# For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
# Optional. Default is '6'
compression-level:
```

### Outputs

| Name | Description | Example |
| - | - | - |
| `artifact-id` | GitHub ID of an Artifact, can be used by the REST API | `1234` |
| `artifact-url` | URL to download an Artifact. Can be used in many scenarios such as linking to artifacts in issues or pull requests. Users must be logged-in in order for this URL to work. This URL is valid as long as the artifact has not expired or the artifact, run or repository have not been deleted | `https://github.com/example-org/example-repo/actions/runs/1/artifacts/1234` |

## Examples

TODO(robherley): add examples
57 changes: 57 additions & 0 deletions merge/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: 'Merge Build Artifacts'
description: 'Merge one or more build Artifacts'
author: 'GitHub'
inputs:
name:
description: 'The name of the artifact that the artifacts will be merged into.'
required: true
default: 'merged-artifacts'
pattern:
description: 'A glob pattern matching the artifact names that should be merged.'
default: '*'
separate-directories:
description: 'When multiple artifacts are matched, this changes the behavior of how they are merged in the archive.
If true, the matched artifacts will be extracted into individual named directories within the specified path.
If false, the matched artifacts will combined in the same directory.'
default: 'false'
retention-days:
description: >
Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
compression-level:
description: >
The level of compression for Zlib to be applied to the artifact archive.
The value can range from 0 to 9:
- 0: No compression
- 1: Best speed
- 6: Default compression (same as GNU Gzip)
- 9: Best compression
Higher levels will result in better compression, but will take longer to complete.
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
default: '6'
delete-merged:
description: >
If true, the artifacts that were merged will be deleted.
If false, the artifacts will still exist.
default: 'false'

outputs:
artifact-id:
description: >
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
artifact-url:
description: >
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
runs:
using: 'node20'
main: '../dist/merge/index.js'
Loading

0 comments on commit 997fffa

Please sign in to comment.