Compare commits

..

No commits in common. "master" and "v1" have entirely different histories.
master ... v1

29 changed files with 342 additions and 55006 deletions

View File

@ -1,3 +0,0 @@
dist/
lib/
node_modules/

View File

@ -1,57 +0,0 @@
{
"plugins": ["jest", "@typescript-eslint"],
"extends": ["plugin:github/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"camelcase": "off",
"eslint-comments/no-use": "off",
"import/no-namespace": "off",
"no-shadow": "off",
"no-unused-vars": "off",
"prefer-template": "off",
"semi": [ "error", "never"],
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
"@typescript-eslint/array-type": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/func-call-spacing": ["error", "never"],
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unnecessary-qualifier": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "warn",
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/restrict-plus-operands": "error",
"@typescript-eslint/semi": ["error", "never"],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error"
},
"env": {
"node": true,
"es6": true,
"jest/globals": true
}
}

3
.gitignore vendored
View File

@ -1,5 +1,3 @@
lib
# Logs # Logs
logs logs
*.log *.log
@ -82,6 +80,7 @@ typings/
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist
# Gatsby files # Gatsby files
.cache/ .cache/

View File

@ -1,5 +0,0 @@
dist/
lib/
node_modules/
__tests__/__outputs__
__tests__/__snapshots__

View File

@ -1,10 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -1,29 +1 @@
# Nextcloud Artifact Upload Action # nextcloud-artifacts-action
Upload artifacts to nextcloud and output a shareable URL.
### How it looks:
![image](https://user-images.githubusercontent.com/23460729/120891750-7f247380-c60a-11eb-9998-3b3b7f61066f.png)
### Example:
Simple example. Globbing is supported.
```yaml
on:
pull_request:
push:
jobs:
build-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2 # checkout the repo
- name: Nextcloud Artifact
uses: trympet/nextcloud-artifacts-action@v2
with:
name: 'my-artifact' # Name of the artifact
path: 'bin/**/*.exe' # Globbing supported
nextcloud-url: 'https://nextcloud.example.com' # Nextcloud URL
nextcloud-username: ${{ secrets.NEXTCLOUD_USERNAME }} # Username from repository secret
nextcloud-password: ${{ secrets.NEXTCLOUD_PASSWORD }} # Password from repository secret
```

View File

@ -1,37 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Inputs } from '../../src/Inputs'
import { NoFileOption } from '../../src/NoFileOption'
export class InputsDouble implements Inputs {
get ArtifactName(): string {
return process.env['ARTIFACT_NAME']!
}
get ArtifactPath(): string {
return process.env['ARTIFACT_PATH']!
}
get Retention(): string {
return ''
}
get Endpoint(): URL {
return new URL(process.env['ENDPOINT']!)
}
get Username(): string {
return process.env['USERNAME']!
}
get Password(): string {
return process.env['PASSWORD']!
}
get Token(): string {
return process.env['TOKEN']!
}
get NoFileBehvaior(): NoFileOption {
return NoFileOption.error
}
}

View File

@ -1,9 +0,0 @@
import { NextcloudArtifact } from '../src/nextcloud/NextcloudArtifact'
import { InputsDouble } from './doubles/InputsDouble'
describe('integration tests', () => {
it('works', async () => {
const artifact = new NextcloudArtifact(new InputsDouble())
await artifact.run()
})
})

View File

@ -1 +0,0 @@
require('dotenv').config()

View File

@ -11,12 +11,6 @@ inputs:
nextcloud-url: nextcloud-url:
description: 'The URL for the nextcloud server' description: 'The URL for the nextcloud server'
required: true required: true
nextcloud-username:
description: 'The username for the nextcloud user'
required: true
nextcloud-password:
description: 'The password for the nextcloud user'
required: true
if-no-files-found: if-no-files-found:
description: > description: >
The desired behavior if no files are found using the provided path. The desired behavior if no files are found using the provided path.
@ -25,10 +19,11 @@ inputs:
error: Fail the action with an error message error: Fail the action with an error message
ignore: Do not output any warnings or errors, the action does not fail ignore: Do not output any warnings or errors, the action does not fail
default: 'warn' default: 'warn'
token: retention-days:
description: GitHub Access Token description: >
required: false Duration after which artifact will expire in days. 0 means using default retention.
default: ${{ github.token }} Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
runs: runs:
using: 'node20' using: 'node12'
main: 'dist/index.js' main: 'dist/index.js'

45372
dist/index.js vendored

File diff suppressed because one or more lines are too long

1
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

2386
dist/licenses.txt vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,12 +0,0 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true,
setupFiles: ["./__tests__/setup.ts"]
}

6533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,70 +1,32 @@
{ {
"name": "nextcloud-artifacts-action", "name": "nextcloud-artifacts-action",
"version": "2.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "lib/nextcloud-artifacts.js", "main": "index.js",
"scripts": { "scripts": {
"build": "tsc", "test": "echo \"Error: no test specified\" && exit 1"
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"package": "ncc build --source-map --license licenses.txt",
"test": "jest --ci"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/trympet/nextcloud-artifacts-action.git" "url": "git+https://github.com/trympet/nextcloud-artifacts-action.git"
}, },
"keywords": [], "keywords": [],
"author": "Trym Lund Flogard <trym@flogard.no>", "author": "",
"license": "GPL-2.0-only", "license": "ISC",
"bugs": { "bugs": {
"url": "https://github.com/trympet/nextcloud-artifacts-action/issues" "url": "https://github.com/trympet/nextcloud-artifacts-action/issues"
}, },
"homepage": "https://github.com/trympet/nextcloud-artifacts-action#readme", "homepage": "https://github.com/trympet/nextcloud-artifacts-action#readme",
"dependencies": { "dependencies": {
"@actions/core": "^1.3.0", "@actions/core": "^1.3.0",
"@actions/exec": "^1.0.4",
"@actions/github": "^5.0.0",
"@actions/glob": "^0.1.2", "@actions/glob": "^0.1.2",
"archiver": "^5.3.0", "archiver": "^5.3.0",
"btoa": "^1.2.1", "node-fetch": "^2.6.1"
"node-fetch": "^2.6.1",
"uuid": "^8.3.2",
"webdav": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {
"@octokit/types": "^6.16.2",
"@octokit/webhooks": "^9.6.3",
"@types/archiver": "^5.1.0", "@types/archiver": "^5.1.0",
"@types/btoa": "^1.2.3",
"@types/jest": "^26.0.23",
"@types/node": "^15.6.2", "@types/node": "^15.6.2",
"@types/node-fetch": "^2.5.10", "@types/node-fetch": "^2.5.10",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"@vercel/ncc": "^0.28.6",
"dotenv": "^10.0.0",
"eslint": "^7.21.0",
"eslint-plugin-github": "^4.1.3",
"eslint-plugin-jest": "^24.1.7",
"jest": "^26.6.3",
"jest-circus": "^26.6.3",
"jest-junit": "^12.0.0",
"js-yaml": "^4.0.0",
"prettier": "2.2.1",
"ts-jest": "^26.5.3",
"typescript": "^4.3.2" "typescript": "^4.3.2"
},
"jest-junit": {
"suiteName": "jest tests",
"outputDirectory": "__tests__/__results__",
"outputName": "jest-junit.xml",
"ancestorSeparator": " ",
"uniqueOutputName": "false",
"suiteNameTemplate": "{filepath}",
"classNameTemplate": "{classname}",
"titleTemplate": "{title}"
} }
} }

View File

@ -1,45 +0,0 @@
import * as core from '@actions/core'
import { NoFileOption } from './NoFileOption'
import { Inputs } from './Inputs'
import { URL } from 'url'
export class ActionInputs implements Inputs {
get ArtifactName(): string {
return core.getInput('name', { required: false }) || 'Nextcloud Artifact'
}
get ArtifactPath(): string {
return core.getInput('path', { required: true })
}
get Endpoint(): URL {
return new URL(core.getInput('nextcloud-url', { required: true }))
}
get Username(): string {
return core.getInput('nextcloud-username', { required: true })
}
get Password(): string {
return core.getInput('nextcloud-password', { required: true })
}
get Token(): string {
return core.getInput('token', { required: true })
}
get NoFileBehvaior(): NoFileOption {
const notFoundAction = core.getInput('if-no-files-found', { required: false }) || 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

@ -1,137 +1,146 @@
import * as glob from '@actions/glob' import * as glob from '@actions/glob'
import { stat } from 'fs' import {stat} from 'fs'
import { debug, info } from '@actions/core' import {debug, info} from '@actions/core'
import * as path from 'path' import * as path from 'path'
import { promisify } from 'util' import {promisify} from 'util'
const stats = promisify(stat) const stats = promisify(stat)
export class FileFinder { export class FileFinder {
private static DefaultGlobOptions: glob.GlobOptions = { private static DefaultGlobOptions: glob.GlobOptions = {
followSymbolicLinks: true, followSymbolicLinks: true,
implicitDescendants: true, implicitDescendants: true,
omitBrokenSymbolicLinks: true omitBrokenSymbolicLinks: true
} };
private globOptions: glob.GlobOptions private globOptions: glob.GlobOptions
constructor(private searchPath: string, globOptions?: glob.GlobOptions) { public constructor(private searchPath: string, globOptions?: glob.GlobOptions) {
this.globOptions = globOptions || FileFinder.DefaultGlobOptions this.globOptions = globOptions || FileFinder.DefaultGlobOptions;
} }
async findFiles() { public async findFiles() {
const searchResults: string[] = [] const searchResults: string[] = []
const globber = await glob.create(this.searchPath, this.globOptions) const globber = await glob.create(
this.searchPath,
this.globOptions
);
const rawSearchResults: string[] = await globber.glob() const rawSearchResults: string[] = await globber.glob()
/* /*
Files are saved with case insensitivity. Uploading both a.txt and A.txt will files to be overwritten Files are saved with case insensitivity. Uploading both a.txt and A.txt will files to be overwritten
Detect any files that could be overwritten for user awareness Detect any files that could be overwritten for user awareness
*/ */
const set = new Set<string>() const set = new Set<string>()
/* /*
Directories will be rejected if attempted to be uploaded. This includes just empty Directories will be rejected if attempted to be uploaded. This includes just empty
directories so filter any directories out from the raw search results directories so filter any directories out from the raw search results
*/ */
for (const searchResult of rawSearchResults) { for (const searchResult of rawSearchResults) {
const fileStats = await stats(searchResult) const fileStats = await stats(searchResult)
// isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead // isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead
if (!fileStats.isDirectory()) { if (!fileStats.isDirectory()) {
debug(`File:${searchResult} was found using the provided searchPath`) debug(`File:${searchResult} was found using the provided searchPath`)
searchResults.push(searchResult) searchResults.push(searchResult)
// detect any files that would be overwritten because of case insensitivity // detect any files that would be overwritten because of case insensitivity
if (set.has(searchResult.toLowerCase())) { if (set.has(searchResult.toLowerCase())) {
info( info(
`Uploads are case insensitive: ${searchResult} was detected that it will be overwritten by another file with the same path` `Uploads are case insensitive: ${searchResult} was detected that it will be overwritten by another file with the same path`
) )
} else { } else {
set.add(searchResult.toLowerCase()) set.add(searchResult.toLowerCase())
}
} else {
debug(
`Removing ${searchResult} from rawSearchResults because it is a directory`
)
}
} }
} else {
debug(`Removing ${searchResult} from rawSearchResults because it is a directory`)
}
}
// Calculate the root directory for the artifact using the search paths that were utilized // Calculate the root directory for the artifact using the search paths that were utilized
const searchPaths: string[] = globber.getSearchPaths() const searchPaths: string[] = globber.getSearchPaths()
if (searchPaths.length > 1) { if (searchPaths.length > 1) {
info(`Multiple search paths detected. Calculating the least common ancestor of all paths`) info(
const lcaSearchPath = this.getMultiPathLCA(searchPaths) `Multiple search paths detected. Calculating the least common ancestor of all paths`
info(`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`) )
const lcaSearchPath = this.getMultiPathLCA(searchPaths)
info(
`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`
)
return { return {
filesToUpload: searchResults, filesToUpload: searchResults,
rootDirectory: lcaSearchPath rootDirectory: lcaSearchPath
} }
} }
/* /*
Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is
not preserved and the root directory will be the single files parent directory not preserved and the root directory will be the single files parent directory
*/ */
if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) { if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) {
return { return {
filesToUpload: searchResults, filesToUpload: searchResults,
rootDirectory: path.dirname(searchResults[0]) rootDirectory: path.dirname(searchResults[0])
} }
} }
return { return {
filesToUpload: searchResults, filesToUpload: searchResults,
rootDirectory: searchPaths[0] rootDirectory: searchPaths[0]
}
}
private getMultiPathLCA(searchPaths: string[]): string {
if (searchPaths.length < 2) {
throw new Error('At least two search paths must be provided')
}
const commonPaths = new Array<string>()
const splitPaths = new Array<string[]>()
let smallestPathLength = Number.MAX_SAFE_INTEGER
// split each of the search paths using the platform specific separator
for (const searchPath of searchPaths) {
debug(`Using search path ${searchPath}`)
const splitSearchPath = path.normalize(searchPath).split(path.sep)
// keep track of the smallest path length so that we don't accidentally later go out of bounds
smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length)
splitPaths.push(splitSearchPath)
}
// on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it
if (searchPaths[0].startsWith(path.sep)) {
commonPaths.push(path.sep)
}
let splitIndex = 0
// function to check if the paths are the same at a specific index
function isPathTheSame(): boolean {
const compare = splitPaths[0][splitIndex]
for (let i = 1; i < splitPaths.length; i++) {
if (compare !== splitPaths[i][splitIndex]) {
// a non-common index has been reached
return false
} }
}
return true
} }
// loop over all the search paths until there is a non-common ancestor or we go out of bounds private getMultiPathLCA(searchPaths: string[]): string {
while (splitIndex < smallestPathLength) { if (searchPaths.length < 2) {
if (!isPathTheSame()) { throw new Error('At least two search paths must be provided')
break }
const commonPaths = new Array<string>()
const splitPaths = new Array<string[]>()
let smallestPathLength = Number.MAX_SAFE_INTEGER
// split each of the search paths using the platform specific separator
for (const searchPath of searchPaths) {
debug(`Using search path ${searchPath}`)
const splitSearchPath = path.normalize(searchPath).split(path.sep)
// keep track of the smallest path length so that we don't accidentally later go out of bounds
smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length)
splitPaths.push(splitSearchPath)
}
// on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it
if (searchPaths[0].startsWith(path.sep)) {
commonPaths.push(path.sep)
}
let splitIndex = 0
// function to check if the paths are the same at a specific index
function isPathTheSame(): boolean {
const compare = splitPaths[0][splitIndex]
for (let i = 1; i < splitPaths.length; i++) {
if (compare !== splitPaths[i][splitIndex]) {
// a non-common index has been reached
return false
}
}
return true
}
// loop over all the search paths until there is a non-common ancestor or we go out of bounds
while (splitIndex < smallestPathLength) {
if (!isPathTheSame()) {
break
}
// if all are the same, add to the end result & increment the index
commonPaths.push(splitPaths[0][splitIndex])
splitIndex++
}
return path.join(...commonPaths)
} }
// if all are the same, add to the end result & increment the index
commonPaths.push(splitPaths[0][splitIndex])
splitIndex++
}
return path.join(...commonPaths)
}
} }

View File

@ -1,18 +1,35 @@
import { URL } from 'url' import core from '@actions/core';
import { NoFileOption } from './NoFileOption' import { NoFileOption } from './NoFileOption';
export interface Inputs { export class Inputs {
readonly ArtifactName: string static get ArtifactName(): string {
return core.getInput("name");
}
readonly ArtifactPath: string static get ArtifactPath(): string {
return core.getInput("path");
}
readonly Endpoint: URL static get Retention(): string {
return core.getInput("retention-days");
}
readonly Username: string static get Endpoint(): string {
return core.getInput("nextcloud-url");
}
readonly Password: string static get NoFileBehvaior(): NoFileOption {
const notFoundAction = core.getInput("if-no-files-found");
const noFileBehavior: NoFileOption = NoFileOption[notFoundAction as keyof typeof NoFileOption];
readonly Token: string if (!noFileBehavior) {
core.setFailed(
`Unrecognized ${"ifNoFilesFound"} input. Provided: ${notFoundAction}. Available options: ${Object.keys(
NoFileOption
)}`
);
}
readonly NoFileBehvaior: NoFileOption return noFileBehavior;
}
} }

View File

@ -1,16 +1,16 @@
export enum NoFileOption { export enum NoFileOption {
/** /**
* Default. Output a warning but do not fail the action * Default. Output a warning but do not fail the action
*/ */
warn = 'warn', warn = 'warn',
/** /**
* Fail the action with an error message * Fail the action with an error message
*/ */
error = 'error', error = 'error',
/** /**
* 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',
} }

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { Inputs } from './Inputs';
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact';
var artifact = new NextcloudArtifact(Inputs.ArtifactName, Inputs.ArtifactPath, Inputs.NoFileBehvaior);
artifact.run();

View File

@ -1,15 +0,0 @@
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact'
import * as core from '@actions/core'
import { ActionInputs } from './ActionInputs'
async function run() {
try {
const artifact = new NextcloudArtifact(new ActionInputs())
await artifact.run()
core.info('Finished')
} catch (error) {
core.setFailed(error.message)
}
}
run()

View File

@ -1,155 +1,64 @@
import * as core from '@actions/core' import core from '@actions/core';
import * as github from '@actions/github' import { FileFinder } from '../FileFinder';
import { GitHub } from '@actions/github/lib/utils' import { Inputs } from '../Inputs';
import { NextcloudClient } from './NextcloudClient';
import { FileFinder } from '../FileFinder' import { NoFileOption } from '../NoFileOption';
import { Inputs } from '../Inputs'
import { NextcloudClient } from './NextcloudClient'
import { NoFileOption } from '../NoFileOption'
export class NextcloudArtifact { export class NextcloudArtifact {
readonly octokit: InstanceType<typeof GitHub> public constructor(
readonly context = NextcloudArtifact.getCheckRunContext() private name: string,
readonly token: string private path: string,
readonly name: string private errorBehavior: NoFileOption) { }
readonly artifactTitle: string
readonly path: string
readonly errorBehavior: NoFileOption
constructor(private inputs: Inputs) { public async run() {
this.token = inputs.Token const fileFinder = new FileFinder(this.path);
this.name = inputs.ArtifactName const files = await fileFinder.findFiles();
this.artifactTitle = `Nextcloud - ${this.name}`
this.path = inputs.ArtifactPath
this.errorBehavior = inputs.NoFileBehvaior
this.name = inputs.ArtifactName
this.octokit = github.getOctokit(this.token)
}
async run() { if (files.filesToUpload.length > 0) {
const fileFinder = new FileFinder(this.path) await this.uploadFiles(files);
const files = await fileFinder.findFiles() }
else {
if (files.filesToUpload.length > 0) { this.logNoFilesFound();
await this.uploadFiles(files) }
} else {
this.logNoFilesFound()
}
}
private static getCheckRunContext(): { sha: string; runId: number } {
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 runId = github.context.runId private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string; }) {
if (github.context.payload.pull_request) { this.logUpload(files.filesToUpload.length, files.rootDirectory);
core.info(`Action was triggered by ${github.context.eventName}: using SHA from head of source branch`)
const pr = github.context.payload.pull_request const client = new NextcloudClient(Inputs.Endpoint, this.name, files.rootDirectory);
return { sha: pr.head.sha, runId }
await client.uploadFiles(files.filesToUpload);
} }
return { sha: github.context.sha, runId } private logUpload(fileCount: number, rootDirectory: string) {
} const s = fileCount === 1 ? '' : 's';
core.info(
`With the provided path, there will be ${fileCount} file${s} uploaded`
);
core.debug(`Root artifact directory is ${rootDirectory}`);
private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string }) { if (fileCount > 10000) {
this.logUpload(files.filesToUpload.length, files.rootDirectory) core.warning(
const createResp = await this.octokit.rest.checks.create({ `There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.`
head_sha: this.context.sha, );
name: this.artifactTitle, }
status: 'in_progress',
output: {
title: `Nextcloud - ${this.name}`,
summary: 'Uploading...'
},
...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)
core.setOutput('SHAREABLE_URL', shareableUrl)
core.info(`Nextcloud shareable URL: ${shareableUrl}`)
const resp = await this.octokit.rest.checks.update({
check_run_id: createResp.data.id,
conclusion: 'success',
status: 'completed',
output: {
title: this.artifactTitle,
summary: 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.trySetFailed(createResp.data.id)
core.setFailed(error)
} }
}
private async trySetFailed(checkId: number) { private logNoFilesFound() {
try { const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`;
await this.octokit.rest.checks.update({ switch (this.errorBehavior) {
check_run_id: checkId, case NoFileOption.warn: {
conclusion: 'failure', core.warning(errorMessage);
status: 'completed', break;
output: { }
title: this.artifactTitle, case NoFileOption.error: {
summary: 'Check failed.' core.setFailed(errorMessage);
}, break;
...github.context.repo }
}) case NoFileOption.ignore: {
return true core.info(errorMessage);
} catch (error) { break;
core.error(`Failed to update check status to failure`) }
return false }
} }
}
private logUpload(fileCount: number, rootDirectory: string) {
const s = fileCount === 1 ? '' : 's'
core.info(`With the provided path, there will be ${fileCount} file${s} uploaded`)
core.debug(`Root artifact directory is ${rootDirectory}`)
if (fileCount > 10000) {
core.warning(
`There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.`
)
}
}
private logNoFilesFound() {
const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`
switch (this.errorBehavior) {
case NoFileOption.warn: {
core.warning(errorMessage)
break
}
case NoFileOption.error: {
core.setFailed(errorMessage)
break
}
case NoFileOption.ignore: {
core.info(errorMessage)
break
}
}
}
} }

View File

@ -1,167 +1,132 @@
import * as fsSync from 'fs' import * as fsSync from 'fs'
import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import * as core from '@actions/core' import core from '@actions/core';
import * as os from 'os' import * as os from 'os';
import * as archiver from 'archiver' import { randomUUID } from 'crypto';
import fetch, { HeadersInit } from 'node-fetch' import * as archiver from 'archiver';
import btoa from 'btoa' import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid' import fetch from 'node-fetch';
import * as webdav from 'webdav'
import { URL } from 'url'
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 public constructor(
private headers: HeadersInit private endpoint: string,
private davClient private artifact: string,
private rootDirectory: string) { }
constructor( public async uploadFiles(files: string[]) {
private endpoint: URL, const spec = this.uploadSpec(files);
private artifact: string, var zip = await this.zipFiles(spec);
private rootDirectory: string, await this.upload(zip);
private username: string, }
private password: string
) {
this.guid = uuidv4()
this.headers = { Authorization: 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64') }
this.davClient = webdav.createClient(`${this.endpoint.href}remote.php/dav/files/${this.username}`, {
username: this.username,
password: this.password,
maxBodyLength: 1024 ** 3
})
}
async uploadFiles(files: string[]): Promise<string> { private uploadSpec(files: string[]): FileSpec[] {
core.info('Preparing upload...') const specifications = [];
const spec = this.uploadSpec(files) if (!fsSync.existsSync(this.rootDirectory)) {
core.info('Zipping files...') throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`);
const zip = await this.zipFiles(spec) }
try { if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) {
core.info('Uploading to Nextcloud...') throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`);
const filePath = await this.upload(zip) }
core.info(`Remote file path: ${filePath}`) // Normalize and resolve, this allows for either absolute or relative paths to be used
return await this.shareFile(filePath) let root = path.normalize(this.rootDirectory);
} finally { root = path.resolve(root);
await fs.unlink(zip) /*
} Example to demonstrate behavior
}
private uploadSpec(files: string[]): FileSpec[] { Input:
const specifications = [] artifactName: my-artifact
if (!fsSync.existsSync(this.rootDirectory)) { rootDirectory: '/home/user/files/plz-upload'
throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`) artifactFiles: [
'/home/user/files/plz-upload/file1.txt',
'/home/user/files/plz-upload/file2.txt',
'/home/user/files/plz-upload/dir/file3.txt'
]
Output:
specifications: [
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file1.txt'],
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file2.txt'],
['/home/user/files/plz-upload/file1.txt', 'my-artifact/dir/file3.txt']
]
*/
for (let file of files) {
if (!fsSync.existsSync(file)) {
throw new Error(`File ${file} does not exist`);
}
if (!fsSync.lstatSync(file).isDirectory()) {
// Normalize and resolve, this allows for either absolute or relative paths to be used
file = path.normalize(file);
file = path.resolve(file);
if (!file.startsWith(root)) {
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
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
be saved in the same container. The artifact name is used as the root directory in the container to separate and distinguish uploaded artifacts
path.join handles all the following cases and would return 'artifact-name/file-to-upload.txt
join('artifact-name/', 'file-to-upload.txt')
join('artifact-name/', '/file-to-upload.txt')
join('artifact-name', 'file-to-upload.txt')
join('artifact-name', '/file-to-upload.txt')
*/
specifications.push({
absolutePath: file,
uploadPath: path.join(this.artifact, uploadPath)
});
}
else {
// Directories are rejected by the server during upload
core.debug(`Removing ${file} from rawSearchResults because it is a directory`);
}
}
return specifications;
} }
if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) {
throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`)
} private async zipFiles(specs: FileSpec[]): Promise<string> {
let root = path.normalize(this.rootDirectory) const tempArtifactDir = path.join(os.tmpdir(), randomUUID());
root = path.resolve(root) const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`);
for (let file of files) { await fs.mkdir(artifactPath, { recursive: true });
if (!fsSync.existsSync(file)) { for (let spec of specs) {
throw new Error(`File ${file} does not exist`) await fs.copyFile(spec.absolutePath, path.join(artifactPath, spec.uploadPath));
}
if (!fsSync.lstatSync(file).isDirectory()) {
file = path.normalize(file)
file = path.resolve(file)
if (!file.startsWith(root)) {
throw new Error(`The rootDirectory: ${root} is not a parent directory of the file: ${file}`)
} }
const uploadPath = file.replace(root, '') const archivePath = path.join(artifactPath, `${this.artifact}.zip`);
specifications.push({ await this.zip(path.join(artifactPath, this.artifact), archivePath);
absolutePath: file,
uploadPath: path.join(this.artifact, uploadPath)
})
} else {
core.debug(`Removing ${file} from rawSearchResults because it is a directory`)
}
}
return specifications
}
private async zipFiles(specs: FileSpec[]): Promise<string> { return archivePath;
const tempArtifactDir = path.join(os.tmpdir(), this.guid)
const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`)
await fs.mkdir(path.join(artifactPath, this.artifact), { recursive: true })
const copies = []
for (const spec of specs) {
const dstpath = path.join(artifactPath, spec.uploadPath)
const dstDir = path.dirname(dstpath)
if (!fsSync.existsSync(dstDir)) {
await fs.mkdir(dstDir, { recursive: true })
}
copies.push(fs.copyFile(spec.absolutePath, dstpath))
} }
await Promise.all(copies) private async zip(dirpath: string, destpath: string) {
core.info(`files: ${await fs.readdir(path.join(artifactPath, this.artifact))}`) const archive = archiver.create('zip', { zlib: { level: 9 } });
const stream = fsSync.createWriteStream(destpath);
archive.directory(dirpath, false)
.on('error', e => Promise.reject())
.on('close', () => Promise.resolve())
.pipe(stream);
const archivePath = path.join(artifactPath, `${this.artifact}.zip`) return archive.finalize();
await this.zip(path.join(artifactPath, this.artifact), archivePath)
return archivePath
}
private async zip(dirpath: string, destpath: string) {
const archive = archiver.create('zip', { zlib: { level: 9 } })
const stream = archive.directory(dirpath, false).pipe(fsSync.createWriteStream(destpath))
await archive.finalize()
return await new Promise<void>((resolve, reject) => {
stream.on('error', e => reject(e)).on('close', () => resolve())
})
}
private async upload(file: string): Promise<string> {
const remoteFileDir = `/artifacts/${this.guid}`
if (!(await this.davClient.exists(remoteFileDir))) {
await this.davClient.createDirectory(remoteFileDir, { recursive: true })
} }
const remoteFilePath = `${remoteFileDir}/${this.artifact}.zip` private async upload(file: string) {
core.debug(`Transferring file... (${file})`) const url = new URL(this.endpoint, '/remote.php/dav/files/user/path/to/file');
const stream = fsSync.createReadStream(file);
await this.davClient.putFileContents(remoteFilePath, await fs.readFile(file)) await fetch(url.href, {
method: 'PUT',
return remoteFilePath body: stream
} });
private async shareFile(remoteFilePath: string): Promise<string> {
const url = `${this.endpoint.href}ocs/v2.php/apps/files_sharing/api/v1/shares`
const body = {
path: remoteFilePath,
shareType: 3,
publicUpload: 'false',
permissions: 1
} }
const res = await fetch(url, { private shareFile() {
method: 'POST',
headers: Object.assign(this.headers, {
'OCS-APIRequest': true,
'Content-Type': 'application/json'
}),
body: JSON.stringify(body)
})
const result = await res.text()
core.debug(`Share response: ${result}`)
const re = /<url>(?<share_url>.*)<\/url>/
const match = re.exec(result)
core.debug(`Match groups:\n${JSON.stringify(match?.groups)}`)
const sharableUrl = (match?.groups || {})['share_url']
if (!sharableUrl) {
throw new Error(`Failed to parse or find sharable URL:\n${result}`)
} }
return sharableUrl
}
} }

View File

@ -1,16 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"strict": true, /* Enable all strict type-checking options. */ "strict": true, /* Enable all strict type-checking options. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"skipLibCheck": true, /* Skip type checking of declaration files. */ "skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"noImplicitAny": true,
"rootDir": "src", "rootDir": "src",
"outDir": "lib", "outDir": "dist",
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019"] "lib": ["es6"]
}, }
"exclude": ["node_modules", "__tests__/**/*.ts"]
} }

View File

@ -1,8 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}