Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,17 @@ jobs:
- name: 🎭 Install Playwright Chromium
run: npx playwright install chromium --with-deps

- name: 🔐 Install ejson
run: |
sudo apt-get update
sudo apt-get install -y golang-go
go install github.com/Shopify/ejson/cmd/ejson@latest
echo "$HOME/go/bin" >> $GITHUB_PATH
- name: 🧪 Run E2E tests
run: npx playwright test --workers=1
env:
EJSON_PRIVATE_KEY: ${{ secrets.EJSON_PRIVATE_KEY }}

- name: 📤 Upload Playwright Report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,7 @@ skeleton template's devDependencies
- This process often requires TWO cli-hydrogen releases for complete updates
- The circular dependency makes the process complex but is currently unavoidable
- Always check that skeleton's CLI version matches the features available in cli-hydrogen

## Security concerns

All content inside of `secrets.ejson` is sensitive and must NEVER be exposed. We have a pre-commit hook to encrypt any newly added secrets. Some of the secrets inside are used in E2E tests, so we must also be careful to NEVER `console.log` (or otherwise leak/print) anything that was derived from inside of `secrets.ejson`.
114 changes: 114 additions & 0 deletions e2e/fixtures/test-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Test secrets loading module.
*
* Retrieves E2E test secrets via EJSON decryption. Uses a single code path
* for both local development and CI:
*
* - Local: Private key from /opt/ejson/keys/{public_key} (set up via setup script)
* - CI: Private key from EJSON_PRIVATE_KEY env var, passed via --key-from-stdin
*
* This keeps secrets.ejson as the single source of truth for all secrets.
* All fields under `e2e-testing` are automatically loaded - no code changes
* needed when adding new secrets.
*/

import {execFileSync} from 'node:child_process';
import {existsSync} from 'node:fs';
import path from 'node:path';

/**
* All secrets from the `e2e-testing` section of secrets.ejson.
* Access any secret by its snake_case key name from the ejson file.
*
* @example
* // If secrets.ejson has: "e2e-testing": { "gift_card_code_1": "abc123" }
* const secrets = getTestSecrets();
* const code = secrets.gift_card_code_1; // "abc123"
*/
export type TestSecrets = Record<string, string>;

let cachedSecrets: TestSecrets | null = null;

/**
* Loads all secrets from the `e2e-testing` section of secrets.ejson.
* Secrets are cached after first load.
*
* @throws Error if secrets cannot be loaded (ejson not configured)
*/
export function getTestSecrets(): TestSecrets {
if (cachedSecrets) return cachedSecrets;

const fromEjson = loadFromEjson();
if (fromEjson) {
cachedSecrets = fromEjson;
return fromEjson;
}

throw new Error(
'Test secrets not available.\n\n' +
'Local development:\n' +
' Run ./scripts/setup-ejson-private-key.sh to configure ejson\n\n' +
'CI environment:\n' +
' Set EJSON_PRIVATE_KEY environment variable\n',
);
}

/**
* Helper to get a required secret, throwing a clear error if missing.
*
* @example
* const code = getRequiredSecret('gift_card_code_1');
*/
export function getRequiredSecret(key: string): string {
const secrets = getTestSecrets();
const value = secrets[key];

if (!value) {
throw new Error(
`Required secret "${key}" not found in secrets.ejson e2e-testing section.\n` +
`Available keys: ${Object.keys(secrets).join(', ') || '(none)'}`,
);
}

return value;
}

function loadFromEjson(): TestSecrets | null {
const secretsPath = path.resolve(__dirname, '../../secrets.ejson');

if (!existsSync(secretsPath)) return null;

try {
const privateKey = process.env.EJSON_PRIVATE_KEY;

// If private key provided via env var, use --key-from-stdin
// Otherwise, rely on keydir (default /opt/ejson/keys)
const args = privateKey
? ['decrypt', '--key-from-stdin', 'secrets.ejson']
: ['decrypt', 'secrets.ejson'];

const output = execFileSync('ejson', args, {
cwd: path.dirname(secretsPath),
encoding: 'utf-8',
input: privateKey, // piped to stdin when --key-from-stdin is set
stdio: ['pipe', 'pipe', 'pipe'],
});

const secrets = JSON.parse(output) as Record<string, unknown>;
const e2eSection = secrets['e2e-testing'];

if (!e2eSection || typeof e2eSection !== 'object') return null;

// Filter to only string values (the actual secrets)
const e2eSecrets: TestSecrets = {};
for (const [key, value] of Object.entries(e2eSection)) {
if (typeof value === 'string') {
e2eSecrets[key] = value;
}
}

return Object.keys(e2eSecrets).length > 0 ? e2eSecrets : null;
} catch {
return null;
}
}
6 changes: 5 additions & 1 deletion secrets.ejson
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"_public_key": "74b4e417f8d77254e5c7306ec8d493901787b3593991b696aeecf4fe473ac11c",
"slack_cli_release_request_webhook_url": "EJ[1:vNsfQ3NvhF9ue/i7T4FQYYAlyujAr+TjNvbxvmExrgM=:WZmmuzB6REiBimJBZ0YrUuVLO/INSN7h:MtAedTDal1u0XnrxaQ0ZGI1Vf300CNBjTBl/cx7UrQ/PHyoaqAq9QvOlYyhrip9qoP868B8JMfvYTaeF2TNlc92gIR3QtbcXY665IFh+WeUCCROCMHqKcuG3Vh7+k9iIfNFFLzhU3+qlciM=]"
"slack_cli_release_request_webhook_url": "EJ[1:vNsfQ3NvhF9ue/i7T4FQYYAlyujAr+TjNvbxvmExrgM=:WZmmuzB6REiBimJBZ0YrUuVLO/INSN7h:MtAedTDal1u0XnrxaQ0ZGI1Vf300CNBjTBl/cx7UrQ/PHyoaqAq9QvOlYyhrip9qoP868B8JMfvYTaeF2TNlc92gIR3QtbcXY665IFh+WeUCCROCMHqKcuG3Vh7+k9iIfNFFLzhU3+qlciM=]",
"e2e-testing": {
"gift_card_code_1": "EJ[1:4tdIjShfz2rkxXx08xocQISdAXjz+ljb8rX1Jv74IwI=:LpRP+FjcrNIifh0hjvny+wU5t5Mn23Xd:z3uP6eT7K+FgeO2M/+v36IsDqpGqffQSUmFsf9D7/q8=]",
"gift_card_code_2": "EJ[1:4tdIjShfz2rkxXx08xocQISdAXjz+ljb8rX1Jv74IwI=:ffla3Swg1wareH47SDao20DvnUfVmNnX:0tZxPh3MRItBYZ+6svyWle1KPXJVtxSXRUyeoqRY3lo=]"
}
}
Loading