From 5275200dad6e7aa7e523f58584ac0e7e22e810c3 Mon Sep 17 00:00:00 2001 From: Johannes Ewald Date: Thu, 23 Apr 2026 10:52:26 +0200 Subject: [PATCH 1/2] PoC loadSecretsFromEnvFileBatched --- src/index.ts | 15 +++++++-- src/utils.test.ts | 78 +++++++++++++++++++++++++++++++++++++++---- src/utils.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index fcc2552..86bf89f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,12 @@ import dotenv from "dotenv"; 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 { + loadSecrets, + loadSecretsFromEnvFileBatched, + unsetPrevious, + validateAuth, +} from "./utils"; import { envFilePath } from "./constants"; const loadSecretsAction = async () => { @@ -30,7 +35,11 @@ const loadSecretsAction = async () => { await installCLI(); // Load secrets - await loadSecrets(shouldExportEnv); + if (file) { + await loadSecretsFromEnvFileBatched(file, shouldExportEnv); + } else { + await loadSecrets(shouldExportEnv); + } } catch (error) { // It's possible for the Error constructor to be modified to be anything // in JavaScript, so the following code accounts for this possibility. @@ -39,7 +48,7 @@ const loadSecretsAction = async () => { if (error instanceof Error) { message = error.message; } else { - String(error); + message = String(error); } core.setFailed(message); } diff --git a/src/utils.test.ts b/src/utils.test.ts index f66a31f..966c59e 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,9 +1,11 @@ +import fs from "node:fs"; import * as core from "@actions/core"; import * as exec from "@actions/exec"; import { read, setClientInfo } from "@1password/op-js"; import { extractSecret, loadSecrets, + loadSecretsFromEnvFileBatched, unsetPrevious, validateAuth, } from "./utils"; @@ -16,6 +18,13 @@ import { } from "./constants"; jest.mock("@1password/op-js"); +jest.mock("node:fs", () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: { + readFileSync: jest.fn(), + }, +})); beforeEach(() => { jest.clearAllMocks(); @@ -141,11 +150,13 @@ describe("loadSecrets", () => { it("sets the client info and gets the executed output", async () => { await loadSecrets(true); - expect(setClientInfo).toHaveBeenCalledWith({ - name: "1Password GitHub Action", - id: "GHA", - }); - expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"'); + expect(setClientInfo).toHaveBeenCalledWith( + expect.objectContaining({ + name: "1Password GitHub Action", + id: "GHA", + }), + ); + expect(exec.getExecOutput).toHaveBeenCalledWith("op", ["env", "ls"]); expect(core.exportVariable).toHaveBeenCalledWith( "OP_MANAGED_VARIABLES", "MOCK_SECRET", @@ -156,7 +167,7 @@ describe("loadSecrets", () => { (exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" }); await loadSecrets(true); - expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"'); + expect(exec.getExecOutput).toHaveBeenCalledWith("op", ["env", "ls"]); expect(core.exportVariable).not.toHaveBeenCalled(); }); @@ -175,6 +186,61 @@ describe("loadSecrets", () => { }); }); +describe("loadSecretsFromEnvFileBatched", () => { + const envFilePath = "/tmp/test.env"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("loads secrets from env file and sets them as outputs", async () => { + (fs.readFileSync as unknown as jest.Mock).mockReturnValue( + Buffer.from("FOO=op://vault/item/foo\nBAR=op://vault/item/bar\n"), + ); + (exec.getExecOutput as jest.Mock).mockReturnValueOnce({ + stdout: JSON.stringify({ FOO: "foo-value", BAR: "bar-value" }), + }); + + await loadSecretsFromEnvFileBatched(envFilePath, false); + + expect(exec.getExecOutput).toHaveBeenCalledWith( + "op", + expect.arrayContaining([ + "run", + `--env-file=${envFilePath}`, + "--no-masking", + ]), + expect.anything(), + ); + expect(core.setOutput).toHaveBeenCalledWith("FOO", "foo-value"); + expect(core.setOutput).toHaveBeenCalledWith("BAR", "bar-value"); + expect(core.exportVariable).not.toHaveBeenCalledWith( + "OP_MANAGED_VARIABLES", + expect.anything(), + ); + expect(core.setSecret).toHaveBeenCalledWith("foo-value"); + expect(core.setSecret).toHaveBeenCalledWith("bar-value"); + }); + + it("loads secrets from env file and exports them as env vars (including managed list)", async () => { + (fs.readFileSync as unknown as jest.Mock).mockReturnValue( + Buffer.from("FOO=op://vault/item/foo\nBAR=op://vault/item/bar\n"), + ); + (exec.getExecOutput as jest.Mock).mockReturnValueOnce({ + stdout: JSON.stringify({ FOO: "foo-value", BAR: "bar-value" }), + }); + + await loadSecretsFromEnvFileBatched(envFilePath, true); + + expect(core.exportVariable).toHaveBeenCalledWith("FOO", "foo-value"); + expect(core.exportVariable).toHaveBeenCalledWith("BAR", "bar-value"); + expect(core.exportVariable).toHaveBeenCalledWith( + envManagedVariables, + "FOO,BAR", + ); + }); +}); + describe("unsetPrevious", () => { const testManagedEnv = "TEST_SECRET"; const testSecretValue = "MyS3cr#T"; diff --git a/src/utils.ts b/src/utils.ts index 571620d..3650e9f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import dotenv from "dotenv"; import * as core from "@actions/core"; import * as exec from "@actions/exec"; import { read, setClientInfo, semverToInt } from "@1password/op-js"; @@ -10,6 +12,8 @@ import { envManagedVariables, } from "./constants"; +const envFileKeysEnvVar = "OP_KEYS_JSON"; + export const validateAuth = (): void => { const isConnect = process.env[envConnectHost] && process.env[envConnectToken]; const isServiceAccount = process.env[envServiceAccountToken]; @@ -57,6 +61,84 @@ export const extractSecret = ( } }; +const exportResolvedSecret = ( + envName: string, + secretValue: string, + shouldExportEnv: boolean, +): void => { + core.info(`Populating variable: ${envName}`); + if (shouldExportEnv) { + core.exportVariable(envName, secretValue); + } else { + core.setOutput(envName, secretValue); + } + // Skip setSecret for empty strings to avoid the warning: + // "Can't add secret mask for empty string in ##[add-mask] command." + if (secretValue) { + core.setSecret(secretValue); + } +}; + +export const loadSecretsFromEnvFileBatched = async ( + envFile: string, + shouldExportEnv: boolean, +): Promise => { + const envFileBuf = fs.readFileSync(envFile); + const envFileVars = dotenv.parse(envFileBuf); + const envNames = Object.keys(envFileVars); + if (envNames.length === 0) { + return; + } + + // Pass User-Agent Information to the 1Password CLI + setClientInfo({ + name: "1Password GitHub Action", + id: "GHA", + build: semverToInt(version), + }); + + // Resolve all secrets in a single `op run --env-file` call, then emit just the + // keys we care about as JSON. `silent: true` ensures values never hit logs. + const nodeScript = + 'const keys=JSON.parse(process.env.OP_KEYS_JSON||"[]");const out={};for(const k of keys){out[k]=process.env[k]??"";}process.stdout.write(JSON.stringify(out));'; + + const res = await exec.getExecOutput( + "op", + [ + "run", + `--env-file=${envFile}`, + "--no-masking", + "--", + "node", + "-e", + nodeScript, + ], + { + silent: true, + env: { + ...process.env, + [envFileKeysEnvVar]: JSON.stringify(envNames), + }, + }, + ); + + let resolved: Record = {}; + try { + resolved = JSON.parse(res.stdout) as Record; + } catch { + throw new Error("Failed to parse secrets resolved from 1Password CLI."); + } + + for (const envName of envNames) { + const secretValue = resolved[envName] ?? ""; + exportResolvedSecret(envName, secretValue, shouldExportEnv); + } + + if (shouldExportEnv) { + core.exportVariable(envManagedVariables, envNames.join()); + } +}; + export const loadSecrets = async (shouldExportEnv: boolean): Promise => { // Pass User-Agent Information to the 1Password CLI setClientInfo({ @@ -68,7 +150,7 @@ export const loadSecrets = async (shouldExportEnv: boolean): Promise => { // Load secrets from environment variables using 1Password CLI. // Iterate over them to find 1Password references, extract the secret values, // and make them available in the next steps either as step outputs or as environment variables. - const res = await exec.getExecOutput(`sh -c "op env ls"`); + const res = await exec.getExecOutput("op", ["env", "ls"]); if (res.stdout === "") { return; From 993d80bd29f0ab3c9322eb4e9468d486307a10be Mon Sep 17 00:00:00 2001 From: Johannes Ewald Date: Thu, 23 Apr 2026 13:18:19 +0200 Subject: [PATCH 2/2] Build dist --- dist/index.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/dist/index.js b/dist/index.js index 5756c68..04adb3f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -35472,6 +35472,9 @@ const installCliOnGithubActionRunner = async (version) => { +;// CONCATENATED MODULE: external "node:fs" +const external_node_fs_namespaceObject = require("node:fs"); +var external_node_fs_default = /*#__PURE__*/__nccwpck_require__.n(external_node_fs_namespaceObject); ;// CONCATENATED MODULE: ./package.json const package_namespaceObject = {"rE":"4.0.0"}; ;// CONCATENATED MODULE: ./src/constants.ts @@ -35488,6 +35491,9 @@ const authErr = `Authentication error with environment variables: you must set e + + +const envFileKeysEnvVar = "OP_KEYS_JSON"; const validateAuth = () => { const isConnect = process.env[envConnectHost] && process.env[envConnectToken]; const isServiceAccount = process.env[envServiceAccountToken]; @@ -35522,6 +35528,66 @@ const extractSecret = (envName, shouldExportEnv) => { core_setSecret(secretValue); } }; +const exportResolvedSecret = (envName, secretValue, shouldExportEnv) => { + info(`Populating variable: ${envName}`); + if (shouldExportEnv) { + exportVariable(envName, secretValue); + } + else { + setOutput(envName, secretValue); + } + // Skip setSecret for empty strings to avoid the warning: + // "Can't add secret mask for empty string in ##[add-mask] command." + if (secretValue) { + core_setSecret(secretValue); + } +}; +const loadSecretsFromEnvFileBatched = async (envFile, shouldExportEnv) => { + const envFileBuf = external_node_fs_default().readFileSync(envFile); + const envFileVars = main_default().parse(envFileBuf); + const envNames = Object.keys(envFileVars); + if (envNames.length === 0) { + return; + } + // Pass User-Agent Information to the 1Password CLI + (0,dist.setClientInfo)({ + name: "1Password GitHub Action", + id: "GHA", + build: (0,dist.semverToInt)(package_namespaceObject.rE), + }); + // Resolve all secrets in a single `op run --env-file` call, then emit just the + // keys we care about as JSON. `silent: true` ensures values never hit logs. + const nodeScript = 'const keys=JSON.parse(process.env.OP_KEYS_JSON||"[]");const out={};for(const k of keys){out[k]=process.env[k]??"";}process.stdout.write(JSON.stringify(out));'; + const res = await getExecOutput("op", [ + "run", + `--env-file=${envFile}`, + "--no-masking", + "--", + "node", + "-e", + nodeScript, + ], { + silent: true, + env: { + ...process.env, + [envFileKeysEnvVar]: JSON.stringify(envNames), + }, + }); + let resolved = {}; + try { + resolved = JSON.parse(res.stdout); + } + catch { + throw new Error("Failed to parse secrets resolved from 1Password CLI."); + } + for (const envName of envNames) { + const secretValue = resolved[envName] ?? ""; + exportResolvedSecret(envName, secretValue, shouldExportEnv); + } + if (shouldExportEnv) { + exportVariable(envManagedVariables, envNames.join()); + } +}; const loadSecrets = async (shouldExportEnv) => { // Pass User-Agent Information to the 1Password CLI (0,dist.setClientInfo)({ @@ -35532,7 +35598,7 @@ const loadSecrets = async (shouldExportEnv) => { // Load secrets from environment variables using 1Password CLI. // Iterate over them to find 1Password references, extract the secret values, // and make them available in the next steps either as step outputs or as environment variables. - const res = await getExecOutput(`sh -c "op env ls"`); + const res = await getExecOutput("op", ["env", "ls"]); if (res.stdout === "") { return; } @@ -35582,7 +35648,12 @@ const loadSecretsAction = async () => { // Download and install the CLI await installCLI(); // Load secrets - await loadSecrets(shouldExportEnv); + if (file) { + await loadSecretsFromEnvFileBatched(file, shouldExportEnv); + } + else { + await loadSecrets(shouldExportEnv); + } } catch (error) { // It's possible for the Error constructor to be modified to be anything @@ -35593,7 +35664,7 @@ const loadSecretsAction = async () => { message = error.message; } else { - String(error); + message = String(error); } setFailed(message); }