Compare commits

..

32 Commits

Author SHA1 Message Date
Jill Regan
dd76a122aa Update dists 2026-02-26 11:22:17 -05:00
Jill Regan
a665f2c1ab Merge pull request #135 from 1Password/jill/validate-secret-reference
Add secret ref validation
2026-02-23 11:42:24 -05:00
Jill Regan
398c918d60 Fix format 2026-02-23 08:13:30 -05:00
Jill Regan
dc90451a94 Apply code suggestions 2026-02-22 12:31:37 -05:00
Jill Regan
9d7acefac9 Move comment 2026-02-20 08:34:52 -05:00
Jill Regan
04984a6c91 Add eslint disable 2026-02-20 08:31:14 -05:00
Jill Regan
db7314de7b Merge branch 'feature/migrate-to-sdk' into jill/validate-secret-reference 2026-02-20 08:24:44 -05:00
Jill Regan
3f9ba481c9 Merge pull request #134 from 1Password/jill/use-sdk-for-service-account
Migrate to use  1Password SDK with Service Account
2026-02-20 08:22:34 -05:00
Jill Regan
1e8273d4be Fix formatting 2026-02-19 14:24:51 -05:00
Jill Regan
015b03300e Code cleanup 2026-02-19 14:17:40 -05:00
Jill Regan
ab44f9f69c Remove unit test 2026-02-18 18:01:22 -05:00
Jill Regan
af49dd18de Remove connect handling 2026-02-18 18:00:42 -05:00
Jill Regan
d456b72513 Add connect e2e test 2026-02-18 17:50:34 -05:00
Jill Regan
2a828228a8 Try woth test failure 2026-02-18 17:48:21 -05:00
Jill Regan
604a86ce4e Make assert-invalid-ref-failed.sh executable 2026-02-18 17:44:26 -05:00
Jill Regan
7998453500 Update script 2026-02-18 17:41:39 -05:00
Jill Regan
e7fe4397d9 Add e2e test 2026-02-18 17:38:00 -05:00
Jill Regan
6911316fe3 Add secret ref validation 2026-02-18 17:24:55 -05:00
Jill Regan
24235f3b6b Fix formatting 2026-02-18 16:37:41 -05:00
Jill Regan
a2ce22dd39 Add error handling 2026-02-18 16:35:10 -05:00
Jill Regan
d2fdd9df66 Update unit test 2026-02-18 14:12:14 -05:00
Jill Regan
95478552e8 Fix linting issues 2026-02-18 13:57:08 -05:00
Jill Regan
4a997a0402 Use SDK with service account 2026-02-18 13:48:19 -05:00
Volodymyr Zotov
81bc2a50b4 Merge pull request #133 from 1Password/vzt/fix-commit-ref-in-e2e-test-workflow
Pass latest commit ref to checkout
2026-01-28 08:35:26 -06:00
Volodymyr Zotov
1dfe1fc19e Build action before testing 2026-01-27 14:19:04 -06:00
Volodymyr Zotov
856971e6d6 Pass latest commit ref to checkout 2026-01-27 12:12:48 -06:00
Volodymyr Zotov
5fd6fbcfdf Merge pull request #132 from toga4/empty-strings
fix: set outputs/env vars for empty string field values
2026-01-27 10:06:27 -06:00
toga4
13f927c806 fix: set outputs/env vars for empty string field values
Empty string field values from 1Password were causing the action to skip setting outputs and environment variables entirely.
This was inconsistent with `op run` behavior, which sets the variable with an empty value.

- Change falsy check to explicit null/undefined check in extractSecret
- Skip setSecret for empty strings to avoid runner warning
- Add tests for empty string value handling
2026-01-23 10:38:43 +09:00
Volodymyr Zotov
fdb192f5dc Merge pull request #130 from BolajiOlajide/bo/ssh-secret-doc
docs: add SSH key format parameter documentation
2025-12-22 11:19:47 -06:00
Bolaji Olajide
13c259d353 update 2025-12-22 18:08:38 +01:00
Bolaji Olajide
b91fef0861 move section to quoickstart 2025-12-22 16:33:55 +01:00
Bolaji Olajide
2d74546fd1 docs: add SSH key format parameter documentation 2025-12-21 05:41:19 +01:00
12 changed files with 3483 additions and 469 deletions

View File

@@ -8,6 +8,11 @@ on:
# For test.yml to call this workflow
workflow_call:
inputs:
ref:
description: "Git ref to checkout"
required: true
type: string
secrets:
OP_CONNECT_CREDENTIALS:
required: true
@@ -34,6 +39,19 @@ jobs:
uses: actions/checkout@v5
with:
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
shell: bash
@@ -87,6 +105,22 @@ jobs:
shell: bash
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:
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
runs-on: ubuntu-latest
@@ -101,6 +135,19 @@ jobs:
uses: actions/checkout@v5
with:
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
run: |

View File

@@ -26,6 +26,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
condition: ${{ steps.check.outputs.condition }}
ref: ${{ steps.check.outputs.ref }}
steps:
- name: Check if PR is from external contributor
id: check
@@ -45,6 +46,7 @@ jobs:
else
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
echo "ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
fi
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
# 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
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
echo "Setting condition=dispatch-event (sha matches)"
echo "ref=$PR_HEAD_SHA" >> $GITHUB_OUTPUT
else
echo "condition=skip" >> $GITHUB_OUTPUT
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
echo "condition=push-to-main" >> $GITHUB_OUTPUT
echo "Setting condition=push-to-main (push to main)"
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
else
# Unknown event type
echo "condition=skip" >> $GITHUB_OUTPUT
@@ -80,6 +84,8 @@ jobs:
||
needs.check-external-pr.outputs.condition == 'push-to-main'
uses: ./.github/workflows/e2e-tests.yml
with:
ref: ${{ needs.check-external-pr.outputs.ref }}
secrets:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}

View File

@@ -71,6 +71,21 @@ jobs:
# 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
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.

File diff suppressed because one or more lines are too long

BIN
dist/core_bg.wasm vendored Normal file

Binary file not shown.

2601
dist/index.js vendored

File diff suppressed because one or more lines are too long

16
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@1password/sdk": "^0.4.0",
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/tool-cache": "^2.0.2",
@@ -72,6 +73,21 @@
"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": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",

View File

@@ -41,6 +41,7 @@
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@1password/sdk": "^0.4.0",
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/tool-cache": "^2.0.2",

View File

@@ -3,7 +3,7 @@ import * as core from "@actions/core";
import { validateCli } from "@1password/op-js";
import { installCliOnGithubActionRunner } from "./op-cli-installer";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
import { envFilePath } from "./constants";
import { envFilePath, envConnectHost, envConnectToken } from "./constants";
const loadSecretsAction = async () => {
try {
@@ -26,8 +26,12 @@ const loadSecretsAction = async () => {
dotenv.config({ path: file });
}
// Download and install the CLI
await installCLI();
const isConnect =
process.env[envConnectHost] && process.env[envConnectToken];
// If Connect is used, download and install the CLI
if (isConnect) {
await installCLI();
}
// Load secrets
await loadSecrets(shouldExportEnv);

View File

@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import {
extractSecret,
loadSecrets,
@@ -22,6 +23,13 @@ jest.mock("@actions/exec", () => ({
})),
}));
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(() => {
jest.clearAllMocks();
@@ -106,9 +114,50 @@ describe("extractSecret", () => {
);
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 () => {
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", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";

View File

@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo, semverToInt } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import { version } from "../package.json";
import {
authErr,
@@ -29,32 +30,77 @@ export const validateAuth = (): void => {
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,
secretValue: string,
shouldExportEnv: boolean,
): void => {
core.info(`Populating variable: ${envName}`);
const ref = process.env[envName];
if (!ref) {
return;
}
const secretValue = read.parse(ref);
if (!secretValue) {
return;
}
if (shouldExportEnv) {
core.exportVariable(envName, secretValue);
} else {
core.setOutput(envName, secretValue);
}
core.setSecret(secretValue);
if (secretValue) {
core.setSecret(secretValue);
}
};
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
// Pass User-Agent Information to the 1Password CLI
export const extractSecret = (
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({
name: "1Password GitHub Action",
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 => {
if (process.env[envManagedVariables]) {
core.info("Unsetting previous values ...");

View 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"