Compare commits
32 Commits
v3.1.0
...
jill/rebui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd76a122aa | ||
|
|
a665f2c1ab | ||
|
|
398c918d60 | ||
|
|
dc90451a94 | ||
|
|
9d7acefac9 | ||
|
|
04984a6c91 | ||
|
|
db7314de7b | ||
|
|
3f9ba481c9 | ||
|
|
1e8273d4be | ||
|
|
015b03300e | ||
|
|
ab44f9f69c | ||
|
|
af49dd18de | ||
|
|
d456b72513 | ||
|
|
2a828228a8 | ||
|
|
604a86ce4e | ||
|
|
7998453500 | ||
|
|
e7fe4397d9 | ||
|
|
6911316fe3 | ||
|
|
24235f3b6b | ||
|
|
a2ce22dd39 | ||
|
|
d2fdd9df66 | ||
|
|
95478552e8 | ||
|
|
4a997a0402 | ||
|
|
81bc2a50b4 | ||
|
|
1dfe1fc19e | ||
|
|
856971e6d6 | ||
|
|
5fd6fbcfdf | ||
|
|
13f927c806 | ||
|
|
fdb192f5dc | ||
|
|
13c259d353 | ||
|
|
b91fef0861 | ||
|
|
2d74546fd1 |
47
.github/workflows/e2e-tests.yml
vendored
47
.github/workflows/e2e-tests.yml
vendored
@@ -8,6 +8,11 @@ on:
|
|||||||
|
|
||||||
# For test.yml to call this workflow
|
# For test.yml to call this workflow
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref to checkout"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
OP_CONNECT_CREDENTIALS:
|
OP_CONNECT_CREDENTIALS:
|
||||||
required: true
|
required: true
|
||||||
@@ -34,6 +39,19 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build actions
|
||||||
|
run: npm run build:all
|
||||||
|
|
||||||
- name: Generate .env.tpl
|
- name: Generate .env.tpl
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -87,6 +105,22 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./tests/assert-env-unset.sh
|
run: ./tests/assert-env-unset.sh
|
||||||
|
|
||||||
|
- name: Load secrets (invalid ref - expect failure)
|
||||||
|
id: load_invalid
|
||||||
|
continue-on-error: true
|
||||||
|
uses: ./
|
||||||
|
env:
|
||||||
|
BAD_REF: "op://x"
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
with:
|
||||||
|
export-env: true
|
||||||
|
|
||||||
|
- name: Assert invalid ref failed
|
||||||
|
shell: bash
|
||||||
|
run: ./tests/assert-invalid-ref-failed.sh
|
||||||
|
env:
|
||||||
|
STEP_OUTCOME: ${{ steps.load_invalid.outcome }}
|
||||||
|
|
||||||
test-connect:
|
test-connect:
|
||||||
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -101,6 +135,19 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build actions
|
||||||
|
run: npm run build:all
|
||||||
|
|
||||||
- name: Generate .env.tpl
|
- name: Generate .env.tpl
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/test-e2e.yml
vendored
6
.github/workflows/test-e2e.yml
vendored
@@ -26,6 +26,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
condition: ${{ steps.check.outputs.condition }}
|
condition: ${{ steps.check.outputs.condition }}
|
||||||
|
ref: ${{ steps.check.outputs.ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check if PR is from external contributor
|
- name: Check if PR is from external contributor
|
||||||
id: check
|
id: check
|
||||||
@@ -45,6 +46,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
|
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
|
||||||
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
|
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
|
||||||
|
echo "ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
|
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
|
||||||
# For repository_dispatch events (ok-to-test), check if sha matches
|
# For repository_dispatch events (ok-to-test), check if sha matches
|
||||||
@@ -58,6 +60,7 @@ jobs:
|
|||||||
if [ -n "$SHA_PARAM" ] && [[ "$PR_HEAD_SHA" == *"$SHA_PARAM"* ]]; then
|
if [ -n "$SHA_PARAM" ] && [[ "$PR_HEAD_SHA" == *"$SHA_PARAM"* ]]; then
|
||||||
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
|
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
|
||||||
echo "Setting condition=dispatch-event (sha matches)"
|
echo "Setting condition=dispatch-event (sha matches)"
|
||||||
|
echo "ref=$PR_HEAD_SHA" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
echo "Setting condition=skip (sha does not match or empty)"
|
echo "Setting condition=skip (sha does not match or empty)"
|
||||||
@@ -65,6 +68,7 @@ jobs:
|
|||||||
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" == "main" ]; then
|
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" == "main" ]; then
|
||||||
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
||||||
echo "Setting condition=push-to-main (push to main)"
|
echo "Setting condition=push-to-main (push to main)"
|
||||||
|
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
# Unknown event type
|
# Unknown event type
|
||||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
@@ -80,6 +84,8 @@ jobs:
|
|||||||
||
|
||
|
||||||
needs.check-external-pr.outputs.condition == 'push-to-main'
|
needs.check-external-pr.outputs.condition == 'push-to-main'
|
||||||
uses: ./.github/workflows/e2e-tests.yml
|
uses: ./.github/workflows/e2e-tests.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.check-external-pr.outputs.ref }}
|
||||||
secrets:
|
secrets:
|
||||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -71,6 +71,21 @@ jobs:
|
|||||||
# Prints: Secret: ***
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🔑 SSH Key Format
|
||||||
|
|
||||||
|
When loading SSH keys, you can specify the format using the `ssh-format` query parameter. This is useful when you need the private key in a specific format like OpenSSH.
|
||||||
|
|
||||||
|
```yml
|
||||||
|
- name: Load SSH key
|
||||||
|
uses: 1password/load-secrets-action@v3
|
||||||
|
env:
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
# Load SSH private key in OpenSSH format
|
||||||
|
SSH_PRIVATE_KEY: op://vault/item/private key?ssh-format=openssh
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
|
||||||
|
|
||||||
## 💙 Community & Support
|
## 💙 Community & Support
|
||||||
|
|
||||||
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||||
|
|||||||
874
configure/dist/index.js
vendored
874
configure/dist/index.js
vendored
File diff suppressed because one or more lines are too long
BIN
dist/core_bg.wasm
vendored
Normal file
BIN
dist/core_bg.wasm
vendored
Normal file
Binary file not shown.
2601
dist/index.js
vendored
2601
dist/index.js
vendored
File diff suppressed because one or more lines are too long
16
package-lock.json
generated
16
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@1password/op-js": "^0.1.11",
|
"@1password/op-js": "^0.1.11",
|
||||||
|
"@1password/sdk": "^0.4.0",
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"@actions/tool-cache": "^2.0.2",
|
"@actions/tool-cache": "^2.0.2",
|
||||||
@@ -72,6 +73,21 @@
|
|||||||
"prettier": "^2.0.0 || ^3.0.0"
|
"prettier": "^2.0.0 || ^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@1password/sdk": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@1password/sdk/-/sdk-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-RIypujc9R/UeUaobjyClTYokqRFpcaIkHq+EO/X9XoHId98Vg+SbjwGV+yygRC4MyHwYNo1KP1iEbZcqJ4ZTdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@1password/sdk-core": "0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@1password/sdk-core": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@1password/sdk-core/-/sdk-core-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-vjeI1o4wiONY+t1naA4dtUp6HktdLH1D2S+tN1Lh4l41S9XIUHxrljov9B5u6G+VHr7f2MUoxmzXA9zT3aokQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@1password/op-js": "^0.1.11",
|
"@1password/op-js": "^0.1.11",
|
||||||
|
"@1password/sdk": "^0.4.0",
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"@actions/tool-cache": "^2.0.2",
|
"@actions/tool-cache": "^2.0.2",
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -3,7 +3,7 @@ import * as core from "@actions/core";
|
|||||||
import { validateCli } from "@1password/op-js";
|
import { validateCli } from "@1password/op-js";
|
||||||
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
||||||
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||||
import { envFilePath } from "./constants";
|
import { envFilePath, envConnectHost, envConnectToken } from "./constants";
|
||||||
|
|
||||||
const loadSecretsAction = async () => {
|
const loadSecretsAction = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -26,8 +26,12 @@ const loadSecretsAction = async () => {
|
|||||||
dotenv.config({ path: file });
|
dotenv.config({ path: file });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and install the CLI
|
const isConnect =
|
||||||
await installCLI();
|
process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
|
// If Connect is used, download and install the CLI
|
||||||
|
if (isConnect) {
|
||||||
|
await installCLI();
|
||||||
|
}
|
||||||
|
|
||||||
// Load secrets
|
// Load secrets
|
||||||
await loadSecrets(shouldExportEnv);
|
await loadSecrets(shouldExportEnv);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as exec from "@actions/exec";
|
import * as exec from "@actions/exec";
|
||||||
import { read, setClientInfo } from "@1password/op-js";
|
import { read, setClientInfo } from "@1password/op-js";
|
||||||
|
import { createClient, Secrets } from "@1password/sdk";
|
||||||
import {
|
import {
|
||||||
extractSecret,
|
extractSecret,
|
||||||
loadSecrets,
|
loadSecrets,
|
||||||
@@ -22,6 +23,13 @@ jest.mock("@actions/exec", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
jest.mock("@1password/op-js");
|
jest.mock("@1password/op-js");
|
||||||
|
jest.mock("@1password/sdk", () => ({
|
||||||
|
createClient: jest.fn(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Secrets: {
|
||||||
|
validateSecretReference: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -106,9 +114,50 @@ describe("extractSecret", () => {
|
|||||||
);
|
);
|
||||||
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when secret value is empty string", () => {
|
||||||
|
const emptySecretValue = "";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(read.parse as jest.Mock).mockReturnValue(emptySecretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(read.parse as jest.Mock).mockReturnValue(testSecretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set empty string as step output", () => {
|
||||||
|
extractSecret(envTestSecretEnv, false);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
emptySecretValue,
|
||||||
|
);
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set empty string as environment variable", () => {
|
||||||
|
extractSecret(envTestSecretEnv, true);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
emptySecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call setSecret for empty string", () => {
|
||||||
|
extractSecret(envTestSecretEnv, false);
|
||||||
|
expect(core.setSecret).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadSecrets", () => {
|
describe("loadSecrets when using Connect", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[envConnectHost] = "https://localhost:8000";
|
||||||
|
process.env[envConnectToken] = "token";
|
||||||
|
process.env[envServiceAccountToken] = "";
|
||||||
|
});
|
||||||
|
|
||||||
it("sets the client info and gets the executed output", async () => {
|
it("sets the client info and gets the executed output", async () => {
|
||||||
await loadSecrets(true);
|
await loadSecrets(true);
|
||||||
|
|
||||||
@@ -146,6 +195,199 @@ describe("loadSecrets", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loadSecrets when using Service Account", () => {
|
||||||
|
const mockResolve = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[envConnectHost] = "";
|
||||||
|
process.env[envConnectToken] = "";
|
||||||
|
process.env[envServiceAccountToken] = "ops_token";
|
||||||
|
|
||||||
|
Object.keys(process.env).forEach((key) => {
|
||||||
|
if (
|
||||||
|
typeof process.env[key] === "string" &&
|
||||||
|
process.env[key]?.startsWith("op://")
|
||||||
|
) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
process.env.MY_SECRET = "op://vault/item/field";
|
||||||
|
|
||||||
|
(createClient as jest.Mock).mockResolvedValue({
|
||||||
|
secrets: { resolve: mockResolve },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockResolve.mockResolvedValue("resolved-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call op env ls when using Service Account", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
expect(exec.getExecOutput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets step output with resolved value when export-env is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith(
|
||||||
|
"MY_SECRET",
|
||||||
|
"resolved-secret-value",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks secret with setSecret when export-env is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledTimes(1);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call exportVariable when export-env is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
"MY_SECRET",
|
||||||
|
"resolved-secret-value",
|
||||||
|
);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
envManagedVariables,
|
||||||
|
"MY_SECRET",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set step output when export-env is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||||
|
"MY_SECRET",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks secret with setSecret when export-env is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledTimes(1);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when no env vars have op:// refs", async () => {
|
||||||
|
Object.keys(process.env).forEach((key) => {
|
||||||
|
if (
|
||||||
|
typeof process.env[key] === "string" &&
|
||||||
|
process.env[key]?.startsWith("op://")
|
||||||
|
) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await loadSecrets(true);
|
||||||
|
expect(exec.getExecOutput).not.toHaveBeenCalled();
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps createClient errors with a descriptive message", async () => {
|
||||||
|
(createClient as jest.Mock).mockRejectedValue(
|
||||||
|
new Error("invalid token format"),
|
||||||
|
);
|
||||||
|
await expect(loadSecrets(false)).rejects.toThrow(
|
||||||
|
"Service account authentication failed: invalid token format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple refs", () => {
|
||||||
|
const ref1 = "op://vault/item/field";
|
||||||
|
const ref2 = "op://vault/other/item";
|
||||||
|
const ref3 = "op://vault/file/secret";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MY_SECRET = ref1;
|
||||||
|
process.env.ANOTHER_SECRET = ref2;
|
||||||
|
process.env.FILE_SECRET = ref3;
|
||||||
|
|
||||||
|
mockResolve
|
||||||
|
.mockResolvedValueOnce("value1")
|
||||||
|
.mockResolvedValueOnce("value2")
|
||||||
|
.mockResolvedValueOnce("value3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves each ref and sets step output for each when export-env is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
|
||||||
|
expect(mockResolve).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockResolve).toHaveBeenCalledWith(ref1);
|
||||||
|
expect(mockResolve).toHaveBeenCalledWith(ref2);
|
||||||
|
expect(mockResolve).toHaveBeenCalledWith(ref3);
|
||||||
|
|
||||||
|
expect(core.setOutput).toHaveBeenCalledTimes(3);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");
|
||||||
|
|
||||||
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(mockResolve).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
"ANOTHER_SECRET",
|
||||||
|
"value2",
|
||||||
|
);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");
|
||||||
|
|
||||||
|
const exportVariableCalls = (core.exportVariable as jest.Mock).mock
|
||||||
|
.calls as [string, string][];
|
||||||
|
const managedVarsCall = exportVariableCalls.find(
|
||||||
|
([name]) => name === envManagedVariables,
|
||||||
|
);
|
||||||
|
expect(managedVarsCall).toBeDefined();
|
||||||
|
const managedList = (managedVarsCall as [string, string])[1].split(",");
|
||||||
|
expect(managedList).toContain("MY_SECRET");
|
||||||
|
expect(managedList).toContain("ANOTHER_SECRET");
|
||||||
|
expect(managedList).toContain("FILE_SECRET");
|
||||||
|
expect(managedList).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secret reference validation", () => {
|
||||||
|
it("fails with clear message when a secret reference is invalid", async () => {
|
||||||
|
process.env.MY_SECRET = "op://x";
|
||||||
|
(Secrets.validateSecretReference as jest.Mock).mockImplementationOnce(
|
||||||
|
() => {
|
||||||
|
throw new Error("invalid reference format");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(loadSecrets(true)).rejects.toThrow(
|
||||||
|
"Invalid secret reference(s): MY_SECRET",
|
||||||
|
);
|
||||||
|
expect(mockResolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates all refs before resolving any secrets", async () => {
|
||||||
|
process.env.MY_SECRET = "op://vault/item/field";
|
||||||
|
process.env.OTHER = "op://vault/other/item";
|
||||||
|
(Secrets.validateSecretReference as jest.Mock).mockImplementation(
|
||||||
|
(ref: string) => {
|
||||||
|
if (ref === "op://vault/other/item") {
|
||||||
|
throw new Error("invalid");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(loadSecrets(false)).rejects.toThrow(
|
||||||
|
"Invalid secret reference(s): OTHER",
|
||||||
|
);
|
||||||
|
expect(mockResolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("unsetPrevious", () => {
|
describe("unsetPrevious", () => {
|
||||||
const testManagedEnv = "TEST_SECRET";
|
const testManagedEnv = "TEST_SECRET";
|
||||||
const testSecretValue = "MyS3cr#T";
|
const testSecretValue = "MyS3cr#T";
|
||||||
|
|||||||
131
src/utils.ts
131
src/utils.ts
@@ -1,6 +1,7 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as exec from "@actions/exec";
|
import * as exec from "@actions/exec";
|
||||||
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||||
|
import { createClient, Secrets } from "@1password/sdk";
|
||||||
import { version } from "../package.json";
|
import { version } from "../package.json";
|
||||||
import {
|
import {
|
||||||
authErr,
|
authErr,
|
||||||
@@ -29,32 +30,77 @@ export const validateAuth = (): void => {
|
|||||||
core.info(`Authenticated with ${authType}.`);
|
core.info(`Authenticated with ${authType}.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractSecret = (
|
const getEnvVarNamesWithSecretRefs = (): string[] =>
|
||||||
|
Object.keys(process.env).filter(
|
||||||
|
(key) =>
|
||||||
|
typeof process.env[key] === "string" &&
|
||||||
|
process.env[key]?.startsWith("op://"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateSecretRefs = (envNames: string[]): void => {
|
||||||
|
const invalid: { name: string; message: string }[] = [];
|
||||||
|
|
||||||
|
for (const envName of envNames) {
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Secrets.validateSecretReference(ref);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
invalid.push({ name: envName, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw an error if any secret references are invalid
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
const details = invalid
|
||||||
|
.map(({ name, message }) => `${name}: ${message}`)
|
||||||
|
.join("; ");
|
||||||
|
throw new Error(`Invalid secret reference(s): ${details}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setResolvedSecret = (
|
||||||
envName: string,
|
envName: string,
|
||||||
|
secretValue: string,
|
||||||
shouldExportEnv: boolean,
|
shouldExportEnv: boolean,
|
||||||
): void => {
|
): void => {
|
||||||
core.info(`Populating variable: ${envName}`);
|
core.info(`Populating variable: ${envName}`);
|
||||||
|
|
||||||
const ref = process.env[envName];
|
|
||||||
if (!ref) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretValue = read.parse(ref);
|
|
||||||
if (!secretValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldExportEnv) {
|
if (shouldExportEnv) {
|
||||||
core.exportVariable(envName, secretValue);
|
core.exportVariable(envName, secretValue);
|
||||||
} else {
|
} else {
|
||||||
core.setOutput(envName, secretValue);
|
core.setOutput(envName, secretValue);
|
||||||
}
|
}
|
||||||
core.setSecret(secretValue);
|
if (secretValue) {
|
||||||
|
core.setSecret(secretValue);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
export const extractSecret = (
|
||||||
// Pass User-Agent Information to the 1Password CLI
|
envName: string,
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): void => {
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = read.parse(ref);
|
||||||
|
if (secretValue === null || secretValue === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedSecret(envName, secretValue, shouldExportEnv);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect loads secrets via the 1Password CLI
|
||||||
|
const loadSecretsViaConnect = async (
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
setClientInfo({
|
setClientInfo({
|
||||||
name: "1Password GitHub Action",
|
name: "1Password GitHub Action",
|
||||||
id: "GHA",
|
id: "GHA",
|
||||||
@@ -79,6 +125,63 @@ export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Service Account loads secrets via the 1Password SDK
|
||||||
|
const loadSecretsViaServiceAccount = async (
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
const envs = getEnvVarNamesWithSecretRefs();
|
||||||
|
if (envs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSecretRefs(envs);
|
||||||
|
|
||||||
|
const token = process.env[envServiceAccountToken];
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(authErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate with the 1Password SDK
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await createClient({
|
||||||
|
auth: token,
|
||||||
|
integrationName: "1Password GitHub Action",
|
||||||
|
integrationVersion: version,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(`Service account authentication failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const envName of envs) {
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the secret value using the 1Password SDK
|
||||||
|
// and make it available either as step outputs or as environment variables
|
||||||
|
const secretValue = await client.secrets.resolve(ref);
|
||||||
|
setResolvedSecret(envName, secretValue, shouldExportEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envManagedVariables, envs.join());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||||
|
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
|
|
||||||
|
if (isConnect) {
|
||||||
|
await loadSecretsViaConnect(shouldExportEnv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSecretsViaServiceAccount(shouldExportEnv);
|
||||||
|
};
|
||||||
|
|
||||||
export const unsetPrevious = (): void => {
|
export const unsetPrevious = (): void => {
|
||||||
if (process.env[envManagedVariables]) {
|
if (process.env[envManagedVariables]) {
|
||||||
core.info("Unsetting previous values ...");
|
core.info("Unsetting previous values ...");
|
||||||
|
|||||||
7
tests/assert-invalid-ref-failed.sh
Executable file
7
tests/assert-invalid-ref-failed.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ "$STEP_OUTCOME" != "failure" ]; then
|
||||||
|
echo "Expected action to fail on invalid ref, got: $STEP_OUTCOME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Action correctly failed on invalid ref"
|
||||||
Reference in New Issue
Block a user