Solo engineer pipeline
Skill OmarEltak/legacy-prod-survival-kit/skills/solo-engineer-pipeline
Claude Code skills for the solo engineer who just inherited a 17-year-old production system. Audit, deploy, monitor, and report — without a DevOps team.
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.
What its author says it does
Copied from the file, not written here
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.
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.