├── .gitignore ├── src ├── action │ ├── mod.rs │ └── wait.rs └── main.rs ├── run ├── wait │ ├── index.js │ └── action.yml └── index.ts ├── crates ├── toolkit │ ├── src │ │ └── lib.rs │ ├── README.md │ └── Cargo.toml └── core │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── util.rs │ ├── logger.rs │ ├── lib.rs │ └── core.rs ├── .rustfmt.toml ├── .editorconfig ├── package.json ├── Cargo.toml ├── tsconfig.json ├── LICENSE ├── README.md ├── Cargo.lock └── .github └── workflows └── release.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /src/action/mod.rs: -------------------------------------------------------------------------------- 1 | mod wait; 2 | 3 | pub use wait::*; 4 | -------------------------------------------------------------------------------- /run/wait/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../../dist'); 4 | -------------------------------------------------------------------------------- /crates/toolkit/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use actions_core as core; 2 | 3 | pub mod prelude { 4 | pub use crate::core::*; 5 | } 6 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | indent_style = "Block" 3 | imports_indent = "Block" 4 | imports_layout = "HorizontalVertical" 5 | max_width = 80 6 | reorder_imports = true 7 | -------------------------------------------------------------------------------- /run/wait/action.yml: -------------------------------------------------------------------------------- 1 | name: '@kjvalencik/actions/run/wait' 2 | description: Waits for a specified number of milliseconds 3 | author: K.J. Valencik 4 | inputs: 5 | milliseconds: 6 | required: true 7 | description: Time to wait 8 | default: '1000' 9 | runs: 10 | using: node12 11 | main: index.js 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yaml] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.json] 17 | indent_style = tab 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /src/action/wait.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use actions_toolkit::core; 4 | use anyhow::{Context, Result}; 5 | 6 | pub fn wait() -> Result<()> { 7 | let ms = core::input("milliseconds") 8 | .context("milliseconds input required")? 9 | .parse() 10 | .context("invalid milliseconds")?; 11 | 12 | let ms = Duration::from_millis(ms); 13 | 14 | std::thread::sleep(ms); 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "npm run pack", 5 | "pack": "ncc build run/index.ts" 6 | }, 7 | "dependencies": { 8 | "@actions/core": "^1.9.1", 9 | "@actions/exec": "^1.0.3", 10 | "@actions/tool-cache": "^1.3.3", 11 | "toml": "^3.0.0" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^13.9.5", 15 | "@zeit/ncc": "^0.22.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actions-core" 3 | version = "0.0.2" 4 | authors = ["K.J. Valencik "] 5 | description = "Github Actions Core" 6 | homepage = "https://github.com/kjvalencik/actions/tree/master/crates/core" 7 | repository = "https://github.com/kjvalencik/actions.git" 8 | documentation = "https://docs.rs/actions-core/" 9 | readme = "README.md" 10 | license = "MIT" 11 | edition = "2018" 12 | workspace = "../.." 13 | 14 | [dependencies] 15 | uuid = { version = "0.8", features = ["v4"] } 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actions" 3 | version = "0.0.1" 4 | authors = ["K.J. Valencik "] 5 | description = "Github Actions Toolkit" 6 | homepage = "https://github.com/kjvalencik/actions" 7 | repository = "https://github.com/kjvalencik/actions.git" 8 | license = "MIT" 9 | publish = false 10 | edition = "2018" 11 | 12 | [dependencies] 13 | anyhow = "1" 14 | actions-toolkit = { version = "=0.0.2", path = "crates/toolkit" } 15 | 16 | [workspace] 17 | members = [ 18 | "crates/toolkit", 19 | "crates/core", 20 | ] 21 | -------------------------------------------------------------------------------- /crates/toolkit/README.md: -------------------------------------------------------------------------------- 1 | # actions-toolkit 2 | 3 | Inspired by the [Javascript Actions Toolkit], this crate provides a set of 4 | modules to make creating Github Actions easier. 5 | 6 | ## Crates 7 | 8 | ### [`actions-toolkit`][actions-toolkit] 9 | 10 | Meta crate that combines the most common actions crates into a single package. 11 | 12 | ### [`actions-core`][actions-core] 13 | 14 | Provides funcitons for inputs, outputs, logging, masking, and environment 15 | variables. 16 | 17 | [actions-core]: ../core 18 | [actions-toolkit]: . 19 | [js-actions-toolkit]: https://github.com/actions/toolkit 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": false, 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "sourceMap": true, 9 | "declaration": false, 10 | "declarationMap": false, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedParameters": true, 16 | "noUnusedLocals": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "lib": ["es2017", "esnext.asynciterable"], 19 | "types": ["node"] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/toolkit/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actions-toolkit" 3 | version = "0.0.2" 4 | authors = ["K.J. Valencik "] 5 | description = "Github Actions Toolkit" 6 | homepage = "https://github.com/kjvalencik/actions/tree/master/crates/toolkit" 7 | repository = "https://github.com/kjvalencik/actions.git" 8 | documentation = "https://docs.rs/actions-toolkit/" 9 | readme = "README.md" 10 | license = "MIT" 11 | edition = "2018" 12 | workspace = "../.." 13 | 14 | [dependencies] 15 | actions-core = { version = "=0.0.2", path = "../core" } 16 | uuid = { version = "0.8", features = ["v4"] } 17 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Context, Error, Result}; 2 | 3 | mod action; 4 | 5 | #[derive(Debug)] 6 | enum Command { 7 | Wait, 8 | } 9 | 10 | impl std::str::FromStr for Command { 11 | type Err = Error; 12 | 13 | fn from_str(s: &str) -> Result { 14 | let cmd = match s { 15 | "wait" => Command::Wait, 16 | _ => return Err(anyhow!("Invalid command: {}", s)), 17 | }; 18 | 19 | Ok(cmd) 20 | } 21 | } 22 | 23 | fn main() -> Result<()> { 24 | let cmd: Command = std::env::args() 25 | .nth(1) 26 | .context("Must provide a command")? 27 | .parse()?; 28 | 29 | let result = match cmd { 30 | Command::Wait => action::wait(), 31 | }; 32 | 33 | if let Err(err) = result { 34 | eprintln!("{:?}", err); 35 | 36 | std::process::exit(1); 37 | } 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /crates/core/README.md: -------------------------------------------------------------------------------- 1 | # actions-core 2 | 3 | Inspired by the [`@actions/core`][js-actions-core], this crate provides a 4 | set of functions to make creating Github Actions easier. 5 | 6 | Core functions for inputs, outputs, logging, setting environment variables, 7 | and masking secrets. 8 | 9 | ## Example 10 | 11 | ```rust 12 | use std::time::Duration; 13 | 14 | use actions_core as core; 15 | use anyhow::{Context, Result}; 16 | 17 | pub fn main() { 18 | let ms = core::input("milliseconds") 19 | .expect("milliseconds input required")? 20 | .parse() 21 | .expect("invalid milliseconds")?; 22 | 23 | let ms = Duration::from_millis(ms); 24 | 25 | std::thread::sleep(ms); 26 | 27 | core::set_output("greeting", "Hello, World!"); 28 | } 29 | ``` 30 | 31 | [js-actions-core]: https://github.com/actions/toolkit/tree/master/packages/core 32 | -------------------------------------------------------------------------------- /crates/core/src/util.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | pub(crate) fn cmd_arg(k: &str, v: V) -> String { 4 | format!("{}={}", k, escape_property(v)) 5 | } 6 | 7 | pub(crate) fn escape_data(data: D) -> String { 8 | data.to_string() 9 | .replace('%', "%25") 10 | .replace('\r', "%0D") 11 | .replace('\n', "%0A") 12 | } 13 | 14 | pub(crate) fn escape_property(prop: P) -> String { 15 | prop.to_string() 16 | .replace('%', "%25") 17 | .replace('\r', "%0D") 18 | .replace('\n', "%0A") 19 | .replace(':', "%3A") 20 | .replace(',', "%2C") 21 | } 22 | 23 | pub(crate) fn var_from_name( 24 | prefix: &str, 25 | name: K, 26 | ) -> Result { 27 | let suffix = name.to_string().replace(' ', "_").to_uppercase(); 28 | let key = format!("{}_{}", prefix, suffix); 29 | 30 | env::var(key) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 K.J. Valencik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/core/src/logger.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::util; 4 | 5 | #[derive(Debug)] 6 | pub enum LogLevel { 7 | Debug, 8 | Error, 9 | Warning, 10 | } 11 | 12 | impl AsRef for LogLevel { 13 | fn as_ref(&self) -> &str { 14 | match self { 15 | LogLevel::Debug => "debug", 16 | LogLevel::Error => "error", 17 | LogLevel::Warning => "warning", 18 | } 19 | } 20 | } 21 | 22 | #[derive(Debug, Default)] 23 | pub struct Log<'f, M> { 24 | pub message: M, 25 | pub file: Option<&'f str>, 26 | pub line: Option, 27 | pub col: Option, 28 | } 29 | 30 | impl<'f, M> Log<'f, M> { 31 | pub fn message(message: M) -> Self { 32 | Self { 33 | message, 34 | file: None, 35 | line: None, 36 | col: None, 37 | } 38 | } 39 | } 40 | 41 | impl<'f, M> fmt::Display for Log<'f, M> 42 | where 43 | M: ToString, 44 | { 45 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 46 | if self.file.is_none() && self.line.is_none() && self.col.is_none() { 47 | return write!(f, "::{}", self.message.to_string()); 48 | } 49 | 50 | let args = vec![ 51 | self.file.map(|f| util::cmd_arg("file", f)), 52 | self.line.map(|l| util::cmd_arg("line", l.to_string())), 53 | self.col.map(|c| util::cmd_arg("col", c.to_string())), 54 | ]; 55 | 56 | let args = args.into_iter().flatten().collect::>().join(","); 57 | 58 | write!(f, " {}::{}", args, self.message.to_string()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github Actions (Rust) 2 | 3 | This repository contains several different, but related pieces. It includes 4 | a set of crates for building Github Actions in Rust, a set of Github Actions 5 | built in Rust and an example of CI and Javascript bootstrapping for executing 6 | Rust actions. 7 | 8 | ## Toolkit 9 | 10 | The [`actions-toolkit`][actions-toolkit] crate provides common functionality for building 11 | Github Actions in Rust. It is broken into sub-crates for more granular 12 | usage. 13 | 14 | ## Actions 15 | 16 | ### [`wait`](./src/action/wait.rs) 17 | 18 | The `wait` action is a sample action that accepts a number of milliseconds 19 | as an input parameter and sleeps for that amount of time. 20 | 21 | This action is sample code to prove functionality and will likely be removed 22 | in the future. 23 | 24 | ```yaml 25 | name: CI 26 | on: push 27 | jobs: 28 | wait: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: kjvalencik/actions/run/wait@master 32 | with: 33 | milliseconds: '10000' 34 | ``` 35 | 36 | ## Releases 37 | 38 | Rust Github Actions are released as pre-compiled binaries for each of the 39 | platforms supported: Linux, Windows, and macOS. The [release workflow][workflow] 40 | looks for version changes in `Cargo.toml` and creates new releases with 41 | the built artifacts. 42 | 43 | ## Bootstrapping 44 | 45 | Github Actions only support Javascript and Docker. In many cases, it may be 46 | preferable to use a Docker Action. However, Docker Actions are limited to use 47 | on Linux and do not have access to the action `$HOME` directory. 48 | 49 | Instead of using Docker, a thin Javascript action can be used to download, 50 | cache, and execute a binary. 51 | 52 | The [`./run/index.ts`](./run/index.ts) file implements a generic bootstrap 53 | action. The file reads the current version from `Cargo.toml` and downloads 54 | the binary from [releases][releases]. 55 | 56 | ### Sub-actions 57 | 58 | Each action provided by this repository is specified in a sub-directory of 59 | [`./run`](./run). These directories only include an `action.yml` to describe 60 | the action and an `index.js` to load the bootstrap code. The bootstrap 61 | action will provide the current directory as an argument to the action 62 | binary. For example, `./action wait`. This allows multiple actions to 63 | effectively be provided by a single binary, decreasing overall size. 64 | 65 | [actions-toolkit]: ./crates/toolkit 66 | [releases]: https://github.com/kjvalencik/actions/releases 67 | [workflow]: ./.github/workflows/release.yaml 68 | -------------------------------------------------------------------------------- /run/index.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import url from "url"; 6 | 7 | import * as core from "@actions/core"; 8 | import * as exec from "@actions/exec"; 9 | import * as tc from "@actions/tool-cache"; 10 | import toml from 'toml'; 11 | 12 | const COMMAND = path.basename(path.dirname(require.main!.filename)); 13 | const CARGO_TOML = fs.readFileSync(path.join(__dirname, "../Cargo.toml")); 14 | 15 | const { 16 | name: NAME, 17 | repository: REPOSITORY, 18 | version: VERSION, 19 | } = toml.parse(CARGO_TOML.toString()).package; 20 | 21 | const { RUNNER_TEMP } = process.env; 22 | const { platform: PLATFORM } = process; 23 | 24 | const BINARY_NAME = PLATFORM === 'win32' ? `${NAME}.exe` : NAME; 25 | const ACTION_NAME = url.parse(REPOSITORY).pathname!.slice(1); 26 | const BASE_URL = `${REPOSITORY}/releases/download/v${VERSION}`; 27 | const FILE_PREFIX = `${NAME}-v${VERSION}`; 28 | 29 | type OS = "linux" | "darwin" | "win32"; 30 | 31 | async function downloadTar(os: OS): Promise { 32 | const file = `${FILE_PREFIX}-${os}-x64.tar.gz`; 33 | const url = `${BASE_URL}/${file}`; 34 | const downloadPath = await tc.downloadTool(url); 35 | const extractPath = await tc.extractTar(downloadPath, RUNNER_TEMP); 36 | const extractedFile = path.join(extractPath, BINARY_NAME); 37 | 38 | return tc.cacheFile(extractedFile, BINARY_NAME, ACTION_NAME, VERSION); 39 | } 40 | 41 | async function linux(): Promise { 42 | const cacheDir = tc.find(ACTION_NAME, "0.0.0") 43 | || await downloadTar("linux"); 44 | 45 | const binary = path.join(cacheDir, NAME); 46 | 47 | await exec.exec(binary, [COMMAND]); 48 | } 49 | 50 | async function macos(): Promise { 51 | const cacheDir = tc.find(ACTION_NAME, "0.0.0") 52 | || await downloadTar("darwin"); 53 | 54 | const binary = path.join(cacheDir, NAME); 55 | 56 | await exec.exec(binary, [COMMAND]); 57 | } 58 | 59 | async function windows(): Promise { 60 | const cacheDir = tc.find(ACTION_NAME, "0.0.0") 61 | || await downloadTar("win32"); 62 | 63 | const binary = path.join(cacheDir, NAME); 64 | 65 | await exec.exec(binary, [COMMAND]); 66 | } 67 | 68 | async function run(): Promise { 69 | switch (PLATFORM) { 70 | case "linux": return linux(); 71 | case "darwin": return macos(); 72 | case "win32": return windows(); 73 | case "aix": 74 | case "freebsd": 75 | case "openbsd": 76 | case "sunos": 77 | default: 78 | throw new Error(`Unsupported platform: ${PLATFORM}`); 79 | } 80 | } 81 | 82 | run().catch((error) => core.setFailed(error.message)); 83 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "actions" 5 | version = "0.0.1" 6 | dependencies = [ 7 | "actions-toolkit", 8 | "anyhow", 9 | ] 10 | 11 | [[package]] 12 | name = "actions-core" 13 | version = "0.0.2" 14 | dependencies = [ 15 | "uuid", 16 | ] 17 | 18 | [[package]] 19 | name = "actions-toolkit" 20 | version = "0.0.2" 21 | dependencies = [ 22 | "actions-core", 23 | "uuid", 24 | ] 25 | 26 | [[package]] 27 | name = "anyhow" 28 | version = "1.0.27" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "013a6e0a2cbe3d20f9c60b65458f7a7f7a5e636c5d0f45a5a6aee5d4b1f01785" 31 | 32 | [[package]] 33 | name = "cfg-if" 34 | version = "0.1.10" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 37 | 38 | [[package]] 39 | name = "getrandom" 40 | version = "0.1.14" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb" 43 | dependencies = [ 44 | "cfg-if", 45 | "libc", 46 | "wasi", 47 | ] 48 | 49 | [[package]] 50 | name = "libc" 51 | version = "0.2.68" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "dea0c0405123bba743ee3f91f49b1c7cfb684eef0da0a50110f758ccf24cdff0" 54 | 55 | [[package]] 56 | name = "ppv-lite86" 57 | version = "0.2.6" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" 60 | 61 | [[package]] 62 | name = "rand" 63 | version = "0.7.3" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 66 | dependencies = [ 67 | "getrandom", 68 | "libc", 69 | "rand_chacha", 70 | "rand_core", 71 | "rand_hc", 72 | ] 73 | 74 | [[package]] 75 | name = "rand_chacha" 76 | version = "0.2.2" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" 79 | dependencies = [ 80 | "ppv-lite86", 81 | "rand_core", 82 | ] 83 | 84 | [[package]] 85 | name = "rand_core" 86 | version = "0.5.1" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 89 | dependencies = [ 90 | "getrandom", 91 | ] 92 | 93 | [[package]] 94 | name = "rand_hc" 95 | version = "0.2.0" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 98 | dependencies = [ 99 | "rand_core", 100 | ] 101 | 102 | [[package]] 103 | name = "uuid" 104 | version = "0.8.1" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "9fde2f6a4bea1d6e007c4ad38c6839fa71cbb63b6dbf5b595aa38dc9b1093c11" 107 | dependencies = [ 108 | "rand", 109 | ] 110 | 111 | [[package]] 112 | name = "wasi" 113 | version = "0.9.0+wasi-snapshot-preview1" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 116 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | strategy: 9 | matrix: 10 | os: 11 | - macos-latest 12 | - ubuntu-latest 13 | - windows-latest 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | - name: Build 19 | run: cargo build --release 20 | - name: Test 21 | run: cargo test --release 22 | - name: Get Cargo Metadata 23 | id: cargo 24 | shell: bash 25 | run: >- 26 | echo -n "::set-output name=version::" && 27 | cargo metadata --format-version=1 --no-deps | 28 | jq -r '.packages[-1].version' && 29 | echo -n "::set-output name=name::" && 30 | cargo metadata --format-version=1 --no-deps | 31 | jq -r '.packages[-1].name' 32 | - name: Check if tag is released 33 | id: tag 34 | shell: bash 35 | env: 36 | TAG: ${{ steps.cargo.outputs.version }} 37 | run: >- 38 | git fetch --depth=1 origin "+refs/tags/${TAG}" > /dev/null 2>&1 && 39 | echo "::set-output name=exists::true" || 40 | echo "::set-output name=exists::false" 41 | - name: Bundle Release Asset 42 | id: asset 43 | shell: bash 44 | env: 45 | NAME: ${{ steps.cargo.outputs.name }} 46 | VERSION: ${{ steps.cargo.outputs.version }} 47 | OS: ${{ matrix.os }} 48 | run: >- 49 | export ARCH="linux" && 50 | if [ "$OS" = "macos-latest" ]; then export ARCH="darwin"; fi && 51 | if [ "$OS" = "windows-latest" ]; then export ARCH="win32"; fi && 52 | export ASSET_NAME="${NAME}-v${VERSION}-${ARCH}-x64.tar.gz" && 53 | export ASSET_PATH="${RUNNER_TEMP}/${ASSET_NAME}" && 54 | if [ "$OS" = "windows-latest" ]; then export NAME="${NAME}.exe"; fi && 55 | export BINARY="./target/release/${NAME}" && 56 | if [ "$OS" != "windows-latest" ]; then strip "$BINARY"; fi && 57 | if [ "$OS" != "windows-latest" ]; then tar -czf "$ASSET_PATH" -C "./target/release" "$NAME"; fi && 58 | if [ "$OS" = "windows-latest" ]; then tar --force-local -czf "$ASSET_PATH" -C "./target/release" "$NAME"; fi && 59 | echo -n "::set-output name=path::" && 60 | echo "$ASSET_PATH" 61 | - name: Create Release 62 | uses: softprops/action-gh-release@v1 63 | if: steps.tag.outputs.exists == 'false' 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | tag_name: v${{ steps.cargo.outputs.version }} 68 | files: ${{ steps.asset.outputs.path }} 69 | retag: 70 | runs-on: ubuntu-latest 71 | needs: release 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v2 75 | - name: Get Cargo Metadata 76 | id: cargo 77 | shell: bash 78 | run: >- 79 | echo -n "::set-output name=version::" && 80 | cargo metadata --format-version=1 --no-deps | 81 | jq -r '.packages[-1].version' 82 | - name: Check if tag is released 83 | id: tag 84 | shell: bash 85 | env: 86 | TAG: ${{ steps.cargo.outputs.version }} 87 | run: >- 88 | git fetch --depth=1 origin "+refs/tags/${TAG}" > /dev/null 2>&1 && 89 | echo "::set-output name=exists::true" || 90 | echo "::set-output name=exists::false" 91 | - name: Create semver tags 92 | if: steps.tag.outputs.exists == 'false' 93 | shell: bash 94 | env: 95 | VERSION: ${{ steps.cargo.outputs.version }} 96 | run: >- 97 | export MAJOR_VERSION="$(cut -d'.' -f1 <<< "$VERSION")" && 98 | export MINOR_VERSION="$(cut -d'.' -f1-2 <<< "$VERSION")" && 99 | git tag "$MAJOR_VERSION" && 100 | git tag "$MINOR_VERSION" && 101 | git tag "$VERSION" && 102 | git push -f origin "$MAJOR_VERSION" && 103 | git push -f origin "$MINOR_VERSION" && 104 | git push -f origin "$VERSION" 105 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::ffi::OsStr; 3 | use std::io; 4 | 5 | pub use crate::core::*; 6 | pub use crate::logger::*; 7 | 8 | mod core; 9 | mod logger; 10 | mod util; 11 | 12 | trait AssertStdout { 13 | fn assert(self) -> T; 14 | } 15 | 16 | impl AssertStdout for io::Result { 17 | fn assert(self) -> T { 18 | match self { 19 | Ok(v) => v, 20 | Err(e) => panic!("failed printing to stdout: {}", e), 21 | } 22 | } 23 | } 24 | 25 | /// Get an action's input parameter. 26 | /// 27 | /// ``` 28 | /// use actions_core as core; 29 | /// 30 | /// # std::env::set_var("INPUT_MILLISECONDS", "1000"); 31 | /// let ms: u32 = core::input("milliseconds") 32 | /// .expect("Failed to get milliseconds") 33 | /// .parse() 34 | /// .expect("Failed to parse milliseconds"); 35 | /// ``` 36 | pub fn input(name: K) -> Result { 37 | util::var_from_name("INPUT", name) 38 | } 39 | 40 | /// Sets an action's output parameter. 41 | /// 42 | /// ``` 43 | /// use actions_core as core; 44 | /// 45 | /// let count = 5; 46 | /// 47 | /// core::set_output("count", 5); 48 | /// ``` 49 | pub fn set_output(k: K, v: V) { 50 | Core::new().set_output(k, v).assert(); 51 | } 52 | 53 | /// Creates or updates an environment variable for any actions running next 54 | /// in a job. Environment variables are immediately set and available to the 55 | /// currently running action. Environment variables are case-sensitive and 56 | /// you can include punctuation. 57 | /// 58 | /// ``` 59 | /// use actions_core as core; 60 | /// 61 | /// core::set_env("MY_GREETING", "hello"); 62 | /// 63 | /// assert_eq!( 64 | /// std::env::var_os("MY_GREETING").as_deref(), 65 | /// Some(std::ffi::OsStr::new("hello")), 66 | /// ); 67 | /// ``` 68 | pub fn set_env(k: K, v: V) { 69 | Core::new().set_env(k, v).assert(); 70 | } 71 | 72 | /// Masking a value prevents a string or variable from being printed in the 73 | /// log. Each masked word separated by whitespace is replaced with the `*` 74 | /// character. 75 | /// 76 | /// ``` 77 | /// use actions_core as core; 78 | /// 79 | /// core::add_mask("supersecret"); 80 | /// ``` 81 | pub fn add_mask(v: V) { 82 | Core::new().add_mask(v).assert(); 83 | } 84 | 85 | /// Appends a directory to the system PATH variable for all subsequent 86 | /// actions in the current job as well as the currently running action. 87 | /// 88 | /// ``` 89 | /// use actions_core as core; 90 | /// 91 | /// core::add_path("/opt/my-app/bin"); 92 | /// ``` 93 | pub fn add_path(v: P) { 94 | Core::new().add_path(v).assert(); 95 | } 96 | 97 | /// Similar to `set_output`, but, shares data from a wrapper action. 98 | /// 99 | /// ``` 100 | /// use actions_core as core; 101 | /// 102 | /// core::save_state("my_greeting", "hello"); 103 | /// ``` 104 | pub fn save_state(k: K, v: V) { 105 | Core::new().save_state(k, v).assert(); 106 | } 107 | 108 | /// Similar to `input`, but, gets data shared from a wrapper action. 109 | /// 110 | /// ``` 111 | /// use actions_core as core; 112 | /// 113 | /// let greeting = core::state("my_greeting") 114 | /// .unwrap_or_else(|_| "hello".to_owned()); 115 | /// ``` 116 | pub fn state(name: K) -> Result { 117 | util::var_from_name("STATE", name) 118 | } 119 | 120 | /// Stops processing workflow commands while the provided function runs. A 121 | /// token is randomly generated and used to re-enable commands after 122 | /// completion. 123 | /// 124 | /// ``` 125 | /// use actions_core as core; 126 | /// 127 | /// core::stop_logging(|| { 128 | /// println!("::set-env name=ignored::value"); 129 | /// }); 130 | /// ``` 131 | pub fn stop_logging(f: F) -> T 132 | where 133 | F: FnOnce() -> T, 134 | { 135 | Core::new().stop_logging(f).assert() 136 | } 137 | 138 | /// Returns `true` if debugging is enabled Action debugging may be enabled 139 | /// by setting a `ACTION_STEP_DEBUG` secret to `true` in the repo. 140 | /// 141 | /// ``` 142 | /// use actions_core as core; 143 | /// 144 | /// let is_debug = core::is_debug(); 145 | /// ``` 146 | pub fn is_debug() -> bool { 147 | env::var_os("RUNNER_DEBUG").as_deref() == Some(OsStr::new("1")) 148 | } 149 | 150 | /// Prints a debug message to the log. Action debugging may be enabled by 151 | /// setting a `ACTION_STEP_DEBUG` secret to `true` in the repo. You can 152 | /// optionally provide a `file`, `line` and `col` with the `log_error` 153 | /// function. 154 | /// 155 | /// ``` 156 | /// use actions_core as core; 157 | /// 158 | /// core::debug("shaving a yak"); 159 | /// ``` 160 | pub fn debug(message: M) { 161 | Core::new().debug(message).assert(); 162 | } 163 | 164 | /// Prints an error message to the log. You can optionally provide a `file`, 165 | /// `line` and `col` with the `log_error` function. 166 | /// 167 | /// ``` 168 | /// use actions_core as core; 169 | /// 170 | /// core::error("shaving a yak"); 171 | /// ``` 172 | pub fn error(message: M) { 173 | Core::new().error(message).assert(); 174 | } 175 | 176 | /// Prints a warning message to the log. You can optionally provide a `file`, 177 | /// `line` and `col` with the `log_warning` function. 178 | /// 179 | /// ``` 180 | /// use actions_core as core; 181 | /// 182 | /// core::warning("shaving a yak"); 183 | /// ``` 184 | pub fn warning(message: M) { 185 | Core::new().warning(message).assert(); 186 | } 187 | 188 | pub fn log_message(level: LogLevel, message: M) { 189 | Core::new().log_message(level, message).assert(); 190 | } 191 | 192 | pub fn log(level: LogLevel, log: Log) { 193 | Core::new().log(level, log).assert(); 194 | } 195 | 196 | pub fn log_debug(log: Log) { 197 | Core::new().log_debug(log).assert(); 198 | } 199 | 200 | pub fn log_error(log: Log) { 201 | Core::new().log_error(log).assert(); 202 | } 203 | 204 | pub fn log_warning(log: Log) { 205 | Core::new().log_warning(log).assert(); 206 | } 207 | -------------------------------------------------------------------------------- /crates/core/src/core.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::io::{self, Write}; 3 | 4 | use crate::logger::{Log, LogLevel}; 5 | use crate::util; 6 | 7 | use uuid::Uuid; 8 | 9 | const PATH_VAR: &str = "PATH"; 10 | 11 | #[cfg(not(windows))] 12 | pub(crate) const DELIMITER: &str = ":"; 13 | 14 | #[cfg(windows)] 15 | pub(crate) const DELIMITER: &str = ";"; 16 | 17 | pub struct Core { 18 | out: W, 19 | } 20 | 21 | impl Default for Core { 22 | fn default() -> Self { 23 | Self { 24 | out: std::io::stdout(), 25 | } 26 | } 27 | } 28 | 29 | impl Core { 30 | pub fn new() -> Self { 31 | Default::default() 32 | } 33 | } 34 | 35 | impl From for Core { 36 | fn from(out: W) -> Self { 37 | Core { out } 38 | } 39 | } 40 | 41 | impl Core 42 | where 43 | W: Write, 44 | { 45 | fn issue(&mut self, k: &str, v: V) -> io::Result<()> { 46 | writeln!(self.out, "::{}::{}", k, util::escape_data(v)) 47 | } 48 | 49 | fn issue_named( 50 | &mut self, 51 | name: &str, 52 | k: K, 53 | v: V, 54 | ) -> io::Result<()> { 55 | writeln!( 56 | self.out, 57 | "::{} {}::{}", 58 | name, 59 | util::cmd_arg("name", k), 60 | util::escape_data(v), 61 | ) 62 | } 63 | 64 | pub fn input( 65 | _: &Self, 66 | name: K, 67 | ) -> Result { 68 | crate::input(name) 69 | } 70 | 71 | pub fn set_output( 72 | &mut self, 73 | k: K, 74 | v: V, 75 | ) -> io::Result<()> { 76 | self.issue_named("set-output", k, v.to_string()) 77 | } 78 | 79 | pub fn set_env( 80 | &mut self, 81 | k: K, 82 | v: V, 83 | ) -> io::Result<()> { 84 | let v = v.to_string(); 85 | 86 | // TODO: Move the side effect to a struct member 87 | env::set_var(k.to_string(), &v); 88 | 89 | self.issue_named("set-env", k, v) 90 | } 91 | 92 | pub fn add_mask(&mut self, v: V) -> io::Result<()> { 93 | self.issue("add-mask", v) 94 | } 95 | 96 | pub fn add_path(&mut self, v: P) -> io::Result<()> { 97 | let v = v.to_string(); 98 | 99 | self.issue("add-path", &v)?; 100 | 101 | // TODO: Move the side effect to a struct member 102 | let path = if let Some(mut path) = env::var_os(PATH_VAR) { 103 | path.push(DELIMITER); 104 | path.push(v); 105 | 106 | path 107 | } else { 108 | v.into() 109 | }; 110 | 111 | env::set_var(PATH_VAR, path); 112 | 113 | Ok(()) 114 | } 115 | 116 | pub fn save_state( 117 | &mut self, 118 | k: K, 119 | v: V, 120 | ) -> io::Result<()> { 121 | self.issue_named("save-state", k, v.to_string()) 122 | } 123 | 124 | pub fn state( 125 | _: &Self, 126 | name: K, 127 | ) -> Result { 128 | crate::state(name) 129 | } 130 | 131 | // TODO: Should the API prevent compiling code that will output commands 132 | // while this is running? 133 | pub fn stop_logging(&mut self, f: F) -> io::Result 134 | where 135 | F: FnOnce() -> T, 136 | { 137 | // TODO: Allow the to be configurable (helpful for tests) 138 | let token = Uuid::new_v4().to_string(); 139 | 140 | self.issue("stop-commands", &token)?; 141 | 142 | let result = f(); 143 | 144 | self.issue(&token, "")?; 145 | 146 | Ok(result) 147 | } 148 | 149 | pub fn is_debug(_: &Self) -> bool { 150 | crate::is_debug() 151 | } 152 | 153 | pub fn log_message( 154 | &mut self, 155 | level: LogLevel, 156 | message: M, 157 | ) -> io::Result<()> { 158 | self.issue(level.as_ref(), message) 159 | } 160 | 161 | pub fn debug(&mut self, message: M) -> io::Result<()> { 162 | self.log_message(LogLevel::Debug, message) 163 | } 164 | 165 | pub fn error(&mut self, message: M) -> io::Result<()> { 166 | self.log_message(LogLevel::Error, message) 167 | } 168 | 169 | pub fn warning(&mut self, message: M) -> io::Result<()> { 170 | self.log_message(LogLevel::Warning, message) 171 | } 172 | 173 | pub fn log( 174 | &mut self, 175 | level: LogLevel, 176 | log: Log, 177 | ) -> io::Result<()> { 178 | writeln!(self.out, "::{}{}", level.as_ref(), log) 179 | } 180 | 181 | pub fn log_debug(&mut self, log: Log) -> io::Result<()> { 182 | self.log(LogLevel::Debug, log) 183 | } 184 | 185 | pub fn log_error(&mut self, log: Log) -> io::Result<()> { 186 | self.log(LogLevel::Error, log) 187 | } 188 | 189 | pub fn log_warning(&mut self, log: Log) -> io::Result<()> { 190 | self.log(LogLevel::Warning, log) 191 | } 192 | } 193 | 194 | #[cfg(test)] 195 | mod test { 196 | use std::cell::RefCell; 197 | use std::env; 198 | use std::io; 199 | use std::rc::Rc; 200 | 201 | use crate::core::DELIMITER; 202 | use crate::*; 203 | 204 | #[derive(Clone)] 205 | struct TestBuf { 206 | inner: Rc>>, 207 | } 208 | 209 | impl TestBuf { 210 | fn new() -> Self { 211 | Self { 212 | inner: Rc::new(RefCell::new(Vec::new())), 213 | } 214 | } 215 | 216 | fn clear(&self) { 217 | self.inner.borrow_mut().clear(); 218 | } 219 | 220 | fn to_string(&self) -> String { 221 | String::from_utf8(self.inner.borrow().to_vec()).unwrap() 222 | } 223 | } 224 | 225 | impl io::Write for TestBuf { 226 | fn write(&mut self, buf: &[u8]) -> io::Result { 227 | self.inner.borrow_mut().write(buf) 228 | } 229 | 230 | fn flush(&mut self) -> io::Result<()> { 231 | self.inner.borrow_mut().flush() 232 | } 233 | } 234 | 235 | fn test(expected: &str, f: F) 236 | where 237 | F: FnOnce(Core) -> io::Result<()>, 238 | { 239 | let buf = TestBuf::new(); 240 | 241 | f(Core::from(buf.clone())).unwrap(); 242 | 243 | assert_eq!(buf.to_string(), expected); 244 | } 245 | 246 | #[test] 247 | fn set_output() { 248 | test("::set-output name=greeting::hello\n", |mut core| { 249 | core.set_output("greeting", "hello") 250 | }); 251 | } 252 | 253 | #[test] 254 | fn set_env() { 255 | test("::set-env name=greeting::hello\n", |mut core| { 256 | core.set_env("greeting", "hello") 257 | }); 258 | 259 | assert_eq!(env::var("greeting").unwrap().as_str(), "hello"); 260 | } 261 | 262 | #[test] 263 | fn add_mask() { 264 | test("::add-mask::super secret message\n", |mut core| { 265 | core.add_mask("super secret message") 266 | }); 267 | } 268 | 269 | #[test] 270 | fn add_path() { 271 | test("::add-path::/this/is/a/test\n", |mut core| { 272 | core.add_path("/this/is/a/test") 273 | }); 274 | 275 | let path = env::var("PATH").unwrap(); 276 | let last_path = path.split(DELIMITER).last().unwrap(); 277 | 278 | assert_eq!(last_path, "/this/is/a/test"); 279 | } 280 | 281 | #[test] 282 | fn save_state() { 283 | test("::save-state name=greeting::hello\n", |mut core| { 284 | core.save_state("greeting", "hello") 285 | }); 286 | } 287 | 288 | #[test] 289 | fn stop_logging() { 290 | let buf = TestBuf::new(); 291 | let mut core = Core::from(buf.clone()); 292 | let mut token = String::new(); 293 | 294 | core.stop_logging(|| { 295 | let output = buf.to_string(); 296 | 297 | assert!(output.starts_with("::stop-commands::")); 298 | 299 | token = output.trim().split("::").last().unwrap().to_string(); 300 | buf.clear(); 301 | }) 302 | .unwrap(); 303 | 304 | assert_eq!(buf.to_string(), format!("::{}::\n", token)); 305 | } 306 | 307 | #[test] 308 | fn test_debug() { 309 | test("::debug::Hello, World!\n", |mut core| { 310 | core.debug("Hello, World!") 311 | }); 312 | } 313 | 314 | #[test] 315 | fn test_error_complex() { 316 | test( 317 | "::error file=/test/file.rs,line=5,col=10::hello\n", 318 | |mut core| { 319 | core.log_error(Log { 320 | message: "hello", 321 | file: Some("/test/file.rs"), 322 | line: Some(5), 323 | col: Some(10), 324 | }) 325 | }, 326 | ); 327 | } 328 | 329 | #[test] 330 | fn test_warning_omit() { 331 | test("::warning::hello\n", |mut core| { 332 | core.log_warning(Log { 333 | message: "hello", 334 | ..Default::default() 335 | }) 336 | }); 337 | } 338 | } 339 | --------------------------------------------------------------------------------