Repository: actions/toolkit
Pull request #575 (2020-09-16 14:37:42)

Add an option to specify retention days for artifacts
Introducing an option to reduce the default retention period for uploaded artifacts.

Today all artifacts have a 90 days retention value and there is no knob to reduce the value.  We are revamping the experience so we can set custom expiry on individual artifact.

The new minimum value is `1` day.  
 

konradpabjan: One more thing, we will need to release a new version of the package so you need to update
- RELEASES.md
- package.json
- package-lock.json

Patch:
diff --git a/packages/artifact/README.md b/packages/artifact/README.md
index ac67b5c..cdab7fd 100644
--- a/packages/artifact/README.md
+++ b/packages/artifact/README.md
@@ -39,6 +39,11 @@ Method Name: `uploadArtifact`
     - If set to `false`, and an error is encountered, all other uploads will stop and any files that were queued will not be attempted to be uploaded. The partial artifact available will only include files up until the failure.
     - If set to `true` and an error is encountered, the failed file will be skipped and ignored and all other queued files will be attempted to be uploaded. There will be an artifact available for download at the end with everything excluding the file that failed to upload
     - Optional, defaults to `true` if not specified
+- `retentionDays`
+    - Duration after which artifact will expire in days
+    - Minimum value: 1
+    - Maximum value: 90 unless changed by repository setting
+    - If this is set to a greater value than the retention settings allowed, the retention on artifacts will be reduced to match the max value allowed on the server, and the upload process will continue. An input of 0 assumes default retention value.
 
 #### Example using Absolute File Paths
 
diff --git a/packages/artifact/src/internal/__mocks__/config-variables.ts b/packages/artifact/src/internal/__mocks__/config-variables.ts
index af7f763..55aaf2e 100644
--- a/packages/artifact/src/internal/__mocks__/config-variables.ts
+++ b/packages/artifact/src/internal/__mocks__/config-variables.ts
@@ -45,3 +45,7 @@ export function getRuntimeUrl(): string {
 export function getWorkFlowRunId(): string {
   return '15'
 }
+
+export function getRetentionDays(): string | undefined {
+  return '45'
+}
diff --git a/packages/artifact/src/internal/artifact-client.ts b/packages/artifact/src/internal/artifact-client.ts
index 746a2e8..bdf8849 100644
--- a/packages/artifact/src/internal/artifact-client.ts
+++ b/packages/artifact/src/internal/artifact-client.ts
@@ -94,7 +94,8 @@ export class DefaultArtifactClient implements ArtifactClient {
     } else {
       // Create an entry for the artifact in the file container
       const response = await uploadHttpClient.createArtifactInFileContainer(
-        name
+        name,
+        options
       )
       if (!response.fileContainerResourceUrl) {
         core.debug(response.toString())
diff --git a/packages/artifact/src/internal/config-variables.ts b/packages/artifact/src/internal/config-variables.ts
index 1d25f6e..0124358 100644
--- a/packages/artifact/src/internal/config-variables.ts
+++ b/packages/artifact/src/internal/config-variables.ts
@@ -61,3 +61,7 @@ export function getWorkSpaceDirectory(): string {
   }
   return workspaceDirectory
 }
+
+export function getRetentionDays(): string | undefined {
+  return process.env['GITHUB_RETENTION_DAYS']
+}
diff --git a/packages/artifact/src/internal/contracts.ts b/packages/artifact/src/internal/contracts.ts
index c0518df..277813c 100644
--- a/packages/artifact/src/internal/contracts.ts
+++ b/packages/artifact/src/internal/contracts.ts
@@ -11,6 +11,7 @@ export interface ArtifactResponse {
 export interface CreateArtifactParameters {
   Type: string
   Name: string
+  RetentionDays?: number
 }
 
 export interface PatchArtifactSize {
diff --git a/packages/artifact/src/internal/upload-http-client.ts b/packages/artifact/src/internal/upload-http-client.ts
index 66f1dc3..b77e5fd 100644
--- a/packages/artifact/src/internal/upload-http-client.ts
+++ b/packages/artifact/src/internal/upload-http-client.ts
@@ -18,12 +18,14 @@ import {
   isForbiddenStatusCode,
   displayHttpDiagnostics,
   getExponentialRetryTimeInMilliseconds,
-  tryGetRetryAfterValueTimeInMilliseconds
+  tryGetRetryAfterValueTimeInMilliseconds,
+  getProperRetention
 } from './utils'
 import {
   getUploadChunkSize,
   getUploadFileConcurrency,
-  getRetryLimit
+  getRetryLimit,
+  getRetentionDays
 } from './config-variables'
 import {promisify} from 'util'
 import {URL} from 'url'
@@ -55,12 +57,23 @@ export class UploadHttpClient {
    * @returns The response from the Artifact Service if the file container was successfully created
    */
   async createArtifactInFileContainer(
-    artifactName: string
+    artifactName: string,
+    options?: UploadOptions | undefined
   ): Promise<ArtifactResponse> {
     const parameters: CreateArtifactParameters = {
       Type: 'actions_storage',
       Name: artifactName
     }
+
+    // calculate retention period
+    if (options && options.retentionDays) {
+      const maxRetentionStr = getRetentionDays()
+      parameters.RetentionDays = getProperRetention(
+        options.retentionDays,
+        maxRetentionStr
+      )
+    }
+
     const data: string = JSON.stringify(parameters, null, 2)
     const artifactUrl = getArtifactUrl()
 
diff --git a/packages/artifact/src/internal/upload-options.ts b/packages/artifact/src/internal/upload-options.ts
index 63d4feb..dd952be 100644
--- a/packages/artifact/src/internal/upload-options.ts
+++ b/packages/artifact/src/internal/upload-options.ts
@@ -15,4 +15,21 @@ export interface UploadOptions {
    *
    */
   continueOnError?: boolean
+
+  /**
+   * Duration after which artifact will expire in days.
+   *
+   * By default artifact expires after 90 days:
+   * https://docs.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts#downloading-and-deleting-artifacts-after-a-workflow-run-is-complete
+   *
+   * Use this option to override the default expiry.
+   *
+   * Min value: 1
+   * Max value: 90 unless changed by repository setting
+   *
+   * If this is set to a greater value than the retention settings allowed, the retention on artifacts
+   * will be reduced to match the max value allowed on server, and the upload process will continue. An
+   * input of 0 assumes default retention setting.
+   */
+  retentionDays?: number
 }
diff --git a/packages/artifact/src/internal/utils.ts b/packages/artifact/src/internal/utils.ts
index 86b482b..a9f76b2 100644
--- a/packages/artifact/src/internal/utils.ts
+++ b/packages/artifact/src/internal/utils.ts
@@ -1,4 +1,4 @@
-import {debug, info} from '@actions/core'
+import {debug, info, warning} from '@actions/core'
 import {promises as fs} from 'fs'
 import {HttpCodes, HttpClient} from '@actions/http-client'
 import {BearerCredentialHandler} from '@actions/http-client/auth'
@@ -302,3 +302,24 @@ export async function createEmptyFilesForArtifact(
     await (await fs.open(filePath, 'w')).close()
   }
 }
+
+export function getProperRetention(
+  retentionInput: number,
+  retentionSetting: string | undefined
+): number {
+  if (retentionInput < 0) {
+    throw new Error('Invalid retention, minimum value is 1.')
+  }
+
+  let retention = retentionInput
+  if (retentionSetting) {
+    const maxRetention = parseInt(retentionSetting)
+    if (!isNaN(maxRetention) && maxRetention < retention) {
+      warning(
+        `Retention days is greater than the max value allowed by the repository setting, reduce retention to ${maxRetention} days`
+      )
+      retention = maxRetention
+    }
+  }
+  return retention
+}


Test patch:
diff --git a/packages/artifact/__tests__/upload.test.ts b/packages/artifact/__tests__/upload.test.ts
index fd5ca36..f473b2e 100644
--- a/packages/artifact/__tests__/upload.test.ts
+++ b/packages/artifact/__tests__/upload.test.ts
@@ -13,6 +13,7 @@ import {
 } from '../src/internal/contracts'
 import {UploadSpecification} from '../src/internal/upload-specification'
 import {getArtifactUrl} from '../src/internal/utils'
+import {UploadOptions} from '../src/internal/upload-options'
 
 const root = path.join(__dirname, '_temp', 'artifact-upload')
 const file1Path = path.join(root, 'file1.txt')
@@ -106,6 +107,17 @@ describe('Upload Tests', () => {
     )
   })
 
+  it('Create Artifact - Retention Less Than Min Value Error', async () => {
+    const artifactName = 'valid-artifact-name'
+    const options: UploadOptions = {
+      retentionDays: -1
+    }
+    const uploadHttpClient = new UploadHttpClient()
+    expect(
+      uploadHttpClient.createArtifactInFileContainer(artifactName, options)
+    ).rejects.toEqual(new Error('Invalid retention, minimum value is 1.'))
+  })
+
   it('Create Artifact - Storage Quota Error', async () => {
     const artifactName = 'storage-quota-hit'
     const uploadHttpClient = new UploadHttpClient()
diff --git a/packages/artifact/__tests__/util.test.ts b/packages/artifact/__tests__/util.test.ts
index 02930e1..1513566 100644
--- a/packages/artifact/__tests__/util.test.ts
+++ b/packages/artifact/__tests__/util.test.ts
@@ -106,6 +106,20 @@ describe('Utils', () => {
     }
   })
 
+  it('Test negative artifact retention throws', () => {
+    expect(() => {
+      utils.getProperRetention(-1, undefined)
+    }).toThrow()
+  })
+
+  it('Test no setting specified takes artifact retention input', () => {
+    expect(utils.getProperRetention(180, undefined)).toEqual(180)
+  })
+
+  it('Test artifact retention must conform to max allowed', () => {
+    expect(utils.getProperRetention(180, '45')).toEqual(45)
+  })
+
   it('Test constructing artifact URL', () => {
     const runtimeUrl = getRuntimeUrl()
     const runId = getWorkFlowRunId()