Bypassing GitHub Enterprise: Implementing Cost-Effective Manual Approval Workflows

Manual approval gates represent a critical control mechanism in modern continuous deployment pipelines, serving as the final checkpoint between code automation and production execution. However, the native implementation of this functionality within GitHub Actions has historically presented significant barriers for organizations utilizing private repositories. The standard method for pausing a workflow to require human intervention relies on GitHub Environments, a feature that, for private repositories, mandates a GitHub Enterprise license. This licensing requirement creates a financial bottleneck for many development teams who require strict deployment controls but lack the budget for enterprise-tier infrastructure. Furthermore, even when using native environments, the workflow runner remains active during the waiting period, consuming compute resources and incurring costs for time spent idle.

To address these limitations, the open-source community has developed alternative mechanisms that replicate the functionality of environment-based approvals without the associated licensing fees or resource waste. By leveraging GitHub Issues and the GitHub Actions API, teams can implement robust, cost-effective approval workflows that maintain security standards while optimizing operational expenditures. This analysis explores the technical architecture of these community-driven solutions, specifically focusing on the pavlospt/manual-approval action and the event-driven issue-based workflow pattern, providing a comprehensive guide for implementing manual approval gates in any GitHub repository.

The Economic and Technical Constraints of Native Approvals

The primary driver for adopting alternative approval mechanisms is the economic inefficiency inherent in GitHub's native workflow pause functionality. In a standard GitHub Actions workflow, when a job is paused waiting for an approval from an environment, the virtual machine or container hosting that job continues to run. This means the organization is billed for compute time during the entire duration of the wait. For deployment pipelines that might require approval from multiple stakeholders across different time zones, these wait times can span hours, leading to significant waste of GitHub Actions minutes.

GitHub imposes a hard limit on job duration to mitigate runaway costs, but this limit creates its own operational constraints. A job, including one that is paused and waiting for approval, will automatically fail after six hours. Consequently, if an approver does not respond within this window, the workflow fails, potentially disrupting deployment schedules or requiring manual intervention to restart the process. While the broader workflow timeout is set to 72 hours, the individual job timeout of six hours is the critical constraint for paused jobs.

For private repositories, the ability to use Environments for this pause functionality is restricted to GitHub Enterprise plans. This restriction forces organizations into a binary choice: pay for an enterprise license to get native approvals and incur ongoing compute costs for idle runners, or find a workaround. The workaround involves decoupling the approval logic from the runner instance itself, effectively pausing the workflow without keeping a compute instance alive.

The Manual-Approval Action Architecture

The pavlospt/manual-approval action provides a direct alternative to native environment approvals by utilizing GitHub Issues as the communication medium for approval decisions. This action is freely available for use on private repositories, removing the need for a GitHub Enterprise license. The mechanism operates by creating a GitHub Issue in the repository containing the workflow when the action is executed. The issue is automatically assigned to the designated approvers, who are specified in the workflow configuration.

The action supports a flexible approval logic. It can require approval from a single user, a list of specific users, or members of an organization team. The action allows for the configuration of minimum approvals, enabling scenarios where multiple stakeholders must agree before the workflow proceeds. The action monitors the created issue for comments from the assigned approvers. When an approver comments with a predefined approval keyword, the action records the approval. If all required approvals are received, the workflow continues. If any approver comments with a denial keyword, the workflow exits with a failed status.

The approval and denial keywords are case-insensitive and can include optional punctuation, such as periods or exclamation marks. The default approval keywords include "approve", "approved", "lgtm", and "yes". The default denial keywords include "deny", "denied", and "no". This flexibility allows teams to adopt terminology that fits their internal communication styles. Once the decision is made, the action automatically closes the initial GitHub Issue, keeping the repository clean.

Configuring the Manual-Approval Action

Implementing the pavlospt/manual-approval action requires careful configuration of permissions and authentication tokens. The action requires write permissions on issues to create the approval ticket and monitor comments. If the workflow does not have these permissions by default, they must be explicitly granted in the workflow file.

yaml permissions: issues: write

The action requires a secret token to interact with the GitHub API. By default, the GITHUB_TOKEN can be used. However, the GITHUB_TOKEN is limited in scope and may not have the necessary permissions to assign users or teams depending on the repository's security settings. In such cases, a GitHub App token can be generated and used. The tibdex/github-app-token action can be used to generate a token with the required scopes. It is important to note that GitHub App tokens expire after one hour. This imposes a hard limit on the approval window when using this method; if the approval process exceeds 60 minutes, the token will expire, and the job will fail due to bad credentials.

```yaml
jobs:
myjob:
runs-on: ubuntu-latest
steps:
- name: Generate token
id: generatetoken
uses: tibdex/github-app-token@v1
with:
app
id: ${{ secrets.APPID }}
private
key: ${{ secrets.APPPRIVATEKEY }}

  - name: Wait for approval
    uses: pavlospt/manual-approval@v2
    with:
      secret: ${{ steps.generate_token.outputs.token }}
      approvers: myteam
      minimum-approvals: 1
      issue-title: "Deploying v1.3.5 to prod from staging"
      issue-body: "Please approve or deny the deployment of version v1.3.5."
      exclude-workflow-initiator-as-approver: false

```

The approvers input accepts a comma-delimited list of users or organization teams. Required approvers must have the ability to be set as approvers in the repository, which generally means they need at least read access to the repository. The exclude-workflow-initiator-as-approver flag can be set to false to allow the person who triggered the workflow to also approve it, which can be useful for solo developers or small teams. Additional approved or denied words can be specified to customize the approval logic further.

The Event-Driven Issue-Based Workflow Pattern

While the pavlospt/manual-approval action provides a convenient wrapper for the approval logic, it still suffers from the compute cost issue if the job is kept alive while waiting. A more cost-effective approach involves splitting the deployment process into two separate workflows. The first workflow is triggered by a code push or pull request merge and is responsible solely for creating an approval issue. This workflow runs quickly and terminates, incurring minimal cost. The second workflow is triggered when a comment is added to the approval issue. This workflow checks if the comment is an approval from an authorized user and, if so, proceeds with the deployment.

This pattern eliminates the need for a paused runner entirely. The compute resources are only consumed during the actual execution of the pre-deployment checks and the deployment itself. The waiting period is handled by GitHub's issue tracking system, which does not consume actions minutes.

The first workflow, often named "Create Approval Issue," is triggered on push to a specific branch or when a pull request is merged. It uses the actions/github-script action to create a new issue with a specific title and body. The body includes instructions for approvers, listing the authorized users and teams, and the required approval keywords. It also embeds metadata, such as the commit SHA, in a JSON block to allow the second workflow to identify which code version is being approved.

yaml name: Create Approval Issue on: push: branches: - dev pull_request: types: [closed] branches: - dev permissions: issues: write contents: read jobs: create-approval-issue: runs-on: ubuntu-latest if: github.event.pull_request.merged == true || github.event_name == 'push' steps: - name: Create Approval Issue uses: actions/github-script@v7 with: script: | let commitSha = ''; if (context.eventName === 'pull_request') { commitSha = context.payload.pull_request.merge_commit_sha; } else { commitSha = context.sha; } const shortSha = commitSha.substring(0, 7); const issueBody = ` Please review and approve the deployment to staging. ### Approval Instructions - Only authorized team members can approve this deployment - Authorized approvers: - @sohag-pro - Members of teams: devops, senior-developers To approve, comment with one of these keywords: "approve", "LGTM" --- Metadata (do not modify): \`\`\`json { "commit_sha": "${commitSha}", "branch": "dev", "triggered_by": "${context.eventName}" } \`\`\` `; const issue = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: `Deploy to Staging needs approval - ${shortSha}`, body: issueBody, labels: ['deployment-approval'] }); console.log(`Created issue #${issue.data.number}`);

Implementing the Approval Processing Logic

The second workflow, named "Deploy to Staging," is triggered when a comment is created on an issue. It uses conditional logic to ensure it only runs if the issue title matches the expected pattern for approval requests. This prevents the workflow from running on every comment in the repository.

yaml name: Deploy to Staging on: issue_comment: types: [created] if: | contains(github.event.issue.title, 'Deploy to Staging needs approval') permissions: issues: write contents: read pull-requests: read jobs: process-approval: runs-on: ubuntu-latest if: | contains(github.event.issue.title, 'Deploy to Staging needs approval') && (contains(github.event.comment.body, 'approve') || contains(github.event.comment.body, 'LGTM')) steps: - name: Check Approver id: check-approver uses: actions/github-script@v7 with: script: | // Define allowed approvers const ALLOWED_APPROVERS = [ 'sohag-pro', 'tech-lead', 'devops-engineer' ]; // Define allowed teams (team slugs) const ALLOWED_TEAMS = [ 'devops', 'senior-developers' ]; const commenter = context.payload.comment.user.login; console.log(`Comment by: ${commenter}`); // First check if user is directly in allowed list if (ALLOWED_APPROVERS.includes(commenter)) { console.log('Approver is in allowed users list'); core.setOutput('status', 'authorized'); return; } // Check if user is member of allowed teams let isTeamMember = false; for (const team of ALLOWED_TEAMS) { try { const { data: isMember } = await github.rest.teams.getMembershipForUserInOrg({ org: context.repo.owner, team_slug: team, username: commenter }); if (isMember.state === 'active') { console.log(`Approver is member of team: ${team}`); isTeamMember = true; break; } } catch (error) { console.log(`Error checking team membership for ${team}`); } } if (isTeamMember) { core.setOutput('status', 'authorized'); } else { core.setOutput('status', 'unauthorized'); }

This script defines a list of allowed individual approvers and allowed team slugs. It first checks if the user who commented is in the list of allowed individuals. If not, it iterates through the list of allowed teams and checks if the user is an active member of any of them. If the user is authorized, the output status is set to "authorized". The workflow can then use this output to determine whether to proceed with the deployment steps. This approach ensures that only authorized personnel can trigger the deployment, maintaining security while avoiding the cost of paused runners.

Testing and Maintenance of Approval Actions

For organizations that wish to customize the pavlospt/manual-approval action or maintain their own fork, testing the changes requires building a Docker image and pushing it to a container registry. The action is containerized, allowing for isolated testing environments. To test a new version, developers can build the image with a specific version tag and push it to a test repository.

bash $ VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/pavlospt/manual-approval-test push

The action.yaml file must then be modified to point to the new image. The workflow can then be run against a development branch to verify the behavior.

yaml - name: Wait for approval uses: your-github-user/manual-approval@your-dev-branch with: secret: ${{ secrets.GITHUB_TOKEN }} approvers: pavlospt

When releasing a new version of the action, the standard release process involves building the image, pushing it, creating a release branch, merging the changes to the main branch, and updating the tags.

bash $ VERSION=1.7.0 make build $ VERSION=1.7.0 make push $ git checkout main && git fetch origin && git merge origin main $ git tag -d v1 && git push --delete origin v1 $ git tag v1.7.0 && git tag v1 && git push origin --tags

It is important to note that the pavlospt/manual-approval action is not certified by GitHub. While it is widely used and maintained, users should be aware of the potential security implications of using third-party actions and review the code to ensure it meets their security standards.

Conclusion

The implementation of manual approval workflows in GitHub Actions does not require an enterprise license or significant compute resources. By leveraging community-developed actions like pavlospt/manual-approval or implementing an event-driven issue-based workflow, organizations can achieve robust deployment controls that are both cost-effective and secure. The key is to understand the trade-offs between convenience and resource utilization. The manual-approval action offers a straightforward integration with minimal configuration, while the event-driven pattern offers superior cost savings by eliminating paused runners entirely. Both approaches utilize GitHub's native issue tracking and API capabilities to create a secure and efficient approval process. As teams continue to adopt continuous deployment practices, these solutions provide a viable path for implementing strict governance without incurring the hidden costs of traditional waiting periods.

Sources

  1. Approve Workflow Manual
  2. Outsmarting GitHub Actions: A Cost-Effective Approval Workflow

Related Posts