Skip to content

Commit 5bbaf90

Browse files
authored
Merge pull request #308 from crazy-max/github-upload-artifact
github: upload artifact
2 parents 00abdc0 + db0a361 commit 5bbaf90

File tree

7 files changed

+957
-30
lines changed

7 files changed

+957
-30
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ jobs:
9898
-
9999
name: Checkout
100100
uses: actions/checkout@v4
101+
-
102+
name: Expose GitHub Runtime
103+
uses: crazy-max/ghaction-github-runtime@v3
101104
-
102105
name: Setup Node
103106
uses: actions/setup-node@v4

__tests__/github.test.itg.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright 2024 actions-toolkit authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
18+
import * as path from 'path';
19+
20+
import {GitHub} from '../src/github';
21+
22+
const fixturesDir = path.join(__dirname, 'fixtures');
23+
24+
const maybe = !process.env.GITHUB_ACTIONS || (process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu')) ? describe : describe.skip;
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
maybe('uploadArtifact', () => {
31+
it('uploads an artifact', async () => {
32+
const res = await GitHub.uploadArtifact({
33+
filename: path.join(fixturesDir, 'github-repo.json'),
34+
mimeType: 'application/json',
35+
retentionDays: 1
36+
});
37+
expect(res).toBeDefined();
38+
console.log('uploadArtifactResponse', res);
39+
expect(res?.url).toBeDefined();
40+
});
41+
});

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,15 @@
4545
"registry": "https://registry.npmjs.org/"
4646
},
4747
"dependencies": {
48+
"@actions/artifact": "^2.1.5",
4849
"@actions/cache": "^3.2.4",
4950
"@actions/core": "^1.10.1",
5051
"@actions/exec": "^1.1.1",
5152
"@actions/github": "^6.0.0",
5253
"@actions/http-client": "^2.2.1",
5354
"@actions/io": "^1.1.3",
5455
"@actions/tool-cache": "^2.0.1",
56+
"@azure/storage-blob": "^12.15.0",
5557
"@octokit/core": "^5.1.0",
5658
"@octokit/plugin-rest-endpoint-methods": "^10.4.0",
5759
"async-retry": "^1.3.3",

src/github.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,23 @@
1414
* limitations under the License.
1515
*/
1616

17-
import {GitHub as Octokit} from '@actions/github/lib/utils';
17+
import crypto from 'crypto';
18+
import fs from 'fs';
19+
import path from 'path';
20+
import {CreateArtifactRequest, FinalizeArtifactRequest, StringValue} from '@actions/artifact/lib/generated';
21+
import {internalArtifactTwirpClient} from '@actions/artifact/lib/internal/shared/artifact-twirp-client';
22+
import {getBackendIdsFromToken} from '@actions/artifact/lib/internal/shared/util';
23+
import {getExpiration} from '@actions/artifact/lib/internal/upload/retention';
24+
import {InvalidResponseError, NetworkError} from '@actions/artifact';
1825
import * as core from '@actions/core';
1926
import * as github from '@actions/github';
27+
import {GitHub as Octokit} from '@actions/github/lib/utils';
2028
import {Context} from '@actions/github/lib/context';
29+
import {TransferProgressEvent} from '@azure/core-http';
30+
import {BlobClient, BlobHTTPHeaders} from '@azure/storage-blob';
2131
import {jwtDecode, JwtPayload} from 'jwt-decode';
2232

23-
import {GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo} from './types/github';
33+
import {GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github';
2434

2535
export interface GitHubOpts {
2636
token?: string;
@@ -90,4 +100,94 @@ export class GitHub {
90100
throw new Error(`Cannot parse GitHub Actions Runtime Token ACs: ${e.message}`);
91101
}
92102
}
103+
104+
public static async uploadArtifact(opts: UploadArtifactOpts): Promise<UploadArtifactResponse> {
105+
const artifactName = path.basename(opts.filename);
106+
const backendIds = getBackendIdsFromToken();
107+
const artifactClient = internalArtifactTwirpClient();
108+
109+
core.info(`Uploading ${artifactName} to blob storage`);
110+
111+
const createArtifactReq: CreateArtifactRequest = {
112+
workflowRunBackendId: backendIds.workflowRunBackendId,
113+
workflowJobRunBackendId: backendIds.workflowJobRunBackendId,
114+
name: artifactName,
115+
version: 4
116+
};
117+
118+
const expiresAt = getExpiration(opts?.retentionDays);
119+
if (expiresAt) {
120+
createArtifactReq.expiresAt = expiresAt;
121+
}
122+
123+
const createArtifactResp = await artifactClient.CreateArtifact(createArtifactReq);
124+
if (!createArtifactResp.ok) {
125+
throw new InvalidResponseError('cannot create artifact client');
126+
}
127+
128+
let uploadByteCount = 0;
129+
const blobClient = new BlobClient(createArtifactResp.signedUploadUrl);
130+
const blockBlobClient = blobClient.getBlockBlobClient();
131+
132+
const headers: BlobHTTPHeaders = {
133+
blobContentDisposition: `attachment; filename="${artifactName}"`
134+
};
135+
if (opts.mimeType) {
136+
headers.blobContentType = opts.mimeType;
137+
}
138+
core.debug(`Upload headers: ${JSON.stringify(headers)}`);
139+
140+
try {
141+
core.info('Beginning upload of artifact content to blob storage');
142+
await blockBlobClient.uploadFile(opts.filename, {
143+
blobHTTPHeaders: headers,
144+
onProgress: (progress: TransferProgressEvent): void => {
145+
core.info(`Uploaded bytes ${progress.loadedBytes}`);
146+
uploadByteCount = progress.loadedBytes;
147+
}
148+
});
149+
} catch (error) {
150+
if (NetworkError.isNetworkErrorCode(error?.code)) {
151+
throw new NetworkError(error?.code);
152+
}
153+
throw error;
154+
}
155+
156+
core.info('Finished uploading artifact content to blob storage!');
157+
158+
const sha256Hash = crypto.createHash('sha256').update(fs.readFileSync(opts.filename)).digest('hex');
159+
core.info(`SHA256 hash of uploaded artifact is ${sha256Hash}`);
160+
161+
const finalizeArtifactReq: FinalizeArtifactRequest = {
162+
workflowRunBackendId: backendIds.workflowRunBackendId,
163+
workflowJobRunBackendId: backendIds.workflowJobRunBackendId,
164+
name: artifactName,
165+
size: uploadByteCount ? uploadByteCount.toString() : '0'
166+
};
167+
168+
if (sha256Hash) {
169+
finalizeArtifactReq.hash = StringValue.create({
170+
value: `sha256:${sha256Hash}`
171+
});
172+
}
173+
174+
core.info(`Finalizing artifact upload`);
175+
const finalizeArtifactResp = await artifactClient.FinalizeArtifact(finalizeArtifactReq);
176+
if (!finalizeArtifactResp.ok) {
177+
throw new InvalidResponseError('Cannot finalize artifact upload');
178+
}
179+
180+
const artifactId = BigInt(finalizeArtifactResp.artifactId);
181+
core.info(`Artifact successfully finalized (${artifactId})`);
182+
183+
const artifactURL = `${GitHub.workflowRunURL}/artifacts/${artifactId}`;
184+
core.info(`Artifact download URL: ${artifactURL}`);
185+
186+
return {
187+
id: Number(artifactId),
188+
filename: artifactName,
189+
size: uploadByteCount,
190+
url: artifactURL
191+
};
192+
}
93193
}

src/types/github.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,16 @@ export interface GitHubActionsRuntimeTokenAC {
3434
Scope: string;
3535
Permission: number;
3636
}
37+
38+
export interface UploadArtifactOpts {
39+
filename: string;
40+
mimeType?: string;
41+
retentionDays?: number;
42+
}
43+
44+
export interface UploadArtifactResponse {
45+
id: number;
46+
filename: string;
47+
size: number;
48+
url: string;
49+
}

tsconfig.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"compilerOptions": {
33
"esModuleInterop": true,
4-
"target": "ES2022",
5-
"module": "nodenext",
6-
"moduleResolution": "nodenext",
4+
"target": "es6",
5+
"module": "commonjs",
76
"strict": true,
87
"declaration": true,
98
"sourceMap": true,

0 commit comments

Comments
 (0)