A scheduled deployment pipeline can do all the right work and still stop at the last step: waiting for someone to approve its environment access. The obvious fix is to automate the approval. The dangerous version is to make the gate disappear. I wanted the boring scheduled run to proceed on its own, while keeping human approval for everything that did not match that exact case.
The boundary is the feature
The requirement was deliberately small: approve only the pending QA deployment created by the first scheduled run, on the application's trunk branch. A manual run should still wait for a person. A rerun should still wait for a person. A different repository, branch, workflow, or environment should never qualify.
That distinction matters because a GitHub environment is not just a pause button. Its required reviewers are a deployment protection rule. I did not want to remove that rule or replace it with a broad rule that approves any deployment from the repository. The automation should act as one narrowly eligible reviewer, and only after GitHub has created the matching pending deployment.
A helper job, not a bypass
I added a reusable workflow that the application can call as a separate job alongside its existing build and deployment work. It does not depend on the deployment job, and it is not bound to the protected QA environment itself; either would make it wait at the same gate it is meant to review. Instead, it watches the workflow run and asks GitHub whether that run has a pending deployment to QA.
The shared workflow stays thin. A composite action contains the approval logic and its tests. actions/github-script provides the authenticated GitHub client and workflow context, while the action loads its colocated CommonJS module using github.action_path. That kept the action self-contained: no application checkout, dependency installation, or generated JavaScript bundle.
Checks before the click
The caller has an early guard, but the action checks the policy again using both the runtime context and GitHub's API. It verifies the repository, schedule event, branch, first attempt, default branch, and exact workflow path. It then confirms that QA still has a required-reviewer protection rule and checks the metadata for the live run before acting.
The action polls for the pending deployment because the build and deploy jobs run in parallel. When QA appears, it selects that environment's ID from the current run, confirms that the token owner is eligible to approve, and checks that no workflow job has failed, been cancelled, timed out, or entered an action-required state. It revalidates the run immediately before calling GitHub's pending-deployment review API. The approval comment is recorded on the deployment review.
Polling is bounded: the action checks every 30 seconds for up to 175 minutes, inside a 180-minute job timeout. If QA never appears, an API call fails, a policy check changes, or the deadline passes, the job fails. It does not turn a timeout into an approval. If a human gets there first, the automation may time out; that is preferable to claiming it approved something it did not.
Reuse credentials carefully
The implementation reuses an existing token rather than adding another secret. The token must be explicitly passed through the caller and reusable workflow, and its owner must already be eligible to review QA. It also has to be available to the helper job outside the protected environment, so it belongs in an appropriate repository or organization secret, not only in the QA environment.
That convenience creates a real trust boundary. The token is not schedule-scoped: workflow code with access to it could potentially use its permissions in other ways. The repository and action therefore need normal code-review protections, and the token still needs the narrowest suitable permissions, authorization, expiration, and rotation. Reusing a credential avoids secret sprawl; it does not make the credential harmless.
Test the policy, not just the happy path
The approval module accepts the GitHub client and run context as inputs, which makes the decision logic testable without a live deployment. The colocated Node tests exercise the allowed QA approval, and deliberately change one condition at a time: wrong repository, manual event, wrong branch, rerun, completed run, missing reviewer protection, ineligible token owner, failed jobs, API errors, and a pending deployment for the wrong environment.
The tests also cover the awkward timing cases: QA appearing after several polls, the exact timeout boundary, and a rerun detected during the final revalidation. The pull request reported 27 passing mocked tests, plus a Node syntax check and actionlint. Those checks validate the policy and wiring; they do not prove that the real token is authorized or that a live scheduled deployment succeeds. That still needs an end-to-end rollout check.
What changed, and what did not
The implementation landed in two steps. The first added the reusable approval workflow, policy tests, and setup guidance. The follow-up extracted the code into a composite action and kept the reusable workflow as a small caller. The application's weekday schedule and deployment targeting stayed as they were. QA's human reviewers stayed configured, and manual runs and reruns remained protected.
The useful lesson is not to automate approvals indiscriminately. Define exactly which event is safe to automate, verify that definition against the live run immediately before acting, and make every uncertainty fail closed. A protected environment can remain protected while a carefully bounded automation handles the one repetitive approval that has a clear policy.
For more detail, see GitHub's documentation on deployment environments and the pending deployment review API.