From 457964ae3b816bd786aac640a49f81812045167b Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 17:37:36 +0300 Subject: [PATCH 1/4] feat: forward allowlisted runtime environment to test container --- .github/actions/run-e2e/action.yml | 21 ++ .github/actions/run-e2e/complete-shard.mjs | 13 +- .github/actions/run-e2e/runtime-env.mjs | 40 ++++ .github/tests/runtime-env.test.mjs | 259 +++++++++++++++++++++ README.md | 28 ++- action.yml | 5 + 6 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 .github/actions/run-e2e/runtime-env.mjs create mode 100644 .github/tests/runtime-env.test.mjs diff --git a/.github/actions/run-e2e/action.yml b/.github/actions/run-e2e/action.yml index 971eddd..77d0bb3 100644 --- a/.github/actions/run-e2e/action.yml +++ b/.github/actions/run-e2e/action.yml @@ -47,6 +47,10 @@ inputs: description: Results path relative to working-directory. required: false default: test-results + runtime-env: + description: Optional newline-delimited names of non-empty workflow environment variables to forward only to the test container. Never pass values here. + required: false + default: "" outputs: result: @@ -157,10 +161,26 @@ runs: E2E_RUNNER_IMAGE_ID: ${{ steps.runner.outputs.actual-image-id }} E2E_SHARD_DIRECTORY: ${{ steps.initialize.outputs.shard-directory }} E2E_SELECTION_FILE: ${{ steps.initialize.outputs.selection-file }} + E2E_RUNTIME_ENV: ${{ inputs.runtime-env }} E2E_SHARD_INDEX: ${{ inputs.shard-index }} E2E_SHARD_TOTAL: ${{ inputs.shard-total }} run: | set +e + runtime_env_names="$(node "$GITHUB_ACTION_PATH/runtime-env.mjs")" + runtime_env_exit_code=$? + if [[ "$runtime_env_exit_code" -ne 0 ]]; then + echo "exit-code=$runtime_env_exit_code" >> "$GITHUB_OUTPUT" + echo "failure-kind=runtime-environment" >> "$GITHUB_OUTPUT" + exit "$runtime_env_exit_code" + fi + + runtime_env_args=() + while IFS= read -r name; do + if [[ -n "$name" ]]; then + runtime_env_args+=(--env "$name") + fi + done <<< "$runtime_env_names" + selection_args_file="$E2E_SHARD_DIRECTORY/.playwright-selection-args" node "$GITHUB_ACTION_PATH/playwright-selection.mjs" \ emit-args "$E2E_SELECTION_FILE" > "$selection_args_file" @@ -193,6 +213,7 @@ runs: --env PLAYWRIGHT_BLOB_OUTPUT_DIR=/test-output/blob-report \ --env PLAYWRIGHT_OUTPUT_DIR=/test-output/test-results \ --env CTRF_OUTPUT_FILE=/test-output/ctrf/ctrf-report.json \ + "${runtime_env_args[@]}" \ --volume "$E2E_SHARD_DIRECTORY:/test-output" \ "$E2E_RUNNER_IMAGE_ID" \ --config "$E2E_CONFIG" \ diff --git a/.github/actions/run-e2e/complete-shard.mjs b/.github/actions/run-e2e/complete-shard.mjs index 9aa2b69..8fbbe50 100644 --- a/.github/actions/run-e2e/complete-shard.mjs +++ b/.github/actions/run-e2e/complete-shard.mjs @@ -83,11 +83,15 @@ if ( phase: requestedPlaywrightFailureKind === "selection" ? "playwright-selection" - : "runner-container", + : requestedPlaywrightFailureKind === "runtime-environment" + ? "runtime-environment" + : "runner-container", message: requestedPlaywrightFailureKind === "selection" ? `Playwright selection preparation exited with ${playwrightExitCode}` - : `E2E runner container exited with ${playwrightExitCode}`, + : requestedPlaywrightFailureKind === "runtime-environment" + ? "Runtime environment validation failed; the test container was not started" + : `E2E runner container exited with ${playwrightExitCode}`, }); } else if (playwrightExitCode !== null && playwrightExitCode > 0) { failures.push({ @@ -114,6 +118,7 @@ const exitCodeForFailure = (failure) => { switch (failure?.phase) { case "playwright": case "playwright-selection": + case "runtime-environment": case "runner-container": return playwrightExitCode ?? 1; case "runner-image": @@ -128,7 +133,9 @@ status.lifecycle.runner.verificationExitCode = runnerExitCode; status.lifecycle.runner.actualImageId = actualRunnerImageId || null; status.lifecycle.playwright = { exitCode: playwrightExitCode, - started: playwrightExitCode !== null, + started: + playwrightExitCode !== null && + requestedPlaywrightFailureKind !== "runtime-environment", }; status.finishedAt = finishedAt.toISOString(); status.durationMs = Math.max(0, finishedAt.getTime() - startedAt.getTime()); diff --git a/.github/actions/run-e2e/runtime-env.mjs b/.github/actions/run-e2e/runtime-env.mjs new file mode 100644 index 0000000..a6ace66 --- /dev/null +++ b/.github/actions/run-e2e/runtime-env.mjs @@ -0,0 +1,40 @@ +const reservedNames = new Set([ + "BASE_URL", + "CI", + "HOME", + "PATH", + "NODE_OPTIONS", + "NODE_PATH", + "BASH_ENV", + "ENV", +]); +const reservedPrefixes = + /^(E2E_|PLAYWRIGHT_|CTRF_|GITHUB_|RUNNER_|INPUT_|DOCKER_|LD_|DYLD_)/; + +try { + const names = new Set(); + for (const line of (process.env.E2E_RUNTIME_ENV ?? "").split(/\r?\n/)) { + const name = line.trim(); + if (!name) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error( + "runtime-env accepts variable names only, one per line; assignments and other characters are not allowed", + ); + } + if (reservedNames.has(name) || reservedPrefixes.test(name)) { + throw new Error( + "runtime-env cannot forward reserved runner or process-control variables", + ); + } + if (!Object.hasOwn(process.env, name) || process.env[name] === "") { + throw new Error( + "Every runtime-env variable must be defined and non-empty in the action environment", + ); + } + names.add(name); + } + if (names.size) process.stdout.write(`${[...names].join("\n")}\n`); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/.github/tests/runtime-env.test.mjs b/.github/tests/runtime-env.test.mjs new file mode 100644 index 0000000..f4076a1 --- /dev/null +++ b/.github/tests/runtime-env.test.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const actionPath = join(root, ".github/actions/run-e2e"); +const secret = "fake-secret spaces 'quotes' $dollar;=\nsecond line"; +const imageId = `sha256:${"a".repeat(64)}`; + +function parseYaml(path) { + const result = spawnSync( + "ruby", + [ + "-ryaml", + "-rjson", + "-e", + "puts JSON.generate(YAML.load_file(ARGV[0]))", + path, + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +const rootAction = parseYaml(join(root, "action.yml")); +const runAction = parseYaml(join(actionPath, "action.yml")); +const runStep = runAction.runs.steps.find((step) => step.id === "playwright"); + +function validate(input, env = {}) { + return spawnSync(process.execPath, [join(actionPath, "runtime-env.mjs")], { + encoding: "utf8", + env: { E2E_RUNTIME_ENV: input, ...env }, + }); +} + +test("Root action forwards the optional name allowlist only to the run subaction", () => { + for (const action of [rootAction, runAction]) { + assert.equal(action.inputs["runtime-env"].default, ""); + assert.equal(action.inputs["runtime-env"].required, false); + } + const consumers = rootAction.runs.steps.filter( + (step) => step.with?.["runtime-env"], + ); + assert.deepEqual( + consumers.map((step) => step.id), + ["run"], + ); + assert.equal(consumers[0].with["runtime-env"], "${{ inputs.runtime-env }}"); + assert.equal(runStep.env.E2E_RUNTIME_ENV, "${{ inputs.runtime-env }}"); +}); + +test("Empty input preserves the existing environment and no forwarding arguments", () => { + const result = validate("\n \r\n"); + assert.equal(result.status, 0); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); +}); + +test("Whitespace and duplicate names normalize without serializing opaque values", () => { + const result = validate( + " STAGING_API_TOKEN\r\n\nother_2 \nSTAGING_API_TOKEN", + { + STAGING_API_TOKEN: secret, + other_2: "0", + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "STAGING_API_TOKEN\nother_2\n"); + assert.equal(result.stderr, ""); + assert.ok(!result.stdout.includes(secret)); +}); + +for (const [label, input, env] of [ + ["assignment", `TOKEN=${secret}`, {}], + ["comma-delimited names", "FIRST,SECOND", {}], + ["shell expansion", "$(printf secret)", {}], + ["invalid identifier", "1TOKEN", {}], + ["hyphen", "TOKEN-KEY", {}], + ["missing value", "TOKEN", {}], + ["empty value", "TOKEN", { TOKEN: "" }], + ["late invalid name", `TOKEN\nBAD=${secret}`, { TOKEN: secret }], +]) { + test(`Reject ${label} without outputting any names, raw input, or secret values`, () => { + const result = validate(input, env); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.ok(!result.stderr.includes(secret)); + assert.ok(!result.stderr.includes(input)); + }); +} + +for (const name of [ + "BASE_URL", + "CI", + "HOME", + "PATH", + "NODE_OPTIONS", + "NODE_PATH", + "BASH_ENV", + "ENV", + "E2E_CONFIG", + "PLAYWRIGHT_OUTPUT_DIR", + "CTRF_OUTPUT_FILE", + "GITHUB_TOKEN", + "RUNNER_TEMP", + "INPUT_TOKEN", + "DOCKER_HOST", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", +]) { + test(`Reject reserved ${name} without exposing its value`, () => { + // Loader variables take effect before Node can validate the requested name. + const env = /^(NODE_OPTIONS|LD_|DYLD_)/.test(name) + ? {} + : { [name]: secret }; + const result = validate(name, env); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /reserved/); + assert.ok(!result.stderr.includes(secret)); + }); +} + +function workspace(t) { + const directory = mkdtempSync(join(tmpdir(), "playwright-runtime-env-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const shard = join(directory, "shard"); + mkdirSync(shard); + mkdirSync(join(directory, "bin")); + writeFileSync( + join(directory, "selection.json"), + JSON.stringify({ + projects: ["api"], + labels: [], + labelMatch: "all", + grep: null, + }), + ); + writeFileSync( + join(directory, "bin/docker"), + `#!${process.execPath} +const fs = require('node:fs'); +const args = process.argv.slice(2); +const forwarded = args.filter((value, index) => args[index - 1] === '--env' && !value.includes('=')); +fs.writeFileSync(process.env.DOCKER_CAPTURE, JSON.stringify({ args, forwarded, tokenMatches: process.env.STAGING_API_TOKEN === process.env.EXPECTED_TOKEN })); +`, + { mode: 0o755 }, + ); + return { directory, shard }; +} + +function runShell(t, input, token = secret) { + const { directory, shard } = workspace(t); + const output = join(directory, "output"); + const capture = join(directory, "docker.json"); + const result = spawnSync("bash", ["-c", runStep.run], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(directory, "bin")}:${process.env.PATH}`, + GITHUB_ACTION_PATH: actionPath, + GITHUB_OUTPUT: output, + E2E_RUNTIME_ENV: input, + E2E_SHARD_DIRECTORY: shard, + E2E_SELECTION_FILE: join(directory, "selection.json"), + E2E_BASE_URL: "https://staging.example.com", + E2E_CONFIG: "/e2e/playwright.config.ts", + E2E_RUNNER_IMAGE_ID: imageId, + E2E_SHARD_INDEX: "1", + E2E_SHARD_TOTAL: "1", + STAGING_API_TOKEN: token, + UNLISTED_TOKEN: "must-not-forward", + EXPECTED_TOKEN: token, + DOCKER_CAPTURE: capture, + }, + }); + return { + ...result, + directory, + shard, + output: readFileSync(output, "utf8"), + capture, + }; +} + +for (const input of ["", "STAGING_API_TOKEN\nSTAGING_API_TOKEN"]) { + test(`Actual run step gives Docker only deduplicated names for ${input ? "selected credentials" : "omitted forwarding"}`, (t) => { + const result = runShell(t, input); + assert.equal(result.status, 0, result.stderr); + const invocation = JSON.parse(readFileSync(result.capture, "utf8")); + assert.deepEqual(invocation.forwarded, input ? ["STAGING_API_TOKEN"] : []); + assert.equal(invocation.tokenMatches, true); + assert.ok(invocation.args.includes("BASE_URL=https://staging.example.com")); + assert.ok(!JSON.stringify(invocation).includes(secret)); + assert.ok(!result.output.includes(secret)); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + assert.match(result.output, /exit-code=0\nfailure-kind=test/); + for (const entry of readdirSync(result.shard)) { + assert.ok( + !readFileSync(join(result.shard, entry), "utf8").includes(secret), + ); + } + }); +} + +test("Invalid runtime configuration never starts Docker and finalizes as an infrastructure failure", (t) => { + const result = runShell(t, `STAGING_API_TOKEN=${secret}`); + assert.equal(result.status, 1); + assert.ok(!readdirSync(result.directory).includes("docker.json")); + assert.equal( + result.output, + "exit-code=1\nfailure-kind=runtime-environment\n", + ); + assert.ok(!result.stderr.includes(secret)); + + const statusFile = join(result.shard, "shard-status.json"); + writeFileSync( + statusFile, + JSON.stringify({ + startedAt: new Date().toISOString(), + lifecycle: { runner: {} }, + }), + ); + const completed = spawnSync( + process.execPath, + [ + join(actionPath, "complete-shard.mjs"), + statusFile, + "success", + "success", + "0", + imageId, + "1", + "runtime-environment", + join(result.directory, "final-output"), + ], + { encoding: "utf8" }, + ); + assert.equal(completed.status, 1); + const status = JSON.parse(readFileSync(statusFile, "utf8")); + assert.equal(status.result, "infrastructure-error"); + assert.equal(status.primaryFailure.phase, "runtime-environment"); + assert.equal(status.lifecycle.playwright.started, false); + assert.ok(!JSON.stringify(status).includes(secret)); + assert.ok(!completed.stderr.includes(secret)); +}); diff --git a/README.md b/README.md index a739433..5c4dd18 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The template owns repository policy and application lifecycle: triggers, permiss ## Use the action -Check out the caller repository and make the target ready before invoking the root action. Use a major-version tag. This URL-only recipe requires a compatible release: currently `v0` points to `v0.2.0`, which still requires Compose. The [starter recipe PR](https://github.com/codotech/playwright-e2e-starter/pull/1) depends on releasing this change first: +Check out the caller repository and make the target ready before invoking the root action. Use a major-version tag that includes the URL-only contract (introduced in `v0.3.0`): ```yaml permissions: @@ -40,6 +40,7 @@ Use version tags such as `@v0` or `@v1`, not commit SHAs or feature branches. Ve | `projects` | empty | Optional Playwright project override, one per line | | `labels` | empty | Optional Playwright tag override, one per line | | `label-match` | empty | Optional `all` or `any` tag matching override | +| `runtime-env` | empty | Optional environment variable names to forward to the test container, one per line; never values | Projects and tags are independent filters. Projects select configured Playwright variants. Tags select tests through Playwright's `--grep`. The action runs the intersection. @@ -119,7 +120,30 @@ To run the same suites locally after installing their dependencies: BASE_URL=https://staging.example.com pnpm --dir e2e test ``` -No Compose commands are needed. Use only environments you are authorized to test. Do not embed credentials in the URL. Arbitrary workflow environment variables, including API tokens, are not currently forwarded into the runner container. +No Compose commands are needed. Use only environments you are authorized to test. Do not embed credentials in the URL. Workflow environment variables are not forwarded into the runner container unless explicitly listed in `runtime-env`. + +### Pass runtime credentials to tests + +`runtime-env` is unreleased. Wait for a release containing this input under your selected major-version tag before using this example; do not substitute a commit SHA or feature branch. + +Provide secret values through the action step's `env` and list only variable names in `runtime-env`: + +```yaml +- id: e2e + uses: codotech/playwright-e2e@v0 + env: + STAGING_API_TOKEN: ${{ secrets.E2E_STAGING_API_KEY }} + with: + profile: pull-request + runtime-env: | + STAGING_API_TOKEN +``` + +Tests read `process.env.STAGING_API_TOKEN`. The action passes `--env STAGING_API_TOKEN` to Docker, without placing its value in command arguments, generated files, runner images, or cache identities. Forwarding applies only to the test container, not the build, merge, or report containers. Tests and dependencies can still expose credentials in logs or traces; the caller must protect those artifacts and run only trusted code. Fork pull requests normally cannot access secrets and must not run authenticated suites. + +Names must match `[A-Za-z_][A-Za-z0-9_]*`. Blank lines are ignored, surrounding whitespace is trimmed, and duplicate names are forwarded once. Every selected variable must exist and be non-empty. Invalid names, assignments such as `TOKEN=value`, missing values, and reserved names fail as `infrastructure-error` before the test container starts. Validation errors never echo the input or values. + +Reserved names are `BASE_URL`, `CI`, `HOME`, `PATH`, `NODE_OPTIONS`, `NODE_PATH`, `BASH_ENV`, and `ENV`. Prefixes `E2E_`, `PLAYWRIGHT_`, `CTRF_`, `GITHUB_`, `RUNNER_`, `INPUT_`, `DOCKER_`, `LD_`, and `DYLD_` are also reserved. Use an application-specific name such as `STAGING_API_TOKEN` instead. Omitting `runtime-env` preserves the default container environment. ### Migrate from action-managed Compose diff --git a/action.yml b/action.yml index bbf81f1..dba9610 100644 --- a/action.yml +++ b/action.yml @@ -23,6 +23,10 @@ inputs: description: Optional label matching override, either all or any. required: false default: "" + runtime-env: + description: Optional newline-delimited names of non-empty workflow environment variables to forward only to the test container. Never pass values here. + required: false + default: "" outputs: result: @@ -193,6 +197,7 @@ runs: shard-index: "1" shard-total: "1" results-directory: test-results + runtime-env: ${{ inputs.runtime-env }} - id: merge name: Merge E2E results From e871a010b1a1e25c16ec59bee62902bdd40356ac Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 17:40:57 +0300 Subject: [PATCH 2/4] Simplify runtime environment forwarding into existing shell step --- .github/actions/run-e2e/action.yml | 25 ++- .github/actions/run-e2e/runtime-env.mjs | 40 ---- .github/tests/runtime-env.test.mjs | 259 ------------------------ 3 files changed, 14 insertions(+), 310 deletions(-) delete mode 100644 .github/actions/run-e2e/runtime-env.mjs delete mode 100644 .github/tests/runtime-env.test.mjs diff --git a/.github/actions/run-e2e/action.yml b/.github/actions/run-e2e/action.yml index 77d0bb3..38abad6 100644 --- a/.github/actions/run-e2e/action.yml +++ b/.github/actions/run-e2e/action.yml @@ -166,20 +166,23 @@ runs: E2E_SHARD_TOTAL: ${{ inputs.shard-total }} run: | set +e - runtime_env_names="$(node "$GITHUB_ACTION_PATH/runtime-env.mjs")" - runtime_env_exit_code=$? - if [[ "$runtime_env_exit_code" -ne 0 ]]; then - echo "exit-code=$runtime_env_exit_code" >> "$GITHUB_OUTPUT" - echo "failure-kind=runtime-environment" >> "$GITHUB_OUTPUT" - exit "$runtime_env_exit_code" - fi - runtime_env_args=() while IFS= read -r name; do - if [[ -n "$name" ]]; then - runtime_env_args+=(--env "$name") + name="${name#"${name%%[![:space:]]*}"}" + name="${name%"${name##*[![:space:]]}"}" + [[ -z "$name" ]] && continue + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || + [[ "$name" =~ ^(BASE_URL|CI|HOME|PATH|NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV)$ ]] || + [[ "$name" =~ ^(E2E_|PLAYWRIGHT_|CTRF_|GITHUB_|RUNNER_|INPUT_|DOCKER_|LD_|DYLD_) ]] || + [[ -z "${!name:-}" ]]; then + echo "::error::runtime-env requires non-reserved variable names with non-empty values; never pass assignments." + echo "exit-code=1" >> "$GITHUB_OUTPUT" + echo "failure-kind=runtime-environment" >> "$GITHUB_OUTPUT" + exit 1 fi - done <<< "$runtime_env_names" + [[ " ${runtime_env_args[*]} " == *" --env $name "* ]] && continue + runtime_env_args+=(--env "$name") + done <<< "$E2E_RUNTIME_ENV" selection_args_file="$E2E_SHARD_DIRECTORY/.playwright-selection-args" node "$GITHUB_ACTION_PATH/playwright-selection.mjs" \ diff --git a/.github/actions/run-e2e/runtime-env.mjs b/.github/actions/run-e2e/runtime-env.mjs deleted file mode 100644 index a6ace66..0000000 --- a/.github/actions/run-e2e/runtime-env.mjs +++ /dev/null @@ -1,40 +0,0 @@ -const reservedNames = new Set([ - "BASE_URL", - "CI", - "HOME", - "PATH", - "NODE_OPTIONS", - "NODE_PATH", - "BASH_ENV", - "ENV", -]); -const reservedPrefixes = - /^(E2E_|PLAYWRIGHT_|CTRF_|GITHUB_|RUNNER_|INPUT_|DOCKER_|LD_|DYLD_)/; - -try { - const names = new Set(); - for (const line of (process.env.E2E_RUNTIME_ENV ?? "").split(/\r?\n/)) { - const name = line.trim(); - if (!name) continue; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - throw new Error( - "runtime-env accepts variable names only, one per line; assignments and other characters are not allowed", - ); - } - if (reservedNames.has(name) || reservedPrefixes.test(name)) { - throw new Error( - "runtime-env cannot forward reserved runner or process-control variables", - ); - } - if (!Object.hasOwn(process.env, name) || process.env[name] === "") { - throw new Error( - "Every runtime-env variable must be defined and non-empty in the action environment", - ); - } - names.add(name); - } - if (names.size) process.stdout.write(`${[...names].join("\n")}\n`); -} catch (error) { - console.error(error.message); - process.exitCode = 1; -} diff --git a/.github/tests/runtime-env.test.mjs b/.github/tests/runtime-env.test.mjs deleted file mode 100644 index f4076a1..0000000 --- a/.github/tests/runtime-env.test.mjs +++ /dev/null @@ -1,259 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const root = fileURLToPath(new URL("../../", import.meta.url)); -const actionPath = join(root, ".github/actions/run-e2e"); -const secret = "fake-secret spaces 'quotes' $dollar;=\nsecond line"; -const imageId = `sha256:${"a".repeat(64)}`; - -function parseYaml(path) { - const result = spawnSync( - "ruby", - [ - "-ryaml", - "-rjson", - "-e", - "puts JSON.generate(YAML.load_file(ARGV[0]))", - path, - ], - { encoding: "utf8" }, - ); - assert.equal(result.status, 0, result.stderr); - return JSON.parse(result.stdout); -} - -const rootAction = parseYaml(join(root, "action.yml")); -const runAction = parseYaml(join(actionPath, "action.yml")); -const runStep = runAction.runs.steps.find((step) => step.id === "playwright"); - -function validate(input, env = {}) { - return spawnSync(process.execPath, [join(actionPath, "runtime-env.mjs")], { - encoding: "utf8", - env: { E2E_RUNTIME_ENV: input, ...env }, - }); -} - -test("Root action forwards the optional name allowlist only to the run subaction", () => { - for (const action of [rootAction, runAction]) { - assert.equal(action.inputs["runtime-env"].default, ""); - assert.equal(action.inputs["runtime-env"].required, false); - } - const consumers = rootAction.runs.steps.filter( - (step) => step.with?.["runtime-env"], - ); - assert.deepEqual( - consumers.map((step) => step.id), - ["run"], - ); - assert.equal(consumers[0].with["runtime-env"], "${{ inputs.runtime-env }}"); - assert.equal(runStep.env.E2E_RUNTIME_ENV, "${{ inputs.runtime-env }}"); -}); - -test("Empty input preserves the existing environment and no forwarding arguments", () => { - const result = validate("\n \r\n"); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); -}); - -test("Whitespace and duplicate names normalize without serializing opaque values", () => { - const result = validate( - " STAGING_API_TOKEN\r\n\nother_2 \nSTAGING_API_TOKEN", - { - STAGING_API_TOKEN: secret, - other_2: "0", - }, - ); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "STAGING_API_TOKEN\nother_2\n"); - assert.equal(result.stderr, ""); - assert.ok(!result.stdout.includes(secret)); -}); - -for (const [label, input, env] of [ - ["assignment", `TOKEN=${secret}`, {}], - ["comma-delimited names", "FIRST,SECOND", {}], - ["shell expansion", "$(printf secret)", {}], - ["invalid identifier", "1TOKEN", {}], - ["hyphen", "TOKEN-KEY", {}], - ["missing value", "TOKEN", {}], - ["empty value", "TOKEN", { TOKEN: "" }], - ["late invalid name", `TOKEN\nBAD=${secret}`, { TOKEN: secret }], -]) { - test(`Reject ${label} without outputting any names, raw input, or secret values`, () => { - const result = validate(input, env); - assert.equal(result.status, 1); - assert.equal(result.stdout, ""); - assert.ok(!result.stderr.includes(secret)); - assert.ok(!result.stderr.includes(input)); - }); -} - -for (const name of [ - "BASE_URL", - "CI", - "HOME", - "PATH", - "NODE_OPTIONS", - "NODE_PATH", - "BASH_ENV", - "ENV", - "E2E_CONFIG", - "PLAYWRIGHT_OUTPUT_DIR", - "CTRF_OUTPUT_FILE", - "GITHUB_TOKEN", - "RUNNER_TEMP", - "INPUT_TOKEN", - "DOCKER_HOST", - "LD_PRELOAD", - "DYLD_INSERT_LIBRARIES", -]) { - test(`Reject reserved ${name} without exposing its value`, () => { - // Loader variables take effect before Node can validate the requested name. - const env = /^(NODE_OPTIONS|LD_|DYLD_)/.test(name) - ? {} - : { [name]: secret }; - const result = validate(name, env); - assert.equal(result.status, 1); - assert.equal(result.stdout, ""); - assert.match(result.stderr, /reserved/); - assert.ok(!result.stderr.includes(secret)); - }); -} - -function workspace(t) { - const directory = mkdtempSync(join(tmpdir(), "playwright-runtime-env-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const shard = join(directory, "shard"); - mkdirSync(shard); - mkdirSync(join(directory, "bin")); - writeFileSync( - join(directory, "selection.json"), - JSON.stringify({ - projects: ["api"], - labels: [], - labelMatch: "all", - grep: null, - }), - ); - writeFileSync( - join(directory, "bin/docker"), - `#!${process.execPath} -const fs = require('node:fs'); -const args = process.argv.slice(2); -const forwarded = args.filter((value, index) => args[index - 1] === '--env' && !value.includes('=')); -fs.writeFileSync(process.env.DOCKER_CAPTURE, JSON.stringify({ args, forwarded, tokenMatches: process.env.STAGING_API_TOKEN === process.env.EXPECTED_TOKEN })); -`, - { mode: 0o755 }, - ); - return { directory, shard }; -} - -function runShell(t, input, token = secret) { - const { directory, shard } = workspace(t); - const output = join(directory, "output"); - const capture = join(directory, "docker.json"); - const result = spawnSync("bash", ["-c", runStep.run], { - encoding: "utf8", - env: { - ...process.env, - PATH: `${join(directory, "bin")}:${process.env.PATH}`, - GITHUB_ACTION_PATH: actionPath, - GITHUB_OUTPUT: output, - E2E_RUNTIME_ENV: input, - E2E_SHARD_DIRECTORY: shard, - E2E_SELECTION_FILE: join(directory, "selection.json"), - E2E_BASE_URL: "https://staging.example.com", - E2E_CONFIG: "/e2e/playwright.config.ts", - E2E_RUNNER_IMAGE_ID: imageId, - E2E_SHARD_INDEX: "1", - E2E_SHARD_TOTAL: "1", - STAGING_API_TOKEN: token, - UNLISTED_TOKEN: "must-not-forward", - EXPECTED_TOKEN: token, - DOCKER_CAPTURE: capture, - }, - }); - return { - ...result, - directory, - shard, - output: readFileSync(output, "utf8"), - capture, - }; -} - -for (const input of ["", "STAGING_API_TOKEN\nSTAGING_API_TOKEN"]) { - test(`Actual run step gives Docker only deduplicated names for ${input ? "selected credentials" : "omitted forwarding"}`, (t) => { - const result = runShell(t, input); - assert.equal(result.status, 0, result.stderr); - const invocation = JSON.parse(readFileSync(result.capture, "utf8")); - assert.deepEqual(invocation.forwarded, input ? ["STAGING_API_TOKEN"] : []); - assert.equal(invocation.tokenMatches, true); - assert.ok(invocation.args.includes("BASE_URL=https://staging.example.com")); - assert.ok(!JSON.stringify(invocation).includes(secret)); - assert.ok(!result.output.includes(secret)); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - assert.match(result.output, /exit-code=0\nfailure-kind=test/); - for (const entry of readdirSync(result.shard)) { - assert.ok( - !readFileSync(join(result.shard, entry), "utf8").includes(secret), - ); - } - }); -} - -test("Invalid runtime configuration never starts Docker and finalizes as an infrastructure failure", (t) => { - const result = runShell(t, `STAGING_API_TOKEN=${secret}`); - assert.equal(result.status, 1); - assert.ok(!readdirSync(result.directory).includes("docker.json")); - assert.equal( - result.output, - "exit-code=1\nfailure-kind=runtime-environment\n", - ); - assert.ok(!result.stderr.includes(secret)); - - const statusFile = join(result.shard, "shard-status.json"); - writeFileSync( - statusFile, - JSON.stringify({ - startedAt: new Date().toISOString(), - lifecycle: { runner: {} }, - }), - ); - const completed = spawnSync( - process.execPath, - [ - join(actionPath, "complete-shard.mjs"), - statusFile, - "success", - "success", - "0", - imageId, - "1", - "runtime-environment", - join(result.directory, "final-output"), - ], - { encoding: "utf8" }, - ); - assert.equal(completed.status, 1); - const status = JSON.parse(readFileSync(statusFile, "utf8")); - assert.equal(status.result, "infrastructure-error"); - assert.equal(status.primaryFailure.phase, "runtime-environment"); - assert.equal(status.lifecycle.playwright.started, false); - assert.ok(!JSON.stringify(status).includes(secret)); - assert.ok(!completed.stderr.includes(secret)); -}); From 0fe34a027762c278322ff6a93453aea7a1b55981 Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 17:43:33 +0300 Subject: [PATCH 3/4] Cover runtime environment forwarding through echo E2E --- .github/workflows/e2e.yml | 9 +++++++++ e2e/tests/echo.api.spec.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ff1a7a8..0f4fc61 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -64,7 +64,16 @@ jobs: if: steps.sut.outcome == 'success' continue-on-error: true uses: $/ + env: + DEMO_RUNTIME_TOKEN: synthetic-token-not-a-secret + DEMO_RUNTIME_MESSAGE: |- + hello from the workflow + spaces, "quotes", $dollar and = survive + DEMO_UNLISTED_TOKEN: must-stay-outside-the-runner with: + runtime-env: | + DEMO_RUNTIME_TOKEN + DEMO_RUNTIME_MESSAGE profile: >- ${{ github.event_name == 'pull_request' && 'pull-request' || github.event_name == 'push' && 'main' || inputs.profile }} diff --git a/e2e/tests/echo.api.spec.ts b/e2e/tests/echo.api.spec.ts index cc00918..97bb6f4 100644 --- a/e2e/tests/echo.api.spec.ts +++ b/e2e/tests/echo.api.spec.ts @@ -8,6 +8,40 @@ interface EchoResponse { body: unknown; } +test( + "echoes forwarded runtime values without exposing unlisted workflow variables", + { tag: ["@smoke", "@regression"] }, + async ({ request }): Promise => { + const requestBody = + await test.step("Arrange: Read runtime values inside the test container", () => ({ + token: process.env.DEMO_RUNTIME_TOKEN ?? null, + message: process.env.DEMO_RUNTIME_MESSAGE ?? null, + unlisted: process.env.DEMO_UNLISTED_TOKEN ?? null, + })); + + const response = + await test.step("Act: Send the runtime values to the echo server", () => + request.post("/echo", { data: requestBody })); + + await test.step("Assert: The server echoes both forwarded values and no unlisted value", async () => { + expect(response.status(), "The echo request should succeed").toBe(200); + await expect( + response.json(), + "Only allowlisted runtime values should reach the test container", + ).resolves.toMatchObject({ + method: "POST", + path: "/echo", + body: { + token: "synthetic-token-not-a-secret", + message: + 'hello from the workflow\nspaces, "quotes", $dollar and = survive', + unlisted: null, + }, + }); + }); + }, +); + test( "reports that the SUT is healthy", { tag: "@smoke" }, From 8a978d06bf40fcf738bf12def3166261af2a3e26 Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 17:48:52 +0300 Subject: [PATCH 4/4] Document runtime environment support for v0.4.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c4dd18..c33fa8e 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ No Compose commands are needed. Use only environments you are authorized to test ### Pass runtime credentials to tests -`runtime-env` is unreleased. Wait for a release containing this input under your selected major-version tag before using this example; do not substitute a commit SHA or feature branch. +`runtime-env` requires `v0.4.0` or later. Use a major-version tag such as `@v0` that includes this release; do not substitute a commit SHA or feature branch. Provide secret values through the action step's `env` and list only variable names in `runtime-env`: