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
15 changes: 15 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ jobs:
REQUIRED_ORCHESTRATOR_PUBLIC_IP: ${{ secrets.ORCHESTRATOR_PUBLIC_IP }}
REQUIRED_GH_ORG: ${{ secrets.GH_ORG }}
REQUIRED_WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }}
# GitHub refuses secret names starting with GITHUB_, so the API's
# GITHUB_WEBHOOK_SECRET is stored as GH_WEBHOOK_SECRET, like GH_ORG.
REQUIRED_GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
# Policy, not a credential, so it is an environment variable. Compose
# refuses to start without it; failing here stops before the host is touched.
REQUIRED_DEFAULT_AGENT_TYPE: ${{ vars.DEFAULT_AGENT_TYPE }}
Expand All @@ -176,6 +179,7 @@ jobs:
REQUIRED_ORCHESTRATOR_PUBLIC_IP \
REQUIRED_GH_ORG \
REQUIRED_WORKER_BROKER_INTERNAL_TOKEN \
REQUIRED_GH_WEBHOOK_SECRET \
REQUIRED_DEFAULT_AGENT_TYPE
do
if [ -z "${!name}" ]; then
Expand All @@ -187,6 +191,14 @@ jobs:
"${{ inputs.environment }}" "${missing[*]}" >&2
exit 1
fi
# The webhook secret is written verbatim into the server .env, so only one
# token that neither the remote heredoc nor Compose's env-file parser can
# reinterpret is accepted. Hex, base64 and base64url qualify.
if [[ ! "${REQUIRED_GH_WEBHOOK_SECRET}" =~ ^[A-Za-z0-9._~+/=-]+$ ]]; then
printf 'GH_WEBHOOK_SECRET for %s must be one token of letters, digits and ._~+/=- (for example openssl rand -hex 32); its value is not shown\n' \
"${{ inputs.environment }}" >&2
exit 1
fi

- name: Validate production provider secrets
if: ${{ inputs.environment == 'production' }}
Expand Down Expand Up @@ -338,6 +350,9 @@ jobs:
# GitHub
GITHUB_APP_ID=${{ secrets.GH_APP_ID }}
GITHUB_ORG=${{ secrets.GH_ORG }}
# Required by the API. Validate required secrets refused any value that is
# not one safe token, so it lands here as data.
GITHUB_WEBHOOK_SECRET=${{ secrets.GH_WEBHOOK_SECRET }}

# Telegram
# Same inheritance, worse consequence: two long-polling clients on one
Expand Down
19 changes: 14 additions & 5 deletions .github/workflows/stand-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ jobs:
TELETHON_API_HASH: ${{ secrets.TELETHON_API_HASH }}
TELETHON_SESSION: ${{ secrets.TELETHON_SESSION }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
# GitHub refuses secret names starting with GITHUB_; the deploy stores it the same way.
GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
run: |
set -euo pipefail
python3 -m scripts.stand_credentials --suite "${SUITE}"
Expand Down Expand Up @@ -636,6 +638,7 @@ jobs:
GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT }}
HOST_CODEX_HOME: /opt/secrets/stand-codex
Expand Down Expand Up @@ -678,7 +681,7 @@ jobs:
"REGISTRY_USER", "REGISTRY_PASSWORD", "REGISTRY_PASSWORD_HASH", "LOKI_PUSH_USER",
"LOKI_PUSH_PASSWORD", "LOKI_PUSH_PASSWORD_HASH", "LOKI_URL", "ADMIN_USER",
"ADMIN_PASSWORD", "GRAFANA_DB_USER", "GRAFANA_DB_PASSWORD", "GRAFANA_ADMIN_PASSWORD",
"WORKER_BROKER_INTERNAL_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT", "HOST_CODEX_HOME", "HOST_CODEX_VALIDATION_PATH", "PO_LLM_MODEL",
"WORKER_BROKER_INTERNAL_TOKEN", "GITHUB_WEBHOOK_SECRET", "STAND_CLAUDE_CODE_OAUTH_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT", "HOST_CODEX_HOME", "HOST_CODEX_VALIDATION_PATH", "PO_LLM_MODEL",
"PO_LLM_BASE_URL", "PO_LLM_API_KEY", "ARCHITECT_LLM_MODEL", "ARCHITECT_LLM_BASE_URL",
"ARCHITECT_LLM_API_KEY", "SUMMARIZATION_MODEL", "LIVE_CONTOUR", "DEFAULT_AGENT_TYPE",
"TELEGRAM_MAX_CONCURRENT_UPDATES",
Expand Down Expand Up @@ -925,20 +928,22 @@ jobs:
# The QA session lives in the qa-worker env file, not in the api
# environment, so the redactor is handed it by name: -e NAME takes
# the value from this shell, never from a command line.
# The api environment must carry GITHUB_WEBHOOK_SECRET; without it the
# redaction path fails closed.
qa_env=./.qa-worker.env
TELETHON_API_HASH=; TELETHON_SESSION=
if [ -f "${qa_env}" ]; then
TELETHON_API_HASH=$(sed -n "s/^TELETHON_API_HASH=//p" "${qa_env}")
TELETHON_SESSION=$(sed -n "s/^TELETHON_SESSION=//p" "${qa_env}")
fi
export TELETHON_API_HASH TELETHON_SESSION
protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION"
protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN GITHUB_WEBHOOK_SECRET PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION"
services_out="${diagnostics_dir}/provisioning-services.log"
services_partial="${services_out}.partial"
rm -f "${services_partial}"
if ${COMPOSE} logs --no-color --tail 300 infra-service scheduler-infrastructure 2>&1 | \
${COMPOSE} exec -T -e STAND_DIAGNOSTIC_SECRET_NAMES="${protected_names}" -e TELETHON_API_HASH -e TELETHON_SESSION api \
python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \
python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); os.environ.get(\"GITHUB_WEBHOOK_SECRET\", \"\").strip() or sys.exit(\"missing redaction value: GITHUB_WEBHOOK_SECRET\"); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \
> "${services_partial}"; then
mv "${services_partial}" "${services_out}"
else
Expand Down Expand Up @@ -1206,14 +1211,16 @@ jobs:
# The QA session lives in the qa-worker env file, not in the api
# environment, so the redactor is handed it by name: -e NAME takes
# the value from this shell, never from a command line.
# The api environment must carry GITHUB_WEBHOOK_SECRET; without it the
# redaction path fails closed.
qa_env=./.qa-worker.env
TELETHON_API_HASH=; TELETHON_SESSION=
if [ -f "${qa_env}" ]; then
TELETHON_API_HASH=$(sed -n "s/^TELETHON_API_HASH=//p" "${qa_env}")
TELETHON_SESSION=$(sed -n "s/^TELETHON_SESSION=//p" "${qa_env}")
fi
export TELETHON_API_HASH TELETHON_SESSION
protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION"
protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN GITHUB_WEBHOOK_SECRET PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION"
out=/root/e2e-runs/suite-services.log
partial="${out}.partial"
rm -f "${partial}"
Expand All @@ -1225,7 +1232,7 @@ jobs:
engineering-worker worker-manager worker-broker api \
qa-worker deploy-worker 2>&1 | \
${COMPOSE} exec -T -e STAND_DIAGNOSTIC_SECRET_NAMES="${protected_names}" -e TELETHON_API_HASH -e TELETHON_SESSION api \
python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \
python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); os.environ.get(\"GITHUB_WEBHOOK_SECRET\", \"\").strip() or sys.exit(\"missing redaction value: GITHUB_WEBHOOK_SECRET\"); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \
> "${partial}"; then
mv "${partial}" "${out}"
else
Expand Down Expand Up @@ -1306,6 +1313,7 @@ jobs:
GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
PO_LLM_API_KEY: ${{ secrets.PO_LLM_API_KEY }}
ARCHITECT_LLM_API_KEY: ${{ secrets.ARCHITECT_LLM_API_KEY }}
Expand Down Expand Up @@ -1452,6 +1460,7 @@ jobs:
GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
PO_LLM_API_KEY: ${{ secrets.PO_LLM_API_KEY }}
ARCHITECT_LLM_API_KEY: ${{ secrets.ARCHITECT_LLM_API_KEY }}
Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ See the CHANGELOG rule in [AGENTS.md](../AGENTS.md).

## 2026-10-18

- Deploy and `stand-e2e.yml` write the `GH_WEBHOOK_SECRET` Environment secret into the API's `.env`
as `GITHUB_WEBHOOK_SECRET`, refuse a missing or unsafe value first, and redact it from stand evidence.
- `POST /api/webhooks/github` publishes a `pr_ci` wake hint for an HMAC-signed `workflow_run`
completion; the API now requires `GITHUB_WEBHOOK_SECRET`, and public routing is not active yet.
- `POST /stories/{id}/pr_review` publishes a `pr_ci` wake hint after its commit, so the PR/CI sweep
Expand Down
20 changes: 14 additions & 6 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ authentication.
### Ephemeral stand authentication

Before the lifecycle preflight can create a machine, the ephemeral BitLaunch
stand validates Telethon material, the bootstrap private key, and the
stand validates Telethon material, the bootstrap private key, the API's webhook
secret (`GH_WEBHOOK_SECRET`, see [GitHub Integration](#github-integration)), and the
operator-supplied, timezone-aware `CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT` metadata
for the opaque annual Claude OAuth token. Missing, malformed, expired, or
near-expiry Claude material refuses without rendering credentials. The GitHub
Expand Down Expand Up @@ -382,11 +383,18 @@ either `openrouter_balance` (the balance and whether an alert was sent) or
| `GH_APP_PRIVATE_KEY` | GitHub App private key PEM (written to `/opt/secrets/github_app.pem`) |
| `GH_ORG` | GitHub organization name |
| `GHCR_TOKEN` | GitHub token with `packages:read` scope (for pulling worker images) |
| `GITHUB_WEBHOOK_SECRET` | Random secret GitHub signs webhook deliveries with; required by the API, which refuses to start without a nonblank value. No other key derives it |

The deploy workflow does not write `GITHUB_WEBHOOK_SECRET` into the server `.env` yet, so a
deploy of the API needs that wiring and the provisioned secret first. Activation of the webhook is
described in [GitHub workflow completion](contracts/scheduler-wake-hints.md#github-workflow-completion).
| `GH_WEBHOOK_SECRET` | Random secret GitHub signs webhook deliveries with, written into the server `.env` as `GITHUB_WEBHOOK_SECRET`. The API refuses to start without a nonblank value. No other key derives it |

GitHub refuses secret names that start with `GITHUB_`, so the API's `GITHUB_WEBHOOK_SECRET` is
the `GH_WEBHOOK_SECRET` secret of each GitHub Environment that runs the API: the operator
provisions a dedicated value separately in `production` and in `stand`. Use 32 random bytes as hex
(`openssl rand -hex 32`); a value must be one token of letters, digits and `._~+/=-`. Keep it out of
model-visible text, issues, logs and artifacts. Deploy refuses a missing or unsafe value by name,
without printing it, before it writes `.env`. `stand-e2e.yml` refuses it the same way before any
paid step, renders it into the stand API's `.env`, and holds it as a protected value of its
service-tail redaction and both artifact admissions. Provisioning it activates no webhook; what
activation still needs is in
[GitHub workflow completion](contracts/scheduler-wake-hints.md#github-workflow-completion).

`GH_ORG` is the name of the *secret* only. The rename stopped at the secret: the deploy workflow
writes it into the server `.env` as `GITHUB_ORG=${{ secrets.GH_ORG }}`
Expand Down
16 changes: 10 additions & 6 deletions docs/contracts/scheduler-wake-hints.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,18 @@ path, and the awakened cycle decides from the same durable Story/Task/Run state
socket error is logged as `pr_ci_wake_failed` with the error type only and the delivery is still
answered 204, so GitHub does not redeliver a hint the interval covers. Zero receivers is a
published hint. Cancellation propagates. There is no retry.
- No GitHub delivery reaches this route yet. Activation needs, in order: a random secret generated
and provisioned as `GITHUB_WEBHOOK_SECRET` (for example `openssl rand -hex 32`, held only in the
deployment secret store); the deploy workflow writing it into the API's environment, without
which the API refuses to start; public HTTPS routing of exactly `POST /api/webhooks/github` to the
API that preserves the body and headers; the GitHub App's webhook URL and that secret, its
- No GitHub delivery reaches this route yet. The deploy and stand workflows write the
`GH_WEBHOOK_SECRET` secret of their GitHub Environment into the API's environment as
`GITHUB_WEBHOOK_SECRET` and refuse to proceed without it
([GitHub Integration](../DEPLOY.md#github-integration)). Activation still needs, in order: a
dedicated random value (32 random bytes as hex, `openssl rand -hex 32`) provisioned as
`GH_WEBHOOK_SECRET` in each Environment that runs the API, held only there and never in
model-visible text or artifacts; public HTTPS routing of exactly `POST /api/webhooks/github` to
the API that preserves the body and headers; the GitHub App's webhook URL and that secret, its
`workflow_run` event subscription and its Actions read permission; and evidence of a delivery
answered 204 followed by an awakened `pr_ci` sweep. To disable it, remove the route from the
public proxy or the App's subscription; the interval sweep keeps recovering every completion.
public proxy or the App's subscription and keep the secret provisioned, since the API still
requires it; the interval sweep keeps recovering every completion.

## Listener

Expand Down
1 change: 1 addition & 0 deletions scripts/stand_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"GRAFANA_DB_PASSWORD",
"GRAFANA_ADMIN_PASSWORD",
"WORKER_BROKER_INTERNAL_TOKEN",
"GITHUB_WEBHOOK_SECRET",
"STAND_CLAUDE_CODE_OAUTH_TOKEN",
"PO_LLM_API_KEY",
"ARCHITECT_LLM_API_KEY",
Expand Down
13 changes: 13 additions & 0 deletions scripts/stand_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from collections.abc import Mapping
from datetime import UTC, datetime
import os
import re

from shared.stand_credentials import (
CLAUDE_MINIMUM_TTL, # noqa: F401 - retained script-level validator API
Expand All @@ -24,6 +25,10 @@

TELETHON_ENV_VARS = ("TELETHON_API_ID", "TELETHON_API_HASH", "TELETHON_SESSION")
PRIVATE_KEY_MINIMUM_LINES = 3
# Every suite starts the API, which refuses to start without this secret. The
# rendered .stand.env line keeps only one token Compose cannot reinterpret.
WEBHOOK_SECRET_ENV_VAR = "GITHUB_WEBHOOK_SECRET" # noqa: S105 - a variable name
_WEBHOOK_SECRET_SHAPE = re.compile(r"[A-Za-z0-9._~+/=-]+")


def _ssh_private_key_looks_usable(value: str | None) -> bool:
Expand Down Expand Up @@ -70,6 +75,14 @@ def validate_precreate_credentials(
"Telethon session", f"is unavailable: missing {', '.join(missing_telethon)}"
)
)
if not _WEBHOOK_SECRET_SHAPE.fullmatch(environment.get(WEBHOOK_SECRET_ENV_VAR, "")):
failures.append(
CredentialFailure(
"GitHub webhook secret",
f"is missing or not one token of letters, digits and ._~+/=-: "
f"{WEBHOOK_SECRET_ENV_VAR} (stand environment secret GH_WEBHOOK_SECRET)",
)
)
if not _ssh_private_key_looks_usable(environment.get("SSH_PRIVATE_KEY")):
failures.append(
CredentialFailure("SSH material", "is missing or not a usable private-key document")
Expand Down
37 changes: 37 additions & 0 deletions scripts/tests/test_stand_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from scripts.stand_acceptance import (
ADMISSION_MARKER,
PROTECTED_STAND_SECRET_NAMES,
ProtectedEnvironmentError,
_emit_admission_diagnostics,
admit_artifact,
admit_artifact_from_environment,
Expand Down Expand Up @@ -1307,6 +1308,42 @@ def test_admission_refuses_a_missing_protected_environment_value_with_a_named_sa
}


def test_a_leaked_webhook_secret_is_refused_and_its_name_alone_is_admissible(tmp_path):
webhook_secret = "9e3a" * 16
environment = {**_protected_environment(), "GITHUB_WEBHOOK_SECRET": webhook_secret}
protected_values = protected_values_from_environment(environment, suite="mega-noop")
leaked = tmp_path / "leaked"
_final_candidate(leaked, f"api | hmac key {webhook_secret} in a traceback\n")
leaked_status = tmp_path / "leaked.json"
safe = tmp_path / "safe"
_final_candidate(
safe,
"api | GITHUB_WEBHOOK_SECRET is configured\n"
"api | github_webhook_refused reason=signature_mismatch status=403\n"
"ORCHESTRATOR_HOSTNAME=stand.example.test\n",
)

assert not admit_artifact(leaked, status_path=leaked_status, protected_values=protected_values)
assert admit_artifact(
safe, status_path=tmp_path / "safe.json", protected_values=protected_values
)
assert json.loads(leaked_status.read_text(encoding="utf-8"))["issues"] == [
{"path": "run.log", "reason": "candidate contains a supplied protected value"}
]
assert webhook_secret not in leaked_status.read_text(encoding="utf-8")


@pytest.mark.parametrize("suite", ["mega-noop", "mega-live"])
def test_admission_requires_the_webhook_secret_for_every_suite(suite):
environment = _protected_environment()
environment["GITHUB_WEBHOOK_SECRET"] = ""

with pytest.raises(ProtectedEnvironmentError) as refused:
protected_values_from_environment(environment, suite=suite)

assert refused.value.missing == ("GITHUB_WEBHOOK_SECRET",)


def test_rejected_admission_emits_only_safe_reason_and_relative_path_to_the_summary(
tmp_path, capsys
):
Expand Down
Loading