test output link

This commit is contained in:
Trym Lund Flogard 2021-06-05 13:13:36 +02:00
parent 8167ea6a3e
commit a48c618622
14 changed files with 15281 additions and 1326 deletions

View File

@ -30,6 +30,10 @@ inputs:
Duration after which artifact will expire in days. 0 means using default retention. Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day. Minimum 1 day.
Maximum 90 days unless changed from the repository settings page. Maximum 90 days unless changed from the repository settings page.
token:
description: GitHub Access Token
required: false
default: ${{ github.token }}
runs: runs:
using: 'node12' using: 'node12'
main: 'dist/index.js' main: 'dist/index.js'

6746
dist/index.js vendored

File diff suppressed because one or more lines are too long

1
dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2581
dist/licenses.txt vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/sourcemap-register.js vendored Normal file

File diff suppressed because one or more lines are too long

6253
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,11 @@
"description": "", "description": "",
"main": "lib/nextcloud-artifacts.js", "main": "lib/nextcloud-artifacts.js",
"scripts": { "scripts": {
"build": "npx tsc", "build": "tsc",
"format": "prettier --write **/*.ts", "format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts", "format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"package": "npx ncc build --source-map --license licenses.txt", "package": "ncc build --source-map --license licenses.txt",
"test": "jest --ci" "test": "jest --ci"
}, },
"repository": { "repository": {
@ -44,6 +44,7 @@
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.16.1", "@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1", "@typescript-eslint/parser": "^4.16.1",
"@vercel/ncc": "^0.28.6",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"eslint": "^7.21.0", "eslint": "^7.21.0",
"eslint-plugin-github": "^4.1.3", "eslint-plugin-github": "^4.1.3",

48
src/ActionInputs.ts Normal file
View File

@ -0,0 +1,48 @@
import * as core from '@actions/core'
import {NoFileOption} from './NoFileOption'
import {Inputs} from './Inputs'
export class ActionInputs implements Inputs {
get ArtifactName(): string {
return core.getInput('name')
}
get ArtifactPath(): string {
return core.getInput('path')
}
get Retention(): string {
return core.getInput('retention-days')
}
get Endpoint(): string {
return core.getInput('nextcloud-url')
}
get Username(): string {
return core.getInput('nextcloud-username')
}
get Password(): string {
return core.getInput('nextcloud-password')
}
get Token(): string {
return core.getInput('token', { required: true })
}
get NoFileBehvaior(): NoFileOption {
const notFoundAction = core.getInput('if-no-files-found') || NoFileOption.warn
const noFileBehavior: NoFileOption = NoFileOption[notFoundAction as keyof typeof NoFileOption]
if (!noFileBehavior) {
core.setFailed(
`Unrecognized ${'ifNoFilesFound'} input. Provided: ${notFoundAction}. Available options: ${Object.keys(
NoFileOption
)}`
)
}
return noFileBehavior
}
}

View File

@ -10,20 +10,17 @@ export class FileFinder {
followSymbolicLinks: true, followSymbolicLinks: true,
implicitDescendants: true, implicitDescendants: true,
omitBrokenSymbolicLinks: true omitBrokenSymbolicLinks: true
}; }
private globOptions: glob.GlobOptions private globOptions: glob.GlobOptions
public constructor(private searchPath: string, globOptions?: glob.GlobOptions) { constructor(private searchPath: string, globOptions?: glob.GlobOptions) {
this.globOptions = globOptions || FileFinder.DefaultGlobOptions; this.globOptions = globOptions || FileFinder.DefaultGlobOptions
} }
public async findFiles() { async findFiles() {
const searchResults: string[] = [] const searchResults: string[] = []
const globber = await glob.create( const globber = await glob.create(this.searchPath, this.globOptions)
this.searchPath,
this.globOptions
);
const rawSearchResults: string[] = await globber.glob() const rawSearchResults: string[] = await globber.glob()
@ -53,9 +50,7 @@ export class FileFinder {
set.add(searchResult.toLowerCase()) set.add(searchResult.toLowerCase())
} }
} else { } else {
debug( debug(`Removing ${searchResult} from rawSearchResults because it is a directory`)
`Removing ${searchResult} from rawSearchResults because it is a directory`
)
} }
} }
@ -63,13 +58,9 @@ export class FileFinder {
const searchPaths: string[] = globber.getSearchPaths() const searchPaths: string[] = globber.getSearchPaths()
if (searchPaths.length > 1) { if (searchPaths.length > 1) {
info( info(`Multiple search paths detected. Calculating the least common ancestor of all paths`)
`Multiple search paths detected. Calculating the least common ancestor of all paths`
)
const lcaSearchPath = this.getMultiPathLCA(searchPaths) const lcaSearchPath = this.getMultiPathLCA(searchPaths)
info( info(`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`)
`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`
)
return { return {
filesToUpload: searchResults, filesToUpload: searchResults,

View File

@ -12,5 +12,5 @@ export enum NoFileOption {
/** /**
* Do not output any warnings or errors, the action does not fail * Do not output any warnings or errors, the action does not fail
*/ */
ignore = 'ignore', ignore = 'ignore'
} }

View File

@ -1,12 +1,15 @@
import { Inputs } from './Inputs'; import {NextcloudArtifact} from './nextcloud/NextcloudArtifact'
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact'; import * as core from '@actions/core'
import * as core from '@actions/core'; import {ActionInputs} from './ActionInputs'
async function run() {
try { try {
var artifact = new NextcloudArtifact(Inputs.ArtifactName, Inputs.ArtifactPath, Inputs.NoFileBehvaior); const artifact = new NextcloudArtifact(new ActionInputs())
artifact.run() await artifact.run()
.catch(e => core.setFailed(e)); core.info('Finished')
core.info("Finished");
} catch (error) { } catch (error) {
core.setFailed(error.message); core.setFailed(error.message)
} }
}
run()

View File

@ -1,63 +1,138 @@
import * as core from '@actions/core'; import * as core from '@actions/core'
import { FileFinder } from '../FileFinder'; import * as github from '@actions/github'
import { Inputs } from '../Inputs'; import { GitHub } from '@actions/github/lib/utils'
import { NextcloudClient } from './NextcloudClient';
import { NoFileOption } from '../NoFileOption'; import { FileFinder } from '../FileFinder'
import { Inputs } from '../Inputs'
import { NextcloudClient } from './NextcloudClient'
import { NoFileOption } from '../NoFileOption'
export class NextcloudArtifact { export class NextcloudArtifact {
public constructor( readonly octokit: InstanceType<typeof GitHub>
private name: string, readonly context = NextcloudArtifact.getCheckRunContext()
private path: string, readonly token: string
private errorBehavior: NoFileOption) { } readonly name: string
readonly path: string
readonly errorBehavior: NoFileOption
public async run() { constructor(private inputs: Inputs) {
const fileFinder = new FileFinder(this.path); this.token = inputs.Token;
const files = await fileFinder.findFiles(); this.name = inputs.ArtifactName
this.path = inputs.ArtifactPath
this.errorBehavior = inputs.NoFileBehvaior
this.name = inputs.ArtifactName
this.octokit = github.getOctokit(this.token)
}
async run() {
const fileFinder = new FileFinder(this.path)
const files = await fileFinder.findFiles()
if (files.filesToUpload.length > 0) { if (files.filesToUpload.length > 0) {
await this.uploadFiles(files); await this.uploadFiles(files)
} } else {
else { this.logNoFilesFound()
this.logNoFilesFound();
} }
} }
private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string; }) { private static getCheckRunContext(): { sha: string; runId: number } {
this.logUpload(files.filesToUpload.length, files.rootDirectory); if (github.context.eventName === 'workflow_run') {
core.info('Action was triggered by workflow_run: using SHA and RUN_ID from triggering workflow')
const event = github.context.payload
if (!event.workflow_run) {
throw new Error("Event of type 'workflow_run' is missing 'workflow_run' field")
}
return {
sha: event.workflow_run.head_commit.id,
runId: event.workflow_run.id
}
}
const client = new NextcloudClient(Inputs.Endpoint, this.name, files.rootDirectory, Inputs.Username, Inputs.Password); const runId = github.context.runId
if (github.context.payload.pull_request) {
core.info(`Action was triggered by ${github.context.eventName}: using SHA from head of source branch`)
const pr = github.context.payload.pull_request
return { sha: pr.head.sha, runId }
}
await client.uploadFiles(files.filesToUpload); return { sha: github.context.sha, runId }
}
private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string }) {
this.logUpload(files.filesToUpload.length, files.rootDirectory)
const createResp = await this.octokit.rest.checks.create({
head_sha: this.context.sha,
name: 'Nextcloud Artifacts',
status: 'in_progress',
output: {
title: 'Nextcloud Artifacts',
summary: ''
},
...github.context.repo
})
const client = new NextcloudClient(
this.inputs.Endpoint,
this.name,
files.rootDirectory,
this.inputs.Username,
this.inputs.Password
)
try {
const shareableUrl = await client.uploadFiles(files.filesToUpload)
const resp = await this.octokit.rest.checks.update({
check_run_id: createResp.data.id,
conclusion: 'success',
status: 'completed',
output: {
title: 'Nextcloud Artifacts',
summary: `${this.name}: ${shareableUrl}`
},
...github.context.repo
})
core.info(`Check run create response: ${resp.status}`)
core.info(`Check run URL: ${resp.data.url}`)
core.info(`Check run HTML: ${resp.data.html_url}`)
} catch (error) {
await this.octokit.rest.checks.update({
check_run_id: createResp.data.id,
conclusion: 'failure',
status: 'completed',
output: {
title: 'Nextcloud Artifacts'
},
...github.context.repo
})
}
} }
private logUpload(fileCount: number, rootDirectory: string) { private logUpload(fileCount: number, rootDirectory: string) {
const s = fileCount === 1 ? '' : 's'; const s = fileCount === 1 ? '' : 's'
core.info( core.info(`With the provided path, there will be ${fileCount} file${s} uploaded`)
`With the provided path, there will be ${fileCount} file${s} uploaded` core.debug(`Root artifact directory is ${rootDirectory}`)
);
core.debug(`Root artifact directory is ${rootDirectory}`);
if (fileCount > 10000) { if (fileCount > 10000) {
core.warning( core.warning(
`There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.` `There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.`
); )
} }
} }
private logNoFilesFound() { private logNoFilesFound() {
const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`; const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`
switch (this.errorBehavior) { switch (this.errorBehavior) {
case NoFileOption.warn: { case NoFileOption.warn: {
core.warning(errorMessage); core.warning(errorMessage)
break; break
} }
case NoFileOption.error: { case NoFileOption.error: {
core.setFailed(errorMessage); core.setFailed(errorMessage)
break; break
} }
case NoFileOption.ignore: { case NoFileOption.ignore: {
core.info(errorMessage); core.info(errorMessage)
break; break
} }
} }
} }

View File

@ -1,63 +1,64 @@
import * as fsSync from 'fs' import * as fsSync from 'fs'
import * as path from 'path' import * as path from 'path'
import * as core from '@actions/core'; import * as core from '@actions/core'
import * as os from 'os'; import * as os from 'os'
import * as archiver from 'archiver'; import * as archiver from 'archiver'
import fetch, { HeadersInit } from 'node-fetch'; import fetch, { HeadersInit } from 'node-fetch'
import btoa from 'btoa'; import btoa from 'btoa'
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid'
import * as webdav from 'webdav' import * as webdav from 'webdav'
const fs = fsSync.promises; const fs = fsSync.promises
interface FileSpec { interface FileSpec {
absolutePath: string, absolutePath: string
uploadPath: string uploadPath: string
} }
export class NextcloudClient { export class NextcloudClient {
private guid: string; private guid: string
private headers: HeadersInit; private headers: HeadersInit
private davClient; private davClient
public constructor( constructor(
private endpoint: string, private endpoint: string,
private artifact: string, private artifact: string,
private rootDirectory: string, private rootDirectory: string,
private username: string, private username: string,
private password: string) { private password: string
this.guid = uuidv4(); ) {
this.headers = { 'Authorization': 'Basic ' + btoa(`${this.username}:${this.password}`) }; this.guid = uuidv4()
this.headers = { Authorization: 'Basic ' + btoa(`${this.username}:${this.password}`) }
this.davClient = webdav.createClient(`${this.endpoint}/remote.php/dav/files/${this.username}`, { this.davClient = webdav.createClient(`${this.endpoint}/remote.php/dav/files/${this.username}`, {
username: this.username, username: this.username,
password: this.password, password: this.password
}); })
} }
public async uploadFiles(files: string[]) { async uploadFiles(files: string[]): Promise<string> {
core.info("Preparing upload..."); core.info('Preparing upload...')
const spec = this.uploadSpec(files); const spec = this.uploadSpec(files)
core.info("Zipping files..."); core.info('Zipping files...')
var zip = await this.zipFiles(spec); const zip = await this.zipFiles(spec)
core.info("Uploading to Nextcloud..."); core.info('Uploading to Nextcloud...')
const path = await this.upload(zip); const filePath = await this.upload(zip)
core.info(`File path: ${path}`); core.info(`File path: ${filePath}`)
core.info("Sharing file..."); core.info('Sharing file...')
await this.shareFile(path); return await this.shareFile(filePath)
} }
private uploadSpec(files: string[]): FileSpec[] { private uploadSpec(files: string[]): FileSpec[] {
const specifications = []; const specifications = []
if (!fsSync.existsSync(this.rootDirectory)) { if (!fsSync.existsSync(this.rootDirectory)) {
throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`); throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`)
} }
if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) { if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) {
throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`); throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`)
} }
// Normalize and resolve, this allows for either absolute or relative paths to be used // Normalize and resolve, this allows for either absolute or relative paths to be used
let root = path.normalize(this.rootDirectory); let root = path.normalize(this.rootDirectory)
root = path.resolve(root); root = path.resolve(root)
/* /*
Example to demonstrate behavior Example to demonstrate behavior
@ -79,17 +80,17 @@ export class NextcloudClient {
*/ */
for (let file of files) { for (let file of files) {
if (!fsSync.existsSync(file)) { if (!fsSync.existsSync(file)) {
throw new Error(`File ${file} does not exist`); throw new Error(`File ${file} does not exist`)
} }
if (!fsSync.lstatSync(file).isDirectory()) { if (!fsSync.lstatSync(file).isDirectory()) {
// Normalize and resolve, this allows for either absolute or relative paths to be used // Normalize and resolve, this allows for either absolute or relative paths to be used
file = path.normalize(file); file = path.normalize(file)
file = path.resolve(file); file = path.resolve(file)
if (!file.startsWith(root)) { if (!file.startsWith(root)) {
throw new Error(`The rootDirectory: ${root} is not a parent directory of the file: ${file}`); throw new Error(`The rootDirectory: ${root} is not a parent directory of the file: ${file}`)
} }
// Check for forbidden characters in file paths that will be rejected during upload // Check for forbidden characters in file paths that will be rejected during upload
const uploadPath = file.replace(root, ''); const uploadPath = file.replace(root, '')
/* /*
uploadFilePath denotes where the file will be uploaded in the file container on the server. During a run, if multiple artifacts are uploaded, they will all uploadFilePath denotes where the file will be uploaded in the file container on the server. During a run, if multiple artifacts are uploaded, they will all
be saved in the same container. The artifact name is used as the root directory in the container to separate and distinguish uploaded artifacts be saved in the same container. The artifact name is used as the root directory in the container to separate and distinguish uploaded artifacts
@ -103,97 +104,102 @@ export class NextcloudClient {
specifications.push({ specifications.push({
absolutePath: file, absolutePath: file,
uploadPath: path.join(this.artifact, uploadPath) uploadPath: path.join(this.artifact, uploadPath)
}); })
} } else {
else {
// Directories are rejected by the server during upload // Directories are rejected by the server during upload
core.debug(`Removing ${file} from rawSearchResults because it is a directory`); core.debug(`Removing ${file} from rawSearchResults because it is a directory`)
} }
} }
return specifications; return specifications
} }
private async zipFiles(specs: FileSpec[]): Promise<string> { private async zipFiles(specs: FileSpec[]): Promise<string> {
const tempArtifactDir = path.join(os.tmpdir(), this.guid); const tempArtifactDir = path.join(os.tmpdir(), this.guid)
const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`); const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`)
await fs.mkdir(path.join(artifactPath, this.artifact), { recursive: true }); await fs.mkdir(path.join(artifactPath, this.artifact), { recursive: true })
const copies = []; const copies = []
for (let spec of specs) { for (const spec of specs) {
const dstpath = path.join(artifactPath, spec.uploadPath); const dstpath = path.join(artifactPath, spec.uploadPath)
const dstDir = path.dirname(dstpath); const dstDir = path.dirname(dstpath)
if (!fsSync.existsSync(dstDir)) { if (!fsSync.existsSync(dstDir)) {
await fs.mkdir(dstDir, { recursive: true }); await fs.mkdir(dstDir, { recursive: true })
} }
copies.push(fs.copyFile(spec.absolutePath, dstpath)); copies.push(fs.copyFile(spec.absolutePath, dstpath))
} }
await Promise.all(copies); await Promise.all(copies)
core.info(`files: ${await fs.readdir(path.join(artifactPath, this.artifact))}`); core.info(`files: ${await fs.readdir(path.join(artifactPath, this.artifact))}`)
const archivePath = path.join(artifactPath, `${this.artifact}.zip`); const archivePath = path.join(artifactPath, `${this.artifact}.zip`)
await this.zip(path.join(artifactPath, this.artifact), archivePath); await this.zip(path.join(artifactPath, this.artifact), archivePath)
core.info(`archive stat: ${(await fs.stat(archivePath)).size}`); core.info(`archive stat: ${(await fs.stat(archivePath)).size}`)
return archivePath; return archivePath
} }
private async zip(dirpath: string, destpath: string) { private async zip(dirpath: string, destpath: string) {
const archive = archiver.create('zip', { zlib: { level: 9 } }); const archive = archiver.create('zip', { zlib: { level: 9 } })
const stream = archive.directory(dirpath, false) const stream = archive.directory(dirpath, false).pipe(fsSync.createWriteStream(destpath))
.pipe(fsSync.createWriteStream(destpath));
await archive.finalize(); await archive.finalize()
return await new Promise<void>((resolve, reject) => { return await new Promise<void>((resolve, reject) => {
stream.on('error', e => reject(e)) stream.on('error', e => reject(e)).on('close', () => resolve())
.on('close', () => resolve());
}) })
} }
private async upload(file: string): Promise<string> { private async upload(file: string): Promise<string> {
const remoteFileDir = `/artifacts/${this.guid}`; const remoteFileDir = `/artifacts/${this.guid}`
core.info("Checking directory..."); core.info('Checking directory...')
if (!(await this.davClient.exists(remoteFileDir))) { if (!(await this.davClient.exists(remoteFileDir))) {
core.info("Creating directory..."); core.info('Creating directory...')
await this.davClient.createDirectory(remoteFileDir, { recursive: true }); await this.davClient.createDirectory(remoteFileDir, { recursive: true })
} }
const remoteFilePath = `${remoteFileDir}/${this.artifact}.zip`; const remoteFilePath = `${remoteFileDir}/${this.artifact}.zip`
core.info(`Transferring file... (${file})`); core.info(`Transferring file... (${file})`)
const fileStat = await fs.stat(file); const fileStat = await fs.stat(file)
const fileStream = fsSync.createReadStream(file); const fileStream = fsSync.createReadStream(file)
const remoteStream = this.davClient.createWriteStream(remoteFilePath, { const remoteStream = this.davClient.createWriteStream(remoteFilePath, {
headers: { "Content-Length": fileStat.size.toString() }, headers: { 'Content-Length': fileStat.size.toString() }
}); })
fileStream.pipe(remoteStream); fileStream.pipe(remoteStream)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
fileStream.on('error', e => reject(e)) fileStream.on('error', e => reject(e)).on('finish', () => resolve())
.on('finish', () => resolve()); })
});
return remoteFilePath; return remoteFilePath
} }
private async shareFile(remoteFilePath: string) { private async shareFile(remoteFilePath: string): Promise<string> {
const url = this.endpoint + `/ocs/v2.php/apps/files_sharing/api/v1/shares`; const url = this.endpoint + `/ocs/v2.php/apps/files_sharing/api/v1/shares`
const body = { const body = {
path: remoteFilePath, path: remoteFilePath,
shareType: 3, shareType: 3,
publicUpload: "false", publicUpload: 'false',
permissions: 1, permissions: 1
}; }
const res = await fetch(url, { const res = await fetch(url, {
method: 'PUT', method: 'POST',
headers: this.headers, headers: Object.assign(this.headers, {
body: JSON.stringify(body), 'OCS-APIRequest': true
}); }),
res.status body: JSON.stringify(body)
core.info(await res.text()) })
const result = await res.text()
const re = /<url>(?<share_url>.*)<\/url>/
const match = re.exec(result)
const sharableUrl = (match?.groups || {})['share_url']
if (!sharableUrl) {
throw new Error('Failed to parse sharable URL.')
}
return sharableUrl
} }
} }

9
workspace.code-workspace Normal file
View File

@ -0,0 +1,9 @@
{
"folders": [
{
"path": "."
}
],
"remoteAuthority": "wsl+Ubuntu-20.04",
"settings": {}
}