diff --git a/.github/actions/run-e2e/action.yml b/.github/actions/run-e2e/action.yml index 971eddd..38abad6 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,29 @@ 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_args=() + while IFS= read -r name; do + 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 + [[ " ${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" \ emit-args "$E2E_SELECTION_FILE" > "$selection_args_file" @@ -193,6 +216,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/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/README.md b/README.md index a739433..c33fa8e 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` 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`: + +```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 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" },