Open source deployment platform for GitHub Actions. Trigger production deploys from GitHub Issues using Issue Types and service labels, with optional approval via reactions, configurable health checks, and manual/automatic rollback.
- Centralized configuration in
deploy.config.yamlβ no hardcoded service names in workflows - Label-based deploy targets β each service key becomes a GitHub label
- Issue form β label sync β service checkboxes and optional deploy options are applied as labels automatically
- Pluggable deploy strategies per service:
ssh-docker,cloudflare-pages,script - Approval gate via π reaction from authorized users
- Rejection via π reaction
- Manual rollback via π reaction
- Automatic rollback on deploy or health check failure (configurable)
- Rollback notifications on the issue with @mentions, failure details, and log excerpts
- Audit trail posted as issue comments
- GitHub CLI first β
gh,gh label,gh issue,gh api
This guide shows how to integrate the platform into a repository that already has code and pipelines. Deploys are triggered by issues β this does not replace your existing build/CI, only the production release step.
- GitHub repository with Actions enabled
- Permission to create repository secrets (and organization secrets, if applicable)
- GitHub CLI β₯ 2.94 on runners (
ubuntu-latestincludes it) - For Issue Types in organization repositories: PAT with
admin:orgscope (see below)
In your project, copy the structure below from gh-deploy-control-with-issues:
your-project/
βββ deploy.config.yaml β create from example (step 2)
βββ .github/
β βββ workflows/
β β βββ deploy.yml
β β βββ sync-resources.yml
β β βββ ci.yml β optional, recommended
β βββ scripts/ β entire folder
β βββ ISSUE_TEMPLATE/ β optional (sync generates deploy.yml)
β βββ deploy-scripts/ β only if using strategy: script
βββ actions/
βββ deploy/action.yml β required router
βββ deploy-ssh-docker/
βββ deploy-cloudflare-pages/
βββ deploy-script/
Via terminal (with authenticated gh):
# At your repository root
OWNER=bunx-ai
REPO=gh-deploy-control-with-issues
TMP=$(mktemp -d)
gh repo clone "$OWNER/$REPO" "$TMP"
cp -R "$TMP/.github/workflows/deploy.yml" "$TMP/.github/workflows/sync-resources.yml" .github/workflows/
cp -R "$TMP/.github/scripts" .github/
cp -R "$TMP/actions" .
cp "$TMP/examples/deploy.config.example.yaml" deploy.config.yaml
mkdir -p .github/deploy-scripts
cp "$TMP/examples/deploy-scripts/worker.sh" .github/deploy-scripts/ # if using script strategy
# Optional: validation CI
cp "$TMP/.github/workflows/ci.yml" .github/workflows/
rm -rf "$TMP"Tip: do this on a branch (
feat/deploy-via-issues) and open a PR for review before merging tomain.
Edit the file at your project root. Each key under services becomes a GitHub label and a job in the deploy matrix.
- List the services you currently publish (API, frontend, worker, etc.)
- Choose a
strategyfor each (ssh-docker,cloudflare-pages, orscript) - Fill
configwith secret names the workflow will inject (do not put sensitive values in the YAML) - Set
deployment.approval.usersto the GitHub usernames of approvers
Minimal example for a Docker backend over SSH:
deployment:
issue_type: Deploy
fallback_trigger_label: deploy
approval:
enabled: true
users: [your-github-username]
rollback:
enabled: true
automatic: true
services:
api:
image: ghcr.io/your-org/your-api
strategy: ssh-docker
config:
ssh_host_secret: PRODUCTION_SSH_HOST
ssh_user_secret: PRODUCTION_SSH_USERNAME
ssh_key_secret: PRODUCTION_SSH_KEY
container_name: api
healthcheck:
url: https://api.yourdomain.com/healthSee examples/deploy.config.example.yaml in this repository for all options.
deploy.config.yaml references secrets by name. The workflow must expose them as environment variables.
Open .github/workflows/deploy.yml and add your secrets to each env: block in the deploy, healthcheck, and rollback jobs:
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USERNAME: ${{ secrets.PRODUCTION_SSH_USERNAME }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Only include secrets your services actually use.
Under Settings β Secrets and variables β Actions, create the secrets referenced in step 3 (SSH hosts, Cloudflare tokens, etc.).
For organization repositories using Issue Type Deploy:
| Secret | Scope | Purpose |
|---|---|---|
ORG_ADMIN_TOKEN |
PAT with admin:org |
Create Issue Type in the org (sync workflow) |
| Other secrets | Deploy/infra | SSH, Cloudflare, etc. |
User-owned repositories (personal accounts) do not need ORG_ADMIN_TOKEN β sync creates the deploy label as a fallback.
If you had a monolithic workflow (e.g. cd.yml that deployed on every push or via issues with [DEPLOYMENT]):
- Disable or remove the old workflow to avoid concurrent deploys
- Move custom logic to:
strategy: script+ a script in.github/deploy-scripts/, or- a new action in
actions/deploy-<name>/(see Adding a custom strategy)
- Compare with the table in Migration from legacy
cd.yml
Your build/test CI can stay as-is β this platform only runs when a deploy issue is opened or labeled.
Merge your branch to main and run Sync Deploy Resources (Actions β Sync Deploy Resources β Run workflow).
Sync will:
- Create labels for each service (
api,frontend, β¦) - Create Issue Type
Deployin the org (withORG_ADMIN_TOKEN) or thedeploylabel (fallback) - Generate/update
.github/ISSUE_TEMPLATE/deploy.yml
- Issues β New issue β Deploy
- Select the test service (e.g.
api) and describe the reason - If
approval.enabled: true, a user inapproval.usersreacts with π - Monitor in Actions β Deploy
Manual test (without opening a new issue):
gh workflow run deploy.yml -f issue_number=123Replace 123 with a valid issue number (type Deploy + service label).
| Item | Done? |
|---|---|
.github/ and actions/ files copied |
β |
deploy.config.yaml with your services |
β |
Secrets mapped in deploy.yml |
β |
| Secrets created in the repository | β |
ORG_ADMIN_TOKEN (org + Issue Type only) |
β |
| Old deploy workflow disabled | β |
| Sync Deploy Resources run | β |
| Test issue with successful deploy | β |
- Monorepo: one service per key in
services; labels select what to deploy for that issue - Images: set
imageto the desired tag; the workflow uses the current commit SHA/ref for the changelog β adjust scripts if you always uselatest - Other workflows: no conflict; deploy uses
concurrencyper issue number - Existing issue templates: the
Deploytemplate coexists with yours;config.ymlonly disables blank issues if you copy ours
Copy the example and edit deploy.config.yaml at the repository root:
cp examples/deploy.config.example.yaml deploy.config.yamldeployment:
issue_type: Deploy
approval:
enabled: true
users: [techlead, sre]
rollback:
enabled: true
automatic: true
healthcheck:
enabled: true
timeout: 300
retries: 10
observability:
include_failed_logs: true
max_log_lines: 40
services:
backend:
image: ghcr.io/company/backend
strategy: ssh-docker
config:
ssh_host_secret: PRODUCTION_SSH_HOST
ssh_user_secret: PRODUCTION_SSH_USERNAME
ssh_key_secret: PRODUCTION_SSH_KEY
container_name: backend
healthcheck:
url: https://api.example.com/healthRun the Sync Deploy Resources workflow (or push deploy.config.yaml to main):
- Creates labels for each service (
frontend,backend,worker, β¦) - Organization repos: creates Issue Type
Deployvia REST API (requires secretORG_ADMIN_TOKEN) - User-owned repos: Issue Types cannot be created via API β the workflow creates a fallback label
deployinstead
GITHUB_TOKEN cannot create Issue Types β it lacks admin:org scope. For organization repositories:
- Create a Personal Access Token with
admin:orgscope (org owner/admin required) - Add it as repository secret:
ORG_ADMIN_TOKEN - Run Sync Deploy Resources again
Without ORG_ADMIN_TOKEN, the sync workflow fails with a clear error and creates the fallback label deploy so deploys can still work via label.
Requires GitHub CLI β₯ 2.94 for issue type support.
Edit .github/workflows/deploy.yml and add secrets referenced in your config to the deploy and rollback job env blocks:
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USERNAME: ${{ secrets.PRODUCTION_SSH_USERNAME }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}Use the Deploy issue template (generated from deploy.config.yaml by the sync workflow):
- New issue β Deploy
- Check the services to deploy (labels are synced automatically when the workflow starts)
- The template sets Issue Type
Deploy(org) and labeldeploy(fallback) - If approval is enabled, an authorized user reacts with π
The template is kept in sync when you change services in deploy.config.yaml β run Sync Deploy Resources or push to main.
CLI alternative:
gh issue create \
--type Deploy \
--title "[Deploy] Release v1.2.0" \
--label deploy \
--label backend \
--body "Reason: merged PR #42"| Emoji | Meaning |
|---|---|
| π | Approve deploy |
| π | Reject deploy (closes issue) |
| π | Request manual rollback |
| Strategy | Description | Config keys |
|---|---|---|
ssh-docker |
SSH to host, docker pull, restart container |
ssh_host_secret, ssh_user_secret, ssh_key_secret, ssh_port_var, container_name |
cloudflare-pages |
Deploy static directory via Wrangler | project_name, directory (+ CLOUDFLARE_* secrets in workflow) |
script |
Run a repository script | script (path relative to repo root) |
Script strategy scripts may record refs for rollback/changelog by writing to temp files before exit:
echo "$PREVIOUS_REF" > "/tmp/deploy-previous-ref-${SERVICE}"
echo "$DEPLOYED_REF" > "/tmp/deploy-deployed-ref-${SERVICE}"If these files are absent, deployed_ref falls back to the IMAGE input.
The workflow checkout is the control repository (workflows, config, scripts). The deployed application may live in a different repo. Set config.git_remote (and optionally config.git_branch) in deploy.config.yaml; the script receives the full config as CONFIG_JSON during deploy and rollback.
See examples/deploy-scripts/multi-repo-git-sync.sh for a generic git-sync pattern with marker-file validation, safe rollback (refs must belong to origin/<branch>), and ref recording.
Rollback refs must belong to the deployed repository. If origin was previously pointed at a different repo, a stale SHA can break the tree β the example script falls back to origin/<branch> when the ref is not an ancestor.
By default, health checks accept HTTP 2xx only. SPA root URLs often return 307/302 redirects, which fail unless configured otherwise.
Recommended: point healthcheck.url at a path that returns 2xx directly (/health, /sign-in, API health endpoint).
Optional per-service overrides in deploy.config.yaml:
services:
frontend:
healthcheck:
url: https://app.example.com/
follow_redirects: true # curl -L; final response must still match accept rules
# OR accept redirect status without following:
# accept_status: [200, 301, 302, 307, 308]When accept_status is set, the HTTP code is matched against that list instead of the default 2xx range. When neither option is set, behavior is unchanged (2xx only).
- Create
actions/deploy-<name>/action.ymlwith outputs:previous_ref,deployed_ref,deploy_status,failure_detail - Register the strategy in
actions/deploy/action.yml - Set
strategy: <name>indeploy.config.yaml
| Workflow | Trigger | Purpose |
|---|---|---|
.github/workflows/ci.yml |
Push/PR to main, manual |
Validate scripts and deploy.config.yaml |
.github/workflows/validate-deploy.yml |
Push/PR (deploy paths), manual | Validate deploy platform files and generated issue template |
.github/workflows/sync-resources.yml |
Manual, push to deploy.config.yaml or sync scripts |
Provision labels, issue types, and deploy issue template |
.github/workflows/deploy.yml |
Issue opened, manual (with issue_number) |
Full deploy pipeline |
Important: deploy does not run on every commit. It triggers when:
- A deploy issue is opened (one run per issue β not on
labeledevents), or - You manually run Deploy in Actions with an
issue_numberinput (for retries).
Every push to main runs CI to validate the repository. Changes under deploy paths also trigger Validate Deploy (path-filtered).
labeled events no longer trigger the workflow (this prevents duplicate runs when the bot syncs service labels). To retry after a failure or partial deploy:
- Open Actions β Deploy β Run workflow
- Enter the issue number
- If approval is enabled, react with π on the new "Waiting for approval" comment (only reactions after that comment count; old π reactions are ignored)
Concurrency is set to cancel-in-progress: true β a new run cancels any in-progress run for the same issue.
Any workflow step that posts issue comments (via audit.sh, gh issue comment, etc.) must have GH_TOKEN, GITHUB_REPOSITORY, and ISSUE_NUMBER in its environment. The deploy workflow sets these at job level on wait-approval, healthcheck, rollback, manual-rollback, and finalize. When adding custom steps that audit to the issue, include the same env block.
GitHub issue forms do not map checkboxes to labels automatically. When a deploy issue is opened:
- Sync labels from issue form reads checked service boxes (and optional deploy-option boxes) from the issue body
- Missing labels are created and applied before validation
Label sync does not re-trigger the workflow β only opened and workflow_dispatch start a deploy run.
You can still add service labels manually or via CLI β both labels and checked boxes are recognized.
Customize strings in deploy.config.yaml; Sync Deploy Resources regenerates .github/ISSUE_TEMPLATE/deploy.yml:
deployment:
issue_template:
intro: |
Multi-line markdown shown at the top of the deploy form.
services_description: Select all services that should be deployed.
reason_placeholder: Deploy v1.2.0 after merging PR #42
notes_description: Expected rollback, dependencies, maintenance window...Generalize optional steps (e.g. database migrations) with options.deploy_options:
services:
backend:
options:
deploy_options:
- id: migrations
label: migrations # GitHub label applied when checked
checkbox: Run database migrations
description: Only when this service is selected.Deploy scripts can read the corresponding label on the issue to decide whether to run the option.
The deploy router (actions/deploy/action.yml) maps strategy outputs directly instead of re-emitting them through a collect step. Plain echo key=value breaks GITHUB_OUTPUT when failure_detail contains |, #, or multi-line Docker logs, and empty multiline heredocs can fail the step even on success. Strategy steps use continue-on-error: true; a final Verify deploy status step fails the action only when deploy status is failure or missing.
Issue (Type: Deploy + labels)
β setup (sync labels + validate + matrix)
β wait-approval (optional, poll π/π after approval comment timestamp)
β deploy (matrix per service β strategy action β per-service outcome artifact)
β deploy-summary (aggregate succeeded/failed services)
β healthcheck (matrix, gated per service deploy outcome)
β rollback-notify (comment on issue with failure details + @mentions)
β rollback (per service, only when that service failed)
β finalize (per-service outcomes β success / partial / rollback summary)
When deploying multiple services (fail-fast: false), each service is tracked independently:
- Backend OK + frontend fail β backend stays deployed; only frontend is rolled back
- Issue stays open with a partial deploy comment listing succeeded vs failed services
- Automatic rollback runs only for services that failed deploy or health check
Full success closes the issue. Full failure with successful rollback closes the issue and adds deploy:rolled-back.
When a deploy or health check fails and automatic rollback is enabled:
- Immediate alert on the issue with @mentions (issue author +
approval.users) - Failure details per service (HTTP status, deploy error, etc.) in collapsible
<details>blocks - Log excerpt from failed workflow steps via
gh run view --log-failed - Per-service rollback updates as each service is restored
- Final summary distinguishing:
- Environment restored (
deploy:rolled-backlabel, issue closed automatically) - Rollback also failed (manual intervention required)
- Partial rollback (some services could not be restored)
- Environment restored (
Configure log inclusion in deploy.config.yaml:
deployment:
observability:
notify_on_success: true
include_failed_logs: true
max_log_lines: 40
max_log_chars: 3500
changelog:
enabled: true
max_commits: 20
state_variable: DEPLOY_LAST_GIT_SHAWhen deploy and health checks succeed:
- Optional success comment on the issue (
notify_on_success, default on) with @mentions - Changelog link comparing the current commit with the last successful deploy (
changelog.enabled, default on) - Collapsible commit summary in the issue comment
- Issue is closed automatically
The last deployed Git SHA is stored in the repository variable DEPLOY_LAST_GIT_SHA (configurable via changelog.state_variable). The workflow needs actions: write permission on the finalize job to persist it. On the first deploy, the changelog falls back to parsing image tags from the previous deployment state.
Disable success notifications:
deployment:
observability:
notify_on_success: false| Legacy | New platform |
|---|---|
[DEPLOYMENT] title prefix |
Issue Type Deploy |
| Checkbox targets in issue body | Service labels from config (auto-synced on issue open) |
| Re-trigger on label change | workflow_dispatch with issue_number (retry flow) |
vars.ALLOWED_USERS_* |
deployment.approval.users |
| Per-service hardcoded jobs | Dynamic matrix + strategies |
curl GitHub API calls |
gh issue comment, gh issue close |
| Inline SSH health checks | healthcheck.sh + config |
The legacy monolithic workflow (cd.yml) has been removed. See examples/ for reference configuration.
Breaking changes when syncing this template into an existing project:
| Change | Action required |
|---|---|
labeled no longer triggers deploy |
Retries use Actions β Deploy β Run workflow with the issue number. Re-approve with π on the new approval comment. |
| Partial multi-service deploys | The issue may stay open when only some services fail; successful services are left running. Only failed services are rolled back. |
| Approval reactions are time-scoped | Only π reactions after the latest "Waiting for approval" comment count. Old reactions are ignored and removed after approval. |
deploy-summary job |
Ensure actions/checkout runs before sourcing .github/scripts/lib/*.sh in summary/finalize jobs. |
| Healthcheck for SPAs | Use a 2xx URL or set follow_redirects / accept_status per service. |
| Script strategy rollback | Scripts receive CONFIG_JSON; multi-repo deploys should validate rollback refs belong to origin/<branch>. |
- GitHub Actions
- GitHub CLI (
gh) on runners (pre-installed onubuntu-latest) yq(installed by workflows)- Repository permissions:
issues: write,contents: read,deployments: write
This platform builds on the original concept and base workflow created by @scarletquasar. The open-source implementation in this repository extends and generalizes that foundation.
Open source β customize deploy.config.yaml and extend strategies for your infrastructure.