Skip to content

Commit 6c1c7ab

Browse files
vdyedscho
authored andcommitted
Merge pull request #399 from vdye/feature/build-installers
Implement workflow to create GitHub release with attached `git` installers
2 parents b7c2064 + 0a32c05 commit 6c1c7ab

File tree

13 files changed

+1516
-0
lines changed

13 files changed

+1516
-0
lines changed

.github/actions/akv-secret/action.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Get Azure Key Vault Secrets
2+
3+
description: |
4+
Get secrets from Azure Key Vault and store the results as masked step outputs,
5+
environment variables, or files.
6+
7+
inputs:
8+
vault:
9+
required: true
10+
description: Name of the Azure Key Vault.
11+
secrets:
12+
required: true
13+
description: |
14+
Comma- or newline-separated list of secret names in Azure Key Vault.
15+
The output and encoding of secrets can be specified using this syntax:
16+
17+
SECRET ENCODING> $output:OUTPUT
18+
SECRET ENCODING> $env:ENVAR
19+
SECRET ENCODING> FILE
20+
21+
SECRET Name of the secret in Azure Key Vault.
22+
ENCODING (optional) Encoding of the secret: base64.
23+
OUTPUT Name of a step output variable.
24+
ENVAR Name of an environment variable.
25+
FILE File path (relative or absolute).
26+
27+
If no output format is specified the default is a step output variable
28+
with the same name as the secret. I.e, SECRET > $output:SECRET.
29+
30+
Examples:
31+
32+
Assign output variable named `raw-var` to the raw value of the secret
33+
`raw-secret`:
34+
35+
raw-secret > $output:raw-var
36+
37+
Assign output variable named `decoded-var` to the base64 decoded value
38+
of the secret `encoded-secret`:
39+
40+
encoded-secret base64> $output:decoded-var
41+
42+
Download the secret named `tls-certificate` to the file path
43+
`.certs/tls.cert`:
44+
45+
tls-certificate > .certs/tls.cert
46+
47+
Assign environment variable `ENV_SECRET` to the base64 decoded value of
48+
the secret `encoded-secret`:
49+
50+
encoded-secret base64> $env:ENV_SECRET
51+
52+
runs:
53+
using: node20
54+
main: index.js

.github/actions/akv-secret/index.js

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
const { spawnSync } = require('child_process');
2+
const fs = require('fs');
3+
const os = require('os');
4+
const path = require('path');
5+
const { isUtf8 } = require("buffer");
6+
7+
// Note that we are not using the `@actions/core` package as it is not available
8+
// without either committing node_modules/ to the repository, or using something
9+
// like ncc to bundle the code.
10+
11+
// See https://github.com/actions/toolkit/blob/%40actions/core%401.1.0/packages/core/src/command.ts#L81-L87
12+
const escapeData = (s) => {
13+
return s
14+
.replace(/%/g, '%25')
15+
.replace(/\r/g, '%0D')
16+
.replace(/\n/g, '%0A')
17+
}
18+
19+
const stringify = (value) => {
20+
if (typeof value === 'string') return value;
21+
if (Buffer.isBuffer(value) && isUtf8(value)) return value.toString('utf-8');
22+
return undefined;
23+
}
24+
25+
const trimEOL = (buf) => {
26+
let l = buf.length
27+
if (l > 0 && buf[l - 1] === 0x0a) {
28+
l -= l > 1 && buf[l - 2] === 0x0d ? 2 : 1
29+
}
30+
return buf.slice(0, l)
31+
}
32+
33+
const writeBufToFile = (buf, file) => {
34+
out = fs.createWriteStream(file)
35+
out.write(buf)
36+
out.end()
37+
}
38+
39+
const logInfo = (message) => {
40+
process.stdout.write(`${message}${os.EOL}`);
41+
}
42+
43+
const setFailed = (error) => {
44+
process.stdout.write(`::error::${escapeData(error.message)}${os.EOL}`);
45+
process.exitCode = 1;
46+
}
47+
48+
const writeCommand = (file, name, value) => {
49+
// Unique delimiter to avoid conflicts with actual values
50+
let delim;
51+
for (let count = 0; ; count++) {
52+
delim = `XXXXXX${count}`;
53+
if (!name.includes(delim) && !value.includes(delim)) {
54+
break;
55+
}
56+
}
57+
58+
fs.appendFileSync(file, `${name}<<${delim}${os.EOL}${value}${os.EOL}${delim}${os.EOL}`);
59+
}
60+
61+
const setSecret = (value) => {
62+
value = stringify(value);
63+
64+
// Masking a secret that is not a valid UTF-8 string or buffer is not useful
65+
if (value === undefined) return;
66+
67+
process.stdout.write(
68+
value
69+
.split(/\r?\n/g)
70+
.filter(line => line.length > 0) // Cannot mask empty lines
71+
.map(
72+
value => `::add-mask::${escapeData(value)}${os.EOL}`
73+
)
74+
.join('')
75+
);
76+
}
77+
78+
const setOutput = (name, value) => {
79+
value = stringify(value);
80+
if (value === undefined) {
81+
throw new Error(`Output value '${name}' is not a valid UTF-8 string or buffer`);
82+
}
83+
84+
writeCommand(process.env.GITHUB_OUTPUT, name, value);
85+
}
86+
87+
const exportVariable = (name, value) => {
88+
value = stringify(value);
89+
if (value === undefined) {
90+
throw new Error(`Environment variable '${name}' is not a valid UTF-8 string or buffer`);
91+
}
92+
93+
writeCommand(process.env.GITHUB_ENV, name, value);
94+
}
95+
96+
(async () => {
97+
const vault = process.env.INPUT_VAULT;
98+
const secrets = process.env.INPUT_SECRETS;
99+
// Parse and normalize secret mappings
100+
const secretMappings = secrets
101+
.split(/[\n,]+/)
102+
.map((entry) => entry.trim())
103+
.filter((entry) => entry)
104+
.map((entry) => {
105+
const [input, encoding, output] = entry.split(/(\S+)?>/).map((part) => part?.trim());
106+
return { input, encoding, output: output || `\$output:${input}` }; // Default output to $output:input if not specified
107+
});
108+
109+
if (secretMappings.length === 0) {
110+
throw new Error('No secrets provided.');
111+
}
112+
113+
// Fetch secrets from Azure Key Vault
114+
for (const { input: secretName, encoding, output } of secretMappings) {
115+
let az = spawnSync('az',
116+
[
117+
'keyvault',
118+
'secret',
119+
'show',
120+
'--vault-name',
121+
vault,
122+
'--name',
123+
secretName,
124+
'--query',
125+
'value',
126+
'--output',
127+
'tsv'
128+
],
129+
{
130+
stdio: ['ignore', 'pipe', 'inherit'],
131+
shell: true // az is a batch script on Windows
132+
}
133+
);
134+
135+
if (az.error) throw new Error(az.error, { cause: az.error });
136+
if (az.status !== 0) throw new Error(`az failed with status ${az.status}`);
137+
138+
// az keyvault secret show --output tsv returns a buffer with trailing \n
139+
// (or \r\n on Windows), so we need to trim it specifically.
140+
let secretBuf = trimEOL(az.stdout);
141+
142+
// Mask the raw secret value in logs
143+
setSecret(secretBuf);
144+
145+
// Handle encoded values if specified
146+
// Sadly we cannot use the `--encoding` parameter of the `az keyvault
147+
// secret (show|download)` command as the former does not support it, and
148+
// the latter must be used with `--file` (we could use /dev/stdout on UNIX
149+
// but not on Windows).
150+
if (encoding) {
151+
switch (encoding.toLowerCase()) {
152+
case 'base64':
153+
secretBuf = Buffer.from(secretBuf.toString('utf-8'), 'base64');
154+
break;
155+
case 'ascii':
156+
case 'utf8':
157+
case 'utf-8':
158+
// No need to decode the existing buffer from the az command
159+
break;
160+
default:
161+
throw new Error(`Unsupported encoding: ${encoding}`);
162+
}
163+
164+
// Mask the decoded value
165+
setSecret(secretBuf);
166+
}
167+
168+
const outputType = output.startsWith('$env:')
169+
? 'env'
170+
: output.startsWith('$output:')
171+
? 'output'
172+
: 'file';
173+
174+
switch (outputType) {
175+
case 'env':
176+
const varName = output.replace('$env:', '').trim();
177+
exportVariable(varName, secretBuf);
178+
logInfo(`Secret set as environment variable: ${varName}`);
179+
break;
180+
181+
case 'output':
182+
const outputName = output.replace('$output:', '').trim();
183+
setOutput(outputName, secretBuf);
184+
logInfo(`Secret set as output variable: ${outputName}`);
185+
break;
186+
187+
case 'file':
188+
const filePath = output.trim();
189+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
190+
writeBufToFile(secretBuf, filePath);
191+
logInfo(`Secret written to file: ${filePath}`);
192+
break;
193+
}
194+
}
195+
})().catch(setFailed);

0 commit comments

Comments
 (0)