Skip to content

Commit

Permalink
Add File Commands (#571)
Browse files Browse the repository at this point in the history
* Add File Commands

* pr updates w/ feedback

* run format

* fix lint/format

* slight update with an example in the docs

* pr feedback
  • Loading branch information
thboop committed Sep 23, 2020
1 parent da34bfb commit 0759cdc
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 60 deletions.
98 changes: 66 additions & 32 deletions docs/commands.md
Expand Up @@ -7,37 +7,6 @@ these things in a script or other tool.
To allow this, we provide a special `::` syntax which, if logged to `stdout` on a new line, will allow the runner to perform special behavior on
your commands. The following commands are all supported:

### Set an environment variable

To set an environment variable for future out of process steps, use `::set-env`:

```sh
echo "::set-env name=FOO::BAR"
```

Running `$FOO` in a future step will now return `BAR`

This is wrapped by the core exportVariable method which sets for future steps but also updates the variable for this step

```javascript
export function exportVariable(name: string, val: string): void {}
```

### PATH Manipulation

To prepend a string to PATH, use `::addPath`:

```sh
echo "::add-path::BAR"
```

Running `$PATH` in a future step will now return `BAR:{Previous Path}`;

This is wrapped by the core addPath method:
```javascript
export function addPath(inputPath: string): void {}
```

### Set outputs

To set an output for the step, use `::set-output`:
Expand Down Expand Up @@ -155,8 +124,73 @@ function setCommandEcho(enabled: boolean): void {}

The `add-mask`, `debug`, `warning` and `error` commands do not support echoing.

### Command Prompt
### Command Prompt

CMD processes the `"` character differently from other shells when echoing. In CMD, the above snippets should have the `"` characters removed in order to correctly process. For example, the set output command would be:
```cmd
echo ::set-output name=FOO::BAR
```


# Environment files

During the execution of a workflow, the runner generates temporary files that can be used to perform certain actions. The path to these files are exposed via environment variables. You will need to use the `utf-8` encoding when writing to these files to ensure proper processing of the commands. Multiple commands can be written to the same file, separated by newlines.

### Set an environment variable

To set an environment variable for future out of process steps, write to the file located at `GITHUB_ENV` or use the equivalent `actions/core` function

```sh
echo "FOO=BAR" >> $GITHUB_ENV
```

Running `$FOO` in a future step will now return `BAR`

For multiline strings, you may use a heredoc style syntax with your choice of delimeter. In the below example, we use `EOF`
```
steps:
- name: Set the value
id: step_one
run: |
echo 'JSON_RESPONSE<<EOF' >> $GITHUB_ENV
curl https://httpbin.org/json >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
```

This would set the value of the `JSON_RESPONSE` env variable to the value of the curl response.

The expected syntax for the heredoc style is:
```
{VARIABLE_NAME}<<{DELIMETER}
{VARIABLE_VALUE}
{DELIMETER}
```

This is wrapped by the core `exportVariable` method which sets for future steps but also updates the variable for this step.

```javascript
export function exportVariable(name: string, val: string): void {}
```

### PATH Manipulation

To prepend a string to PATH write to the file located at `GITHUB_PATH` or use the equivalent `actions/core` function

```sh
echo "/Users/test/.nvm/versions/node/v12.18.3/bin" >> $GITHUB_PATH
```

Running `$PATH` in a future step will now return `/Users/test/.nvm/versions/node/v12.18.3/bin:{Previous Path}`;

This is wrapped by the core addPath method:
```javascript
export function addPath(inputPath: string): void {}
```

### Powershell

Powershell does not use UTF8 by default. You will want to make sure you write in the correct encoding. For example, to set the path:
```
steps:
- run: echo "mypath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8
```
90 changes: 78 additions & 12 deletions packages/core/__tests__/core.test.ts
@@ -1,3 +1,4 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as core from '../src/core'
Expand All @@ -20,56 +21,103 @@ const testEnvVars = {
INPUT_MULTIPLE_SPACES_VARIABLE: 'I have multiple spaces',

// Save inputs
STATE_TEST_1: 'state_val'
STATE_TEST_1: 'state_val',

// File Commands
GITHUB_PATH: '',
GITHUB_ENV: ''
}

describe('@actions/core', () => {
beforeAll(() => {
const filePath = path.join(__dirname, `test`)
if (!fs.existsSync(filePath)) {
fs.mkdirSync(filePath)
}
})

beforeEach(() => {
for (const key in testEnvVars)
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', () => {
it('legacy 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', () => {
it('legacy 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%2C%3A::special val${os.EOL}`
])
})

it('exportVariable escapes variable values', () => {
it('legacy 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('exportVariable handles boolean inputs', () => {
it('legacy exportVariable handles boolean inputs', () => {
core.exportVariable('my var', true)
assertWriteCalls([`::set-env name=my var::true${os.EOL}`])
})

it('exportVariable handles number inputs', () => {
it('legacy exportVariable handles number inputs', () => {
core.exportVariable('my var', 5)
assertWriteCalls([`::set-env name=my var::5${os.EOL}`])
})

it('exportVariable produces the correct command and sets the env', () => {
const command = 'ENV'
createFileCommandFile(command)
core.exportVariable('my var', 'var val')
verifyFileCommand(
command,
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}var val${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
)
})

it('exportVariable handles boolean inputs', () => {
const command = 'ENV'
createFileCommandFile(command)
core.exportVariable('my var', true)
verifyFileCommand(
command,
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}true${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
)
})

it('exportVariable handles number inputs', () => {
const command = 'ENV'
createFileCommandFile(command)
core.exportVariable('my var', 5)
verifyFileCommand(
command,
`my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}5${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
)
})

it('setSecret produces the correct command', () => {
core.setSecret('secret val')
assertWriteCalls([`::add-mask::secret val${os.EOL}`])
})

it('prependPath produces the correct commands and sets the env', () => {
const command = 'PATH'
createFileCommandFile(command)
core.addPath('myPath')
expect(process.env['PATH']).toBe(
`myPath${path.delimiter}path1${path.delimiter}path2`
)
verifyFileCommand(command, `myPath${os.EOL}`)
})

it('legacy prependPath produces the correct commands and sets the env', () => {
core.addPath('myPath')
expect(process.env['PATH']).toBe(
`myPath${path.delimiter}path1${path.delimiter}path2`
Expand Down Expand Up @@ -259,3 +307,21 @@ function assertWriteCalls(calls: string[]): void {
expect(process.stdout.write).toHaveBeenNthCalledWith(i + 1, calls[i])
}
}

function createFileCommandFile(command: string): void {
const filePath = path.join(__dirname, `test/${command}`)
process.env[`GITHUB_${command}`] = filePath
fs.appendFileSync(filePath, '', {
encoding: 'utf8'
})
}

function verifyFileCommand(command: string, expectedContents: string): void {
const filePath = path.join(__dirname, `test/${command}`)
const contents = fs.readFileSync(filePath, 'utf8')
try {
expect(contents).toEqual(expectedContents)
} finally {
fs.unlinkSync(filePath)
}
}
14 changes: 1 addition & 13 deletions packages/core/src/command.ts
@@ -1,4 +1,5 @@
import * as os from 'os'
import {toCommandValue} from './utils'

// For internal use, subject to change.

Expand Down Expand Up @@ -76,19 +77,6 @@ class Command {
}
}

/**
* Sanitizes an input into a string so it can be passed into issueCommand safely
* @param input input to sanitize into a string
*/
export function toCommandValue(input: any): string {
if (input === null || input === undefined) {
return ''
} else if (typeof input === 'string' || input instanceof String) {
return input as string
}
return JSON.stringify(input)
}

function escapeData(s: any): string {
return toCommandValue(s)
.replace(/%/g, '%25')
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/core.ts
@@ -1,4 +1,6 @@
import {issue, issueCommand, toCommandValue} from './command'
import {issue, issueCommand} from './command'
import {issueCommand as issueFileCommand} from './file-command'
import {toCommandValue} from './utils'

import * as os from 'os'
import * as path from 'path'
Expand Down Expand Up @@ -39,7 +41,15 @@ export enum ExitCode {
export function exportVariable(name: string, val: any): void {
const convertedVal = toCommandValue(val)
process.env[name] = convertedVal
issueCommand('set-env', {name}, convertedVal)

const filePath = process.env['GITHUB_ENV'] || ''
if (filePath) {
const delimiter = '_GitHubActionsFileCommandDelimeter_'
const commandValue = `${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}`
issueFileCommand('ENV', commandValue)
} else {
issueCommand('set-env', {name}, convertedVal)
}
}

/**
Expand All @@ -55,7 +65,12 @@ export function setSecret(secret: string): void {
* @param inputPath
*/
export function addPath(inputPath: string): void {
issueCommand('add-path', {}, inputPath)
const filePath = process.env['GITHUB_PATH'] || ''
if (filePath) {
issueFileCommand('PATH', inputPath)
} else {
issueCommand('add-path', {}, inputPath)
}
process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`
}

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/file-command.ts
@@ -0,0 +1,24 @@
// For internal use, subject to change.

// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */

import * as fs from 'fs'
import * as os from 'os'
import {toCommandValue} from './utils'

export function issueCommand(command: string, message: any): void {
const filePath = process.env[`GITHUB_${command}`]
if (!filePath) {
throw new Error(
`Unable to find environment variable for file command ${command}`
)
}
if (!fs.existsSync(filePath)) {
throw new Error(`Missing file at path: ${filePath}`)
}

fs.appendFileSync(filePath, `${toCommandValue(message)}${os.EOL}`, {
encoding: 'utf8'
})
}
15 changes: 15 additions & 0 deletions packages/core/src/utils.ts
@@ -0,0 +1,15 @@
// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
* Sanitizes an input into a string so it can be passed into issueCommand safely
* @param input input to sanitize into a string
*/
export function toCommandValue(input: any): string {
if (input === null || input === undefined) {
return ''
} else if (typeof input === 'string' || input instanceof String) {
return input as string
}
return JSON.stringify(input)
}

0 comments on commit 0759cdc

Please sign in to comment.