Solo engineer pipeline
Skill OmarEltak/legacy-prod-survival-kit/skills/solo-engineer-pipeline
Use when scaffolding a deployment pipeline for a constrained shared-hosting environment (cPanel, Plesk, etc.) with no shell access and disabled PHP exec functions. Generates a complete pipeline; PHP-based deploy endpoint, smoke tests, GitHub Actions for lint/deploy/monitoring, with auto-rollback. All free-tier. Designed for the solo engineer who needs a production-grade pipeline without a DevOps team.From its SKILL.md
npx -y skills add OmarEltak/legacy-prod-survival-kit --skill solo-engineer-pipelineAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
18.4 KB, ~4.7k tokens by cl100k_base, as published. Nobody here has run it
Solo-engineer pipeline
When to use this
You need to ship code to a production system, but:
- The hosting is shared cPanel / Plesk / similar — no SSH, no shell access from PHP.
- You're alone. No DevOps team to ask, no QA to write tests, no SRE to set up monitoring.
- Standard tools (Capistrano, Deployer, GitHub Actions over SSH) don't work because of the constraints above.
- The site is real production — students, customers, payments. Not a hobby project.
- You need this working today, on free tools, with safety nets (rollback, smoke tests, monitoring).
This skill produces the full pipeline as deployable artifacts. You'll have a working CI/CD setup in about 30 minutes.
Run shared-hosting-recon first — its output tells you which deployment options are even possible on your specific host.
Architecture
┌──────────────────────┐ ┌──────────────────────┐
│ Local dev │ push │ GitHub Actions │
│ reg-mishkah-prod ├───────►│ ┌────────────────┐ │
└──────────────────────┘ │ │ 1. PHP lint │ │
│ │ 2. Deploy │ │
│ │ 3. Smoke tests │ │
│ │ 4. Auto- │ │
│ │ rollback │ │
│ └────────────────┘ │
└──────────┬───────────┘
│ HTTPS curl
▼
┌──────────────────────────────┐
│ yourdomain.com │
│ /_deploy.php?token=... │
│ ┌────────────────────────┐ │
│ │ Fetch zipball (GitHub) │ │
│ │ ZipArchive extract │ │
│ │ md5-skip + exclude │ │
│ │ State: SHAs persisted │ │
│ └────────────────────────┘ │
└──────────────┬───────────────┘
▼
/home/user/public_html
(live site)
Six artifacts to produce:
_deploy.php— token-gated PHP endpoint that fetches a zipball from GitHub, extracts, and md5-copies to public_html._deploy_config.php— secrets file (NOT in git), holds deploy token + GitHub PAT.scripts/smoke.sh— HTTP smoke tests against critical paths..github/workflows/deploy.yml— push-to-main → lint → deploy → smoke → auto-rollback..github/workflows/lint.yml— PHP syntax check on push/PR..github/workflows/monitoring.yml— every 10 min, smoke test, open issue on failure.
Pre-requisites
- A GitHub repository, private or public, with the code you want to deploy.
- A GitHub fine-grained PAT scoped to that one repo with
Contents: Read-only. - A deploy token: 32+ random hex bytes (
openssl rand -hex 32ornode -e "console.log(require('crypto').randomBytes(32).toString('hex'))"). - Confirmed via
shared-hosting-recon:allow_url_fopen: 1ZipArchive: AVAILABLE- PHP
memory_limit≥ 256MB (will set 512 in script) /home/<user>/public_htmlwritable from PHP- Some safe directory (e.g.
/home/<user>/.deploy_staging) writable from PHP
Skill questions
When invoked, ask the user:
- GitHub repo URL (e.g.,
OmarEltak/reg-mishkah-production) - Domain / base URL (e.g.,
https://reg.mishkahuniversity.com) - Server-side document root path (e.g.,
/home/reg/public_html) - Server-side staging dir for deploy temp files (e.g.,
/home/reg/.deploy_staging— outside web root) - Server-side config path (e.g.,
/home/reg/_deploy_config.php— outside web root) - PHP version (5.6, 7.x, 8.x — affects syntax in deploy script)
- Exclude list — directories to keep out of deploy. At minimum:
.git, the deploy script itself, large media dirs, user uploads. - Critical URLs for smoke tests (homepage, login, admin, checkout, etc.)
Then generate the artifacts.
Artifact 1 — _deploy.php
PHP 5.6 compatible. Goes at <doc_root>/_deploy.php.
Key behaviour:
- Loads config from outside web root.
- Gates on token via
hash_equals(constant-time compare). - Supports
?a=deploy(default),?a=status,?a=info(JSON),?a=rollback. - On
deploy: fetcheshttps://api.github.com/repos/<owner>/<repo>/zipball/<ref>with PAT inAuthorization: Bearerheader, viafile_get_contents+stream_context_create(no curl needed). - Extracts via
ZipArchive. GitHub zipballs nest under one top-level dir; auto-detect. - Recursive copy to doc root, skipping files in exclude list, md5-skipping unchanged files.
- Records
current_shaandprevious_shain a JSON state file. Onrollback, redeploysprevious_sha. - Logs every step to a per-run log file. Keeps last 5 logs.
- File-locks via
flockto prevent concurrent deploys. - Self-excludes — does not overwrite itself during deploy (avoids the "I'm rewriting myself mid-execution" problem).
Full template: see examples/_deploy.php in this kit. About 280 lines.
Critical implementation details (commonly missed):
// PHP 5.6 — no null coalescing operator
$token = isset($_GET['token']) ? $_GET['token'] : '';
// Constant-time token compare (prevents timing attacks)
if (!hash_equals($DEPLOY_TOKEN, $token)) {
http_response_code(403);
die("forbidden\n");
}
// HTTP context with PAT, NO curl_exec
$ctx = stream_context_create(array(
'http' => array(
'method' => 'GET',
'header' => "Authorization: Bearer $GITHUB_PAT\r\n" .
"User-Agent: deploy/1.0\r\n" .
"Accept: application/vnd.github+json\r\n",
'follow_location' => 1, // critical — GitHub redirects to AWS
'max_redirects' => 5,
'timeout' => 60,
'ignore_errors' => true, // capture body even on non-2xx
),
'ssl' => array(
'verify_peer' => true,
'verify_peer_name' => true,
),
));
$zip_data = @file_get_contents($apiUrl, false, $ctx);
// Inspect status from $http_response_header (set automatically by file_get_contents)
$http_status = 0;
foreach ($http_response_header as $h) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $http_status = (int)$m[1];
}
// Extract via ZipArchive (PharData also works for tar.gz — pick one)
$zip = new ZipArchive();
$zip->open($zip_path);
$zip->extractTo($extract_dir);
$zip->close();
// GitHub zipballs are nested in one dir
$tops = glob($extract_dir . '/*', GLOB_ONLYDIR);
$src_root = $tops[0];
// Capture commit SHA from the dir name (format: <owner>-<repo>-<sha>)
preg_match('/-([0-9a-f]{7,40})$/', basename($src_root), $m);
$deployed_sha = $m[1];
// Recursive copy with md5 skip
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src_root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $info) {
$rel = str_replace('\\', '/', substr($info->getPathname(), strlen($src_root) + 1));
if (path_excluded($rel)) continue;
$dest = $deploy_path . '/' . $rel;
if ($info->isDir()) {
if (!is_dir($dest)) @mkdir($dest, 0755, true);
continue;
}
// md5 skip — only copy if content actually different
if (is_file($dest) &&
filesize($dest) === filesize($info->getPathname()) &&
@md5_file($dest) === @md5_file($info->getPathname())) {
continue;
}
@mkdir(dirname($dest), 0755, true);
@copy($info->getPathname(), $dest);
@chmod($dest, 0644);
}
Artifact 2 — _deploy_config.php
NOT in git. Lives outside web root. Generated locally and uploaded once via cPanel File Manager.
<?php
// /home/<user>/_deploy_config.php — secrets, NOT in git
$DEPLOY_TOKEN = '<32-char hex>';
$GITHUB_PAT = 'github_pat_...';
$GITHUB_OWNER = '<owner>';
$GITHUB_REPO = '<repo>';
$DEPLOY_PATH = '/home/<user>/public_html';
$STAGING_DIR = '/home/<user>/.deploy_staging';
Artifact 3 — scripts/smoke.sh
Bash. Curls a list of URLs, checks expected HTTP status. Exits non-zero if any fail.
#!/usr/bin/env bash
set -u
BASE="${1:-https://yourdomain.com}"
PASS=0
FAIL=0
check() {
local path="$1" expected="$2" desc="${3:-}"
local actual
actual="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$BASE$path" 2>&1 || echo CURL_ERR)"
if [[ "$actual" == "$expected" ]]; then
printf ' [PASS] %-40s %s%s\n' "$path" "$actual" "${desc:+ ($desc)}"
PASS=$((PASS + 1))
else
printf ' [FAIL] %-40s expected=%s got=%s%s\n' "$path" "$expected" "$actual" "${desc:+ ($desc)}"
FAIL=$((FAIL + 1))
fi
}
echo "Smoke tests against: $BASE"
check "/" "302" "homepage redirects"
check "/login.php" "200" "login page"
check "/admin/" "401" "admin auth-required"
# ... add 5–8 more for your critical paths
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -gt 0 ]] && exit 1 || exit 0
Aim for 6–10 checks. Each one < 15 seconds. Total budget: under 2 minutes.
Artifact 4 — .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch:
inputs:
ref: {description: 'Git ref', required: false, default: 'main'}
concurrency:
group: prod-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
BASE_URL: https://yourdomain.com
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with: { php-version: '5.6', tools: none }
- name: PHP lint
run: |
set -e
while IFS= read -r f; do
out="$(php -l -- "$f" 2>&1)"
case "$out" in
"No syntax errors detected"*) ;;
*) echo "SYNTAX ERROR in $f"; echo "$out"; exit 1 ;;
esac
done < <(git ls-files '*.php')
- name: Trigger deploy
id: deploy
run: |
curl -sS --max-time 300 \
"$BASE_URL/_deploy.php?token=$DEPLOY_TOKEN&ref=${{ github.event.inputs.ref || 'main' }}" \
| tee deploy.log
- name: Wait
run: sleep 5
- name: Smoke tests
id: smoke
run: bash scripts/smoke.sh "$BASE_URL"
- name: AUTO-ROLLBACK on smoke failure
if: failure() && steps.smoke.outcome == 'failure'
run: |
echo "::error::Smoke failed. Rolling back."
curl -sS --max-time 300 "$BASE_URL/_deploy.php?token=$DEPLOY_TOKEN&a=rollback" | tee rollback.log
sleep 5
bash scripts/smoke.sh "$BASE_URL" || {
echo "::error::CRITICAL: rollback didn't fix it. Manual intervention required."
exit 1
}
exit 1 # mark run failed even though we recovered, so you notice
- if: always()
uses: actions/upload-artifact@v4
with:
name: deploy-logs-${{ github.run_id }}
path: |
deploy.log
rollback.log
if-no-files-found: ignore
retention-days: 14
Artifact 5 — .github/workflows/lint.yml
Standalone lint that also runs on PR / dev branch (catches errors before merge):
name: PHP Lint
on:
push: { branches: [main, dev] }
pull_request: { branches: [main] }
jobs:
php-syntax-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '5.6', tools: none }
- name: Lint all .php files
run: |
set -e
fail=0
while IFS= read -r f; do
out="$(php -l -- "$f" 2>&1)"
case "$out" in
"No syntax errors detected"*) ;;
*) echo "SYNTAX ERROR in $f"; echo "$out"; fail=1 ;;
esac
done < <(git ls-files '*.php')
[[ $fail -eq 0 ]] || exit 1
Artifact 6 — .github/workflows/monitoring.yml
name: Production Monitoring
on:
schedule: [{cron: '*/10 * * * *'}] # every 10 min (5 is GH minimum, 10 saves Actions minutes)
workflow_dispatch:
concurrency:
group: monitoring
cancel-in-progress: false
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: bash scripts/smoke.sh # uses production URL hardcoded in script
- name: Open issue on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const open = await github.rest.issues.listForRepo({
owner: context.repo.owner, repo: context.repo.repo,
state: 'open', labels: 'monitoring', per_page: 5,
});
if (open.data.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: open.data[0].number,
body: `Smoke tests failed at ${new Date().toISOString()}.\nRun: ${context.payload.repository.html_url}/actions/runs/${context.runId}`,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner, repo: context.repo.repo,
title: `Site monitoring: smoke failing (${new Date().toISOString()})`,
labels: ['monitoring', 'urgent'],
body: `Production smoke tests are failing.\n\nRun: ${context.payload.repository.html_url}/actions/runs/${context.runId}\n\nQuick triage:\n1. Check the site in a browser.\n2. Check ${{ inputs.deploy_url || 'deploy info' }} (\`?a=info\`).\n3. If a recent deploy is the cause: \`?a=rollback\`.`,
});
}
GitHub Actions free tier on private repos: 2,000 min/month. This monitoring uses ~25 min/day = ~750/month. Comfortable headroom.
Setup steps (after artifacts are generated)
- Generate token & PAT.
- Create
_deploy_config.phpwith real values; upload via cPanel File Manager to/home/<user>/_deploy_config.php(outside web root). - Upload
_deploy.phpto web root via cPanel. - Test status:
curl "https://yourdomain.com/_deploy.php?token=<TOKEN>&a=status"— should return env info, not 403, not 500. - Add token to GitHub Actions secrets:
gh secret set DEPLOY_TOKEN --repo <owner>/<repo>. - Commit & push the
.github/workflows/files. - First test deploy: push a tiny change. Watch the action run. Verify on prod.
- Set up rotation reminder: the GitHub PAT expires in 90 days. Calendar reminder for day 80.
Self-update problem (and the bootstrap pattern)
_deploy.php is in your git repo, but excluded from the deploy operation (otherwise the script overwrites itself mid-execution — usually fine but occasionally weird).
To update _deploy.php itself:
- Push the new version to the repo.
- Upload a tiny bootstrap PHP that fetches the latest
_deploy.phpfrom GitHub and writes it. The bootstrap self-deletes after running. - Run the bootstrap once. Future deploys benefit from the new logic.
<?php
// _deploy_bootstrap.php — one-time self-updater. Self-deletes.
$config = '/home/<user>/_deploy_config.php';
require $config;
$k = isset($_GET['k']) ? $_GET['k'] : '';
if (!hash_equals($DEPLOY_TOKEN, $k)) { http_response_code(403); die('forbidden'); }
header('Content-Type: text/plain');
$url = "https://api.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO/contents/_deploy.php?ref=main";
$ctx = stream_context_create(array(
'http' => array(
'method' => 'GET',
'header' => "Authorization: Bearer $GITHUB_PAT\r\nUser-Agent: bootstrap\r\nAccept: application/vnd.github.v3.raw\r\n",
'follow_location' => 1, 'max_redirects' => 5, 'timeout' => 30,
),
'ssl' => array('verify_peer' => true, 'verify_peer_name' => true),
));
$content = @file_get_contents($url, false, $ctx);
if ($content === false || strlen($content) < 500) { die("fetch failed\n"); }
file_put_contents('/home/<user>/public_html/_deploy.php', $content);
@chmod('/home/<user>/public_html/_deploy.php', 0644);
echo "updated\n";
@unlink(__FILE__);
echo "self-deleted\n";
Limitations
- PHP-only deploy — no shell-out, so no
rsync, nogit pull, no native parallelism. Sufficient for codebases under ~50 MB. - No
--deletesemantics — files removed from git stay on prod until manually deleted. This is intentional (safety) but means orphan cleanup is a separate manual step. - Sequential, single-process — one deploy at a time (file lock). Multiple developers pushing rapidly will queue.
- GitHub-specific — uses the GitHub zipball API. Adapting to GitLab / Bitbucket / Gitea requires changing one URL and the auth header format.
Why this skill exists
Building this pipeline from scratch took ~3 hours of trial-and-error. The rabbit holes:
- cPanel native deploy — broken on Codero by
/tmpperms. - SSH key with passphrase — cPanel forces passphrase, can't be entered non-interactively.
- HTTPS in clone URL — cPanel rejects credentials in URL. Workaround: write
~/.git-credentials. passthru('tar ...')— disabled. Need pure PHP.curl_exec— disabled. Needfile_get_contentswith stream context._deploy.phpoverwriting itself during deploy — needed exclude-self logic.
This skill compresses all of that into a 30-minute setup. The pain is real and recurring — every solo engineer on shared hosting hits the same walls.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.