Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add File Commands #571

Merged
merged 6 commits into from
Sep 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 66 additions & 32 deletions docs/commands.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
}