diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..ea4d4ad1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules/ +packages/*/node_modules/ +packages/*/lib/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index db3c608d..241986aa 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,11 +10,12 @@ "no-unused-vars": "off", "eslint-comments/no-use": "off", "import/no-namespace": "off", - "@typescript-eslint/no-unused-vars": "error" + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}] }, "env": { "node": true, "es6": true, "jest/globals": true } -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..ea4d4ad1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules/ +packages/*/node_modules/ +packages/*/lib/ \ No newline at end of file diff --git a/docs/package-specs.md b/docs/package-specs.md index 21861fb0..b318251e 100644 --- a/docs/package-specs.md +++ b/docs/package-specs.md @@ -47,10 +47,16 @@ export interface InputOptions { export function getInput(name: string, options?: InputOptions): string | undefined /** - * fail the action + * sets the status of the action to neutral * @param message */ -export function setFailure(message: string): void +export function setNeutral(message: string): void + +/** + * sets the status of the action to failed + * @param message + */ +export function setFailed(message: string): void ``` ### IO spec diff --git a/package.json b/package.json index e4cd4f30..da897d50 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "bootstrap": "lerna bootstrap", "build": "lerna run tsc", "check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build -- -- --noEmit\"", - "format": "prettier --write packages/*/src/**/*.ts", - "format-check": "prettier --check packages/*/src/**/*.ts", - "lint": "eslint packages/*/src/**/*.ts", + "format": "prettier --write packages/**/*.ts", + "format-check": "prettier --check packages/**/*.ts", + "lint": "eslint packages/**/*.ts", "new-package": "scripts/create-package", "test": "jest" }, diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..d5bf5bab --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,7 @@ +# `@actions/core` + +> Core functions for setting results, logging, registering secrets and exporting variables across actions + +## Usage + +See [src/core.ts](src/core.ts). diff --git a/packages/core/__tests__/lib.test.ts b/packages/core/__tests__/lib.test.ts new file mode 100644 index 00000000..a21b51e1 --- /dev/null +++ b/packages/core/__tests__/lib.test.ts @@ -0,0 +1,159 @@ +import * as os from 'os' +import * as core from '../src/core' + +const testEnvVars = { + 'my var': '', + 'special char var \r\n];': '', + 'my var2': '', + 'my secret': '', + 'special char secret \r\n];': '', + 'my secret2': '', + + // Set inputs + INPUT_MY_INPUT: 'val', + INPUT_MISSING: '', + 'INPUT_SPECIAL_CHARS_\'\t"\\': '\'\t"\\ repsonse ' +} + +describe('@actions/core', () => { + beforeEach(() => { + for (const key in testEnvVars) + process.env[key] = testEnvVars[key as keyof typeof testEnvVars] + + process.stdout.write = jest.fn() + }) + + afterEach(() => { + for (const key in testEnvVars) Reflect.deleteProperty(testEnvVars, key) + }) + + it('exportVariable produces the correct command and sets the env', () => { + core.exportVariable('my var', 'var val') + assertWriteCalls([`##[set-env name=my var;]var val${os.EOL}`]) + }) + + it('exportVariable escapes variable names', () => { + core.exportVariable('special char var \r\n];', 'special val') + expect(process.env['special char var \r\n];']).toBe('special val') + assertWriteCalls([ + `##[set-env name=special char var %0D%0A%5D%3B;]special val${os.EOL}` + ]) + }) + + it('exportVariable escapes variable values', () => { + core.exportVariable('my var2', 'var val\r\n') + expect(process.env['my var2']).toBe('var val\r\n') + assertWriteCalls([`##[set-env name=my var2;]var val%0D%0A${os.EOL}`]) + }) + + it('exportSecret produces the correct commands and sets the env', () => { + core.exportSecret('my secret', 'secret val') + expect(process.env['my secret']).toBe('secret val') + assertWriteCalls([ + `##[set-env name=my secret;]secret val${os.EOL}`, + `##[set-secret]secret val${os.EOL}` + ]) + }) + + it('exportSecret escapes secret names', () => { + core.exportSecret('special char secret \r\n];', 'special secret val') + expect(process.env['special char secret \r\n];']).toBe('special secret val') + assertWriteCalls([ + `##[set-env name=special char secret %0D%0A%5D%3B;]special secret val${ + os.EOL + }`, + `##[set-secret]special secret val${os.EOL}` + ]) + }) + + it('exportSecret escapes secret values', () => { + core.exportSecret('my secret2', 'secret val\r\n') + expect(process.env['my secret2']).toBe('secret val\r\n') + assertWriteCalls([ + `##[set-env name=my secret2;]secret val%0D%0A${os.EOL}`, + `##[set-secret]secret val%0D%0A${os.EOL}` + ]) + }) + + it('getInput gets non-required input', () => { + expect(core.getInput('my input')).toBe('val') + }) + + it('getInput gets required input', () => { + expect(core.getInput('my input', {required: true})).toBe('val') + }) + + it('getInput throws on missing required input', () => { + expect(() => core.getInput('missing', {required: true})).toThrow( + 'Input required and not supplied: missing' + ) + }) + + it('getInput doesnt throw on missing non-required input', () => { + expect(core.getInput('missing', {required: false})).toBe('') + }) + + it('getInput is case insensitive', () => { + expect(core.getInput('My InPuT')).toBe('val') + }) + + it('getInput handles special characters', () => { + expect(core.getInput('special chars_\'\t"\\')).toBe('\'\t"\\ repsonse') + }) + + it('setNeutral sets the correct exit code', () => { + core.setFailed('Failure message') + expect(process.exitCode).toBe(1) + }) + + it('setFailure sets the correct exit code and failure message', () => { + core.setFailed('Failure message') + expect(process.exitCode).toBe(1) + assertWriteCalls([`##[error]Failure message${os.EOL}`]) + }) + + it('setFailure escapes the failure message', () => { + core.setFailed('Failure \r\n\nmessage\r') + expect(process.exitCode).toBe(1) + assertWriteCalls([`##[error]Failure %0D%0A%0Amessage%0D${os.EOL}`]) + }) + + it('error sets the correct error message', () => { + core.error('Error message') + assertWriteCalls([`##[error]Error message${os.EOL}`]) + }) + + it('error escapes the error message', () => { + core.error('Error message\r\n\n') + assertWriteCalls([`##[error]Error message%0D%0A%0A${os.EOL}`]) + }) + + it('warning sets the correct message', () => { + core.warning('Warning') + assertWriteCalls([`##[warning]Warning${os.EOL}`]) + }) + + it('warning escapes the message', () => { + core.warning('\r\nwarning\n') + assertWriteCalls([`##[warning]%0D%0Awarning%0A${os.EOL}`]) + }) + + it('debug sets the correct message', () => { + core.debug('Debug') + assertWriteCalls([`##[debug]Debug${os.EOL}`]) + }) + + it('debug escapes the message', () => { + core.debug('\r\ndebug\n') + assertWriteCalls([`##[debug]%0D%0Adebug%0A${os.EOL}`]) + }) +}) + +// Assert that process.stdout.write calls called only with the given arguments. +function assertWriteCalls(calls: string[]) { + expect(process.stdout.write).toHaveBeenCalledTimes(calls.length) + + for (let i = 0; i < calls.length; i++) { + expect(process.stdout.write).toHaveBeenNthCalledWith(i + 1, calls[i]) + } +} diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json new file mode 100644 index 00000000..fef7996a --- /dev/null +++ b/packages/core/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@actions/core", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz", + "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", + "dev": true + } + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..df9eb6b7 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,40 @@ +{ + "name": "@actions/core", + "version": "0.1.0", + "description": "Actions core lib", + "keywords": [ + "core", + "actions" + ], + "author": "Bryan MacFarlane ", + "homepage": "https://github.com/actions/toolkit/tree/master/packages/core", + "license": "MIT", + "main": "lib/core.js", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "tsc": "tsc" + }, + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "devDependencies": { + "@types/node": "^12.0.2" + }, + "dependencies": { + "@actions/exit": "^0.0.0" + } +} diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts new file mode 100644 index 00000000..b277bfb2 --- /dev/null +++ b/packages/core/src/command.ts @@ -0,0 +1,87 @@ +import * as os from 'os' + +// For internal use, subject to change. + +/** + * Commands + * + * Command Format: + * ##[name key=value;key=value]message + * + * Examples: + * ##[warning]This is the user warning message + * ##[set-secret name=mypassword]definatelyNotAPassword! + */ +export function issueCommand( + command: string, + properties: any, + message: string +) { + const cmd = new Command(command, properties, message) + process.stdout.write(cmd.toString() + os.EOL) +} + +export function issue(name: string, message: string) { + issueCommand(name, {}, message) +} + +const CMD_PREFIX = '##[' + +class Command { + constructor( + command: string, + properties: {[key: string]: string}, + message: string + ) { + if (!command) { + command = 'missing.command' + } + + this.command = command + this.properties = properties + this.message = message + } + + command: string + message: string + properties: {[key: string]: string} + + toString() { + let cmdStr = CMD_PREFIX + this.command + + if (this.properties && Object.keys(this.properties).length > 0) { + cmdStr += ' ' + for (const key in this.properties) { + if (this.properties.hasOwnProperty(key)) { + const val = this.properties[key] + if (val) { + // safely append the val - avoid blowing up when attempting to + // call .replace() if message is not a string for some reason + cmdStr += `${key}=${escape(`${val || ''}`)};` + } + } + } + } + + cmdStr += ']' + + // safely append the message - avoid blowing up when attempting to + // call .replace() if message is not a string for some reason + const message: string = `${this.message || ''}` + cmdStr += escapeData(message) + + return cmdStr + } +} + +function escapeData(s: string): string { + return s.replace(/\r/g, '%0D').replace(/\n/g, '%0A') +} + +function escape(s: string): string { + return s + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/]/g, '%5D') + .replace(/;/g, '%3B') +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts new file mode 100644 index 00000000..408b9ad1 --- /dev/null +++ b/packages/core/src/core.ts @@ -0,0 +1,100 @@ +import {ExitCode} from '@actions/exit' +import {issue, issueCommand} from './command' + +/** + * Interface for getInput options + */ +export interface InputOptions { + /** Optional. Whether the input is required. If required and not present, will throw. Defaults to false */ + required?: boolean +} + +//----------------------------------------------------------------------- +// Variables +//----------------------------------------------------------------------- + +/** + * sets env variable for this action and future actions in the job + * @param name the name of the variable to set + * @param val the value of the variable + */ +export function exportVariable(name: string, val: string) { + process.env[name] = val + issueCommand('set-env', {name}, val) +} + +/** + * exports the variable and registers a secret which will get masked from logs + * @param name the name of the variable to set + * @param val value of the secret + */ +export function exportSecret(name: string, val: string) { + exportVariable(name, val) + issueCommand('set-secret', {}, val) +} + +/** + * Gets the value of an input. The value is also trimmed. + * + * @param name name of the input to get + * @param options optional. See InputOptions. + * @returns string + */ +export function getInput(name: string, options?: InputOptions): string { + const val: string = + process.env[`INPUT_${name.replace(' ', '_').toUpperCase()}`] || '' + if (options && options.required && !val) { + throw new Error(`Input required and not supplied: ${name}`) + } + + return val.trim() +} + +//----------------------------------------------------------------------- +// Results +//----------------------------------------------------------------------- + +/** + * Sets the action status to neutral + */ +export function setNeutral() { + process.exitCode = ExitCode.Neutral +} + +/** + * Sets the action status to failed. + * When the action exits it will be with an exit code of 1 + * @param message add error issue message + */ +export function setFailed(message: string) { + process.exitCode = ExitCode.Failure + error(message) +} + +//----------------------------------------------------------------------- +// Logging Commands +//----------------------------------------------------------------------- + +/** + * Writes debug message to user log + * @param message debug message + */ +export function debug(message: string) { + issueCommand('debug', {}, message) +} + +/** + * Adds an error issue + * @param message error issue message + */ +export function error(message: string) { + issue('error', message) +} + +/** + * Adds an warning issue + * @param message warning issue message + */ +export function warning(message: string) { + issue('warning', message) +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..a8b812a6 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file diff --git a/packages/exit/README.md b/packages/exit/README.md index c54d04da..cbbe0eef 100644 --- a/packages/exit/README.md +++ b/packages/exit/README.md @@ -4,8 +4,4 @@ ## Usage -``` -const exit = require('@actions/exit'); - -// TODO: DEMONSTRATE API -``` +See [src/exit.ts](src/exit.ts). \ No newline at end of file diff --git a/packages/toolkit/README.md b/packages/toolkit/README.md index 1e2abbd7..fa5277da 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -4,8 +4,4 @@ ## Usage -``` -const github = require('@actions/toolkit'); - -// TODO: DEMONSTRATE API -``` +See [src/toolkit.ts](src/toolkit.ts). \ No newline at end of file