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
24 changes: 24 additions & 0 deletions .github/actions/run-e2e/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down
13 changes: 10 additions & 3 deletions .github/actions/run-e2e/complete-shard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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":
Expand All @@ -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());
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions e2e/tests/echo.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ interface EchoResponse {
body: unknown;
}

test(
"echoes forwarded runtime values without exposing unlisted workflow variables",
{ tag: ["@smoke", "@regression"] },
async ({ request }): Promise<void> => {
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" },
Expand Down
Loading