feat(setup-project): auto-PR working Dockerfiles back to upstream
## Context
The setup-project skill treats "build from a Dockerfile" as priority 6 — "last
resort, avoid if possible" — with no formalized path for apps whose upstream
lacks a working Dockerfile. When we end up writing one to get the deploy green,
that Dockerfile stays private in the infra repo and upstream never benefits.
## This change
Adds a closed-loop flow: when we author a new Dockerfile (or fix a broken
upstream one) and the deploy is healthy for 10 minutes, auto-open a PR against
the upstream repo so the self-hosting community gets the working recipe.
Flow:
1. Classify dockerfile_state during research phase (image-used / used-as-is /
fixed-broken-upstream / written-from-scratch). Persist to
modules/kubernetes/<service>/.contribution-state.json.
2. After Terraform apply, run scripts/stability-gate.sh — polls pod Ready +
HTTP 200 every 30s x 20 iterations, requires 18/20 successes.
3. On pass with a trigger state, scripts/contribute-dockerfile.sh does the
GitHub API dance: fork → merge-upstream → branch → commit Dockerfile /
.dockerignore / BUILD.md via Contents API → open PR with body rendered from
templates/PR_BODY.md. Idempotent (skips on recorded PR URL, existing fork,
existing branch, open PR, upstream landed a Dockerfile mid-deploy).
GitHub API via curl (gh CLI is sandbox-blocked per .claude/CLAUDE.md); token
pulled from Vault (`secret/viktor` → `github_pat`). Commits include
Signed-off-by for DCO-enforcing repos. Fork branch name is `add-dockerfile`
for written-from-scratch or `fix-dockerfile` for fixed-broken-upstream, with
timestamp suffix on collision.
## Files
- SKILL.md — state classification table, quality bar checklist, §8b stability
gate, §10 contribute-upstream step, checklist updates
- scripts/stability-gate.sh — 10-minute health probe
- scripts/contribute-dockerfile.sh — GitHub API orchestrator
- templates/PR_BODY.md — `{{VAR}}` placeholder template for PR description
- templates/Dockerfile.README.md — BUILD.md template shipped with the PR
## What is NOT in this change
- No Woodpecker / GHA changes (skill-local flow).
- No auto-tracking of merge/reject outcomes upstream (manual follow-up).
- Not yet exercised end-to-end; first real-world run will validate the API
dance. Plan to dry-run against a throwaway sink repo before pointing at a
real upstream.
## Test Plan
### Automated
- bash -n on both scripts → pass
- Manual read-through of SKILL.md — step numbering coherent, existing
§1-9 untouched semantics, new §8b/§10 reference real files
### Manual Verification
1. Next time setup-project onboards a Dockerfile-less app:
- Confirm .contribution-state.json is written with `written-from-scratch`
- Run stability-gate.sh — expect 18/20 passes on a healthy deploy
- Run contribute-dockerfile.sh — expect a fork + branch + PR on ViktorBarzin
- Verify contribution_pr_url is back-written to the state file
2. Re-run contribute-dockerfile.sh → must be a no-op (idempotent)
3. Upstream-archived case: manually archive a test upstream → re-run →
expect SKIP, no PR created
[ci skip]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1860cd1dfb
commit
5e9e487661
5 changed files with 474 additions and 0 deletions
|
|
@ -43,6 +43,40 @@ date: 2025-01-01
|
|||
5. Check releases page for container images
|
||||
6. Last resort: Build from Dockerfile (avoid if possible)
|
||||
|
||||
**Classify Dockerfile State** (drives whether we contribute a PR back upstream later):
|
||||
|
||||
| State | When | Action on deploy success |
|
||||
|---|---|---|
|
||||
| `image-used` | An official/community image worked (priority 1-5). | No upstream PR. Default case. |
|
||||
| `used-as-is` | Upstream ships a Dockerfile; it built and ran fine. | No upstream PR. |
|
||||
| `fixed-broken-upstream` | Upstream Dockerfile exists but fails to build / run; we patched it. | Open a `fix-dockerfile` PR after stability gate. |
|
||||
| `written-from-scratch` | Upstream has no Dockerfile at all; we authored one. | Open an `add-dockerfile` PR after stability gate. |
|
||||
|
||||
Record the chosen state and supporting metadata in `modules/kubernetes/<service>/.contribution-state.json`. When we author or fix a Dockerfile, also write `modules/kubernetes/<service>/files/Dockerfile`, `.dockerignore`, and `BUILD.md` (from `templates/Dockerfile.README.md`) — these travel with the upstream PR.
|
||||
|
||||
```json
|
||||
{
|
||||
"upstream_repo": "owner/name",
|
||||
"dockerfile_state": "written-from-scratch",
|
||||
"dockerfile_path_in_infra": "modules/kubernetes/<service>/files/Dockerfile",
|
||||
"deploy_target_url": "https://<service>.viktorbarzin.me",
|
||||
"image_tag": "registry.viktorbarzin.me/<service>:<sha>",
|
||||
"image_size": "<MB>",
|
||||
"base_image": "<e.g. python:3.12-slim>",
|
||||
"dockerfile_shape": "multi-stage, non-root, linux/amd64",
|
||||
"deploy_verified_at": null,
|
||||
"contribution_pr_url": null
|
||||
}
|
||||
```
|
||||
|
||||
**Dockerfile quality bar** (when writing one ourselves — enforced before PR):
|
||||
- Multi-stage build where it makes sense (Node, Go, Rust, Python with compiled deps).
|
||||
- Explicit non-root `USER`.
|
||||
- `HEALTHCHECK` when the app exposes a known endpoint.
|
||||
- Minimal base image (alpine / distroless preferred; `-slim` otherwise).
|
||||
- No secrets baked in; runtime config via `ENV`.
|
||||
- `.dockerignore` that excludes `.git`, `node_modules`, test artifacts.
|
||||
|
||||
**Extract Configuration**:
|
||||
- Container port (default port the app listens on)
|
||||
- Environment variables (DATABASE_URL, REDIS_HOST, SMTP, etc.)
|
||||
|
|
@ -345,6 +379,21 @@ kubectl logs -n <service> -l app=<service> --tail=50
|
|||
|
||||
Test URL: `https://<service>.viktorbarzin.me`
|
||||
|
||||
### 8b. Stability Gate (required when `dockerfile_state ∈ {written-from-scratch, fixed-broken-upstream}`)
|
||||
|
||||
Before committing — and before any upstream PR in §10 — run a 10-minute stability check to catch pods that crash-loop a few minutes after Ready.
|
||||
|
||||
```bash
|
||||
.claude/skills/setup-project/scripts/stability-gate.sh <service> <service> https://<service>.viktorbarzin.me
|
||||
```
|
||||
|
||||
Polls pod readiness + `curl` 200 every 30s × 20 iterations. Requires 18/20 successes (tolerates 2 blips).
|
||||
|
||||
- **Pass** → update the state file: `jq '.deploy_verified_at = (now | todate)' .contribution-state.json | sponge .contribution-state.json` → proceed to §9 and §10.
|
||||
- **Fail** → stop. Investigate via `kubectl logs`, `kubectl describe`. Do NOT commit. Do NOT fire §10. Re-run the gate after fixes.
|
||||
|
||||
For `image-used` / `used-as-is` states, the gate is optional (app is already running a known-good image).
|
||||
|
||||
### 9. Commit Changes
|
||||
|
||||
```bash
|
||||
|
|
@ -358,6 +407,37 @@ git commit -m "Add <service> deployment
|
|||
[ci skip]"
|
||||
```
|
||||
|
||||
### 10. Contribute Dockerfile Upstream (only when `dockerfile_state ∈ {written-from-scratch, fixed-broken-upstream}`)
|
||||
|
||||
Goal: give the community the working Dockerfile we just validated in production.
|
||||
|
||||
**Preconditions** (script enforces):
|
||||
- `.contribution-state.json` present with a trigger state and `deploy_verified_at` set.
|
||||
- `files/Dockerfile`, `files/.dockerignore`, `files/BUILD.md` exist next to the module.
|
||||
- `GITHUB_TOKEN` in env — or `vault kv get -field=github_pat secret/viktor` is reachable.
|
||||
|
||||
**Run**:
|
||||
```bash
|
||||
.claude/skills/setup-project/scripts/contribute-dockerfile.sh modules/kubernetes/<service>
|
||||
```
|
||||
|
||||
**What the script does** (all via GitHub REST — `gh` CLI is sandbox-blocked):
|
||||
1. Reads `.contribution-state.json`; skips unless state is `written-from-scratch` or `fixed-broken-upstream` and no `contribution_pr_url` is already recorded.
|
||||
2. Upstream sanity checks: repo exists, public, not archived; default branch discoverable; for `written-from-scratch`, verifies a `Dockerfile` didn't land upstream while we were deploying; bails cleanly if an open PR from our fork already exists.
|
||||
3. `POST /repos/<owner>/<name>/forks` — idempotent; waits up to 30s for the fork to be ready at `ViktorBarzin/<name>`.
|
||||
4. `POST /repos/ViktorBarzin/<name>/merge-upstream` — keeps fork current with upstream default branch.
|
||||
5. Creates branch `add-dockerfile` (or `fix-dockerfile`), timestamp-suffixed if that branch already exists with unrelated commits.
|
||||
6. Commits `Dockerfile`, `.dockerignore`, `BUILD.md` via Contents API. Each commit message carries `Signed-off-by:` for DCO-enforcing repos.
|
||||
7. Opens PR against upstream with body rendered from `templates/PR_BODY.md`.
|
||||
8. Writes `contribution_pr_url` back into `.contribution-state.json` and echoes the URL.
|
||||
|
||||
**Failure handling**:
|
||||
- Upstream archived / private / deleted → logged as SKIP, deploy success stands.
|
||||
- Fork/branch/PR already exists → treated as idempotent success; existing URL recorded.
|
||||
- GitHub 5xx → 3× exponential backoff, then hard fail with a clear message — safe to re-run the script.
|
||||
|
||||
**After the PR opens**: the URL is in `.contribution-state.json`. Share it with the user. No automated follow-up on merge/reject — that's a manual check for now.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Init Container for Migrations
|
||||
|
|
@ -410,13 +490,17 @@ env {
|
|||
- [ ] Create NFS directory and export on TrueNAS (if persistent storage needed)
|
||||
- [ ] Verify NFS mount is accessible from k8s nodes
|
||||
- [ ] Create `modules/kubernetes/<service>/main.tf`
|
||||
- [ ] Classify `dockerfile_state` and write `.contribution-state.json`
|
||||
- [ ] If writing/fixing Dockerfile: satisfy the quality bar (multi-stage, non-root, `.dockerignore`, `BUILD.md`)
|
||||
- [ ] Update `modules/kubernetes/main.tf` (variables, DEFCON level, module block)
|
||||
- [ ] Update `main.tf` (variable, pass to module)
|
||||
- [ ] Update `terraform.tfvars` (password, Cloudflare DNS)
|
||||
- [ ] Run `terraform init` and `terraform apply`
|
||||
- [ ] Verify pods are running
|
||||
- [ ] Test the URL
|
||||
- [ ] Run stability-gate.sh — needed for contribution, optional otherwise
|
||||
- [ ] Commit changes with `[ci skip]`
|
||||
- [ ] Run contribute-dockerfile.sh if state triggers an upstream PR
|
||||
|
||||
## Questions to Ask User
|
||||
|
||||
|
|
|
|||
270
.claude/skills/setup-project/scripts/contribute-dockerfile.sh
Executable file
270
.claude/skills/setup-project/scripts/contribute-dockerfile.sh
Executable file
|
|
@ -0,0 +1,270 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contribute a working Dockerfile back to an upstream GitHub repo.
|
||||
#
|
||||
# Reads state from <service-module-dir>/.contribution-state.json and:
|
||||
# 1. Validates triggers (dockerfile_state ∈ {written-from-scratch, fixed-broken-upstream})
|
||||
# 2. Confirms upstream is public, not archived, no concurrent Dockerfile landed
|
||||
# 3. Forks upstream to ViktorBarzin (idempotent)
|
||||
# 4. Syncs fork with upstream default branch
|
||||
# 5. Creates branch (add-dockerfile or fix-dockerfile), appends -<ts> on collision
|
||||
# 6. Commits Dockerfile + .dockerignore + BUILD.md via Contents API
|
||||
# 7. Opens PR against upstream with body rendered from PR_BODY.md
|
||||
# 8. Writes contribution_pr_url back into state file
|
||||
#
|
||||
# Usage:
|
||||
# contribute-dockerfile.sh <service-module-dir>
|
||||
#
|
||||
# Example:
|
||||
# contribute-dockerfile.sh /home/wizard/code/infra/modules/kubernetes/myapp
|
||||
#
|
||||
# Requires: jq, curl, vault CLI (logged in).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEMPLATES_DIR="$(cd "$SCRIPT_DIR/../templates" && pwd)"
|
||||
|
||||
FORK_OWNER="ViktorBarzin"
|
||||
|
||||
log() { echo "contribute-dockerfile: $*"; }
|
||||
die() { echo "contribute-dockerfile: ERROR: $*" >&2; exit 1; }
|
||||
skip() { echo "contribute-dockerfile: SKIP: $*"; exit 0; }
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
die "usage: $0 <service-module-dir>"
|
||||
fi
|
||||
|
||||
MODULE_DIR="$1"
|
||||
STATE_FILE="$MODULE_DIR/.contribution-state.json"
|
||||
|
||||
[ -f "$STATE_FILE" ] || die "state file not found: $STATE_FILE"
|
||||
|
||||
# --- Read + validate state ---
|
||||
dockerfile_state=$(jq -r '.dockerfile_state // ""' "$STATE_FILE")
|
||||
upstream_repo=$(jq -r '.upstream_repo // ""' "$STATE_FILE")
|
||||
dockerfile_path=$(jq -r '.dockerfile_path_in_infra // ""' "$STATE_FILE")
|
||||
deploy_verified_at=$(jq -r '.deploy_verified_at // ""' "$STATE_FILE")
|
||||
existing_pr_url=$(jq -r '.contribution_pr_url // ""' "$STATE_FILE")
|
||||
|
||||
if [ -n "$existing_pr_url" ] && [ "$existing_pr_url" != "null" ]; then
|
||||
skip "PR already exists: $existing_pr_url"
|
||||
fi
|
||||
|
||||
case "$dockerfile_state" in
|
||||
written-from-scratch) BRANCH_NAME="add-dockerfile"; reason_type="none" ;;
|
||||
fixed-broken-upstream) BRANCH_NAME="fix-dockerfile"; reason_type="broken" ;;
|
||||
*) skip "dockerfile_state='$dockerfile_state' — nothing to contribute" ;;
|
||||
esac
|
||||
|
||||
[ -z "$deploy_verified_at" ] || [ "$deploy_verified_at" = "null" ] && die "deploy not verified yet (deploy_verified_at empty); run stability-gate first"
|
||||
|
||||
[ -z "$upstream_repo" ] && die "upstream_repo empty in state file"
|
||||
[[ "$upstream_repo" == */* ]] || die "upstream_repo must be owner/name, got: $upstream_repo"
|
||||
|
||||
UP_OWNER="${upstream_repo%/*}"
|
||||
UP_NAME="${upstream_repo#*/}"
|
||||
|
||||
abs_dockerfile="$MODULE_DIR/$(basename "$dockerfile_path")"
|
||||
if [ ! -f "$MODULE_DIR/files/Dockerfile" ]; then
|
||||
die "Dockerfile not found at $MODULE_DIR/files/Dockerfile"
|
||||
fi
|
||||
DOCKERFILE_SRC="$MODULE_DIR/files/Dockerfile"
|
||||
DOCKERIGNORE_SRC="$MODULE_DIR/files/.dockerignore"
|
||||
BUILDMD_SRC="$MODULE_DIR/files/BUILD.md"
|
||||
for f in "$DOCKERIGNORE_SRC" "$BUILDMD_SRC"; do
|
||||
[ -f "$f" ] || die "required file missing: $f"
|
||||
done
|
||||
|
||||
# --- GitHub auth ---
|
||||
GITHUB_TOKEN="${GITHUB_TOKEN:-$(vault kv get -field=github_pat secret/viktor 2>/dev/null || true)}"
|
||||
[ -n "$GITHUB_TOKEN" ] || die "GITHUB_TOKEN not set and vault lookup failed (vault login -method=oidc first)"
|
||||
|
||||
gh_api() {
|
||||
local method="$1"; local path="$2"; local data="${3:-}"
|
||||
local url="https://api.github.com${path}"
|
||||
local curl_args=(-sS -w "\n%{http_code}" -X "$method"
|
||||
-H "Authorization: token $GITHUB_TOKEN"
|
||||
-H "Accept: application/vnd.github+json"
|
||||
-H "X-GitHub-Api-Version: 2022-11-28")
|
||||
[ -n "$data" ] && curl_args+=(-d "$data")
|
||||
curl "${curl_args[@]}" "$url"
|
||||
}
|
||||
|
||||
gh_api_retry() {
|
||||
local method="$1"; local path="$2"; local data="${3:-}"
|
||||
local attempt=1
|
||||
local max_attempts=3
|
||||
local out http
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
out=$(gh_api "$method" "$path" "$data")
|
||||
http=$(printf '%s' "$out" | tail -n1)
|
||||
body=$(printf '%s' "$out" | sed '$d')
|
||||
if [ "$http" -ge 500 ] || [ "$http" = "000" ]; then
|
||||
log "retry $attempt/$max_attempts on $method $path (http=$http)"
|
||||
attempt=$((attempt + 1))
|
||||
sleep $((2 ** attempt))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n%s' "$body" "$http"
|
||||
return 0
|
||||
done
|
||||
die "GitHub API 5xx after $max_attempts attempts on $method $path"
|
||||
}
|
||||
|
||||
# Helpers that parse the combined body+http form.
|
||||
gh_http() { printf '%s' "$1" | tail -n1; }
|
||||
gh_body() { printf '%s' "$1" | sed '$d'; }
|
||||
|
||||
# --- Upstream sanity checks ---
|
||||
log "checking upstream $upstream_repo"
|
||||
resp=$(gh_api_retry GET "/repos/$UP_OWNER/$UP_NAME")
|
||||
http=$(gh_http "$resp"); body=$(gh_body "$resp")
|
||||
if [ "$http" = "404" ]; then skip "upstream repo not found (may be private or deleted): $upstream_repo"; fi
|
||||
[ "$http" = "200" ] || die "GET upstream failed http=$http body=$body"
|
||||
|
||||
archived=$(printf '%s' "$body" | jq -r '.archived')
|
||||
default_branch=$(printf '%s' "$body" | jq -r '.default_branch')
|
||||
[ "$archived" = "true" ] && skip "upstream is archived — not opening PR"
|
||||
[ -n "$default_branch" ] || die "could not determine upstream default branch"
|
||||
log "upstream default branch: $default_branch"
|
||||
|
||||
# If we wrote the Dockerfile from scratch, make sure one didn't land upstream meanwhile.
|
||||
if [ "$dockerfile_state" = "written-from-scratch" ]; then
|
||||
resp=$(gh_api_retry GET "/repos/$UP_OWNER/$UP_NAME/contents/Dockerfile?ref=$default_branch")
|
||||
http=$(gh_http "$resp")
|
||||
if [ "$http" = "200" ]; then
|
||||
skip "a Dockerfile landed upstream since we started — aborting to avoid clobbering"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for an existing open PR from our fork.
|
||||
resp=$(gh_api_retry GET "/repos/$UP_OWNER/$UP_NAME/pulls?state=open&head=${FORK_OWNER}:${BRANCH_NAME}")
|
||||
http=$(gh_http "$resp"); body=$(gh_body "$resp")
|
||||
if [ "$http" = "200" ]; then
|
||||
existing=$(printf '%s' "$body" | jq -r '.[0].html_url // ""')
|
||||
if [ -n "$existing" ]; then
|
||||
log "existing open PR found: $existing — recording and skipping"
|
||||
jq --arg url "$existing" '.contribution_pr_url = $url' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fork ---
|
||||
log "ensuring fork exists at $FORK_OWNER/$UP_NAME"
|
||||
resp=$(gh_api_retry POST "/repos/$UP_OWNER/$UP_NAME/forks" '{}')
|
||||
http=$(gh_http "$resp")
|
||||
if [ "$http" != "202" ] && [ "$http" != "200" ]; then
|
||||
die "fork call failed http=$http"
|
||||
fi
|
||||
|
||||
# Wait for fork to be ready (GitHub can take up to ~30s).
|
||||
for i in $(seq 1 15); do
|
||||
resp=$(gh_api_retry GET "/repos/$FORK_OWNER/$UP_NAME")
|
||||
if [ "$(gh_http "$resp")" = "200" ]; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$(gh_http "$resp")" = "200" ] || die "fork $FORK_OWNER/$UP_NAME did not become ready"
|
||||
|
||||
# --- Sync fork with upstream default branch ---
|
||||
log "syncing fork with upstream/$default_branch"
|
||||
resp=$(gh_api_retry POST "/repos/$FORK_OWNER/$UP_NAME/merge-upstream" "$(jq -n --arg b "$default_branch" '{branch:$b}')")
|
||||
http=$(gh_http "$resp")
|
||||
[ "$http" = "200" ] || [ "$http" = "409" ] || log "merge-upstream returned http=$http (continuing)"
|
||||
|
||||
# --- Determine base SHA for new branch ---
|
||||
resp=$(gh_api_retry GET "/repos/$FORK_OWNER/$UP_NAME/git/ref/heads/$default_branch")
|
||||
http=$(gh_http "$resp"); body=$(gh_body "$resp")
|
||||
[ "$http" = "200" ] || die "could not read default branch ref on fork (http=$http)"
|
||||
base_sha=$(printf '%s' "$body" | jq -r '.object.sha')
|
||||
|
||||
# --- Create branch (or append timestamp on collision) ---
|
||||
attempt_branch="$BRANCH_NAME"
|
||||
resp=$(gh_api_retry GET "/repos/$FORK_OWNER/$UP_NAME/git/ref/heads/$attempt_branch")
|
||||
if [ "$(gh_http "$resp")" = "200" ]; then
|
||||
attempt_branch="${BRANCH_NAME}-$(date +%s | tail -c 9)"
|
||||
log "branch existed; using $attempt_branch"
|
||||
fi
|
||||
|
||||
log "creating branch $attempt_branch off $base_sha"
|
||||
payload=$(jq -n --arg r "refs/heads/$attempt_branch" --arg s "$base_sha" '{ref:$r,sha:$s}')
|
||||
resp=$(gh_api_retry POST "/repos/$FORK_OWNER/$UP_NAME/git/refs" "$payload")
|
||||
[ "$(gh_http "$resp")" = "201" ] || die "could not create branch: $(gh_body "$resp")"
|
||||
|
||||
# --- Helper to PUT a file via Contents API ---
|
||||
put_file() {
|
||||
local src="$1"; local dst="$2"; local message="$3"
|
||||
local b64 payload exists_resp http existing_sha=""
|
||||
b64=$(base64 -w0 < "$src")
|
||||
|
||||
exists_resp=$(gh_api_retry GET "/repos/$FORK_OWNER/$UP_NAME/contents/$dst?ref=$attempt_branch")
|
||||
if [ "$(gh_http "$exists_resp")" = "200" ]; then
|
||||
existing_sha=$(gh_body "$exists_resp" | jq -r '.sha')
|
||||
fi
|
||||
|
||||
if [ -n "$existing_sha" ]; then
|
||||
payload=$(jq -n --arg m "$message" --arg c "$b64" --arg b "$attempt_branch" --arg sha "$existing_sha" \
|
||||
'{message:$m, content:$c, branch:$b, sha:$sha}')
|
||||
else
|
||||
payload=$(jq -n --arg m "$message" --arg c "$b64" --arg b "$attempt_branch" \
|
||||
'{message:$m, content:$c, branch:$b}')
|
||||
fi
|
||||
|
||||
resp=$(gh_api_retry PUT "/repos/$FORK_OWNER/$UP_NAME/contents/$dst" "$payload")
|
||||
http=$(gh_http "$resp")
|
||||
[ "$http" = "200" ] || [ "$http" = "201" ] || die "PUT $dst failed http=$http body=$(gh_body "$resp")"
|
||||
}
|
||||
|
||||
commit_msg_prefix="Add Dockerfile"
|
||||
[ "$dockerfile_state" = "fixed-broken-upstream" ] && commit_msg_prefix="Fix Dockerfile"
|
||||
|
||||
log "committing Dockerfile, .dockerignore, BUILD.md"
|
||||
put_file "$DOCKERFILE_SRC" "Dockerfile" "$commit_msg_prefix
|
||||
|
||||
Signed-off-by: Viktor Barzin <viktorbarzin@meta.com>"
|
||||
put_file "$DOCKERIGNORE_SRC" ".dockerignore" "Add .dockerignore
|
||||
|
||||
Signed-off-by: Viktor Barzin <viktorbarzin@meta.com>"
|
||||
put_file "$BUILDMD_SRC" "BUILD.md" "Add BUILD.md
|
||||
|
||||
Signed-off-by: Viktor Barzin <viktorbarzin@meta.com>"
|
||||
|
||||
# --- Render PR body ---
|
||||
reason_paragraph="This project currently has no Dockerfile, making it harder for the self-hosting community to run this. I put together a working one while deploying this app to my home Kubernetes cluster and wanted to upstream it."
|
||||
if [ "$reason_type" = "broken" ]; then
|
||||
reason_paragraph="The existing Dockerfile in this repo does not build cleanly for \`linux/amd64\`. I tracked down the fixes while deploying this app to my home Kubernetes cluster and wanted to upstream them."
|
||||
fi
|
||||
|
||||
IMAGE_SIZE=$(jq -r '.image_size // "unknown"' "$STATE_FILE")
|
||||
BASE_IMAGE=$(jq -r '.base_image // "unknown"' "$STATE_FILE")
|
||||
IMAGE_TAG=$(jq -r '.image_tag // "myapp:latest"' "$STATE_FILE")
|
||||
DOCKERFILE_SHAPE=$(jq -r '.dockerfile_shape // "multi-stage, non-root, linux/amd64"' "$STATE_FILE")
|
||||
|
||||
pr_body=$(cat "$TEMPLATES_DIR/PR_BODY.md")
|
||||
pr_body="${pr_body//\{\{REASON_PARAGRAPH\}\}/$reason_paragraph}"
|
||||
pr_body="${pr_body//\{\{DOCKERFILE_SHAPE\}\}/$DOCKERFILE_SHAPE}"
|
||||
pr_body="${pr_body//\{\{IMAGE_SIZE\}\}/$IMAGE_SIZE}"
|
||||
pr_body="${pr_body//\{\{BASE_IMAGE\}\}/$BASE_IMAGE}"
|
||||
pr_body="${pr_body//\{\{IMAGE_TAG\}\}/$IMAGE_TAG}"
|
||||
|
||||
pr_title="$commit_msg_prefix"
|
||||
|
||||
# --- Open PR ---
|
||||
log "opening PR against $UP_OWNER/$UP_NAME:$default_branch"
|
||||
payload=$(jq -n \
|
||||
--arg t "$pr_title" \
|
||||
--arg h "${FORK_OWNER}:${attempt_branch}" \
|
||||
--arg b "$default_branch" \
|
||||
--arg body "$pr_body" \
|
||||
'{title:$t, head:$h, base:$b, body:$body, maintainer_can_modify:true}')
|
||||
resp=$(gh_api_retry POST "/repos/$UP_OWNER/$UP_NAME/pulls" "$payload")
|
||||
http=$(gh_http "$resp"); body=$(gh_body "$resp")
|
||||
if [ "$http" != "201" ]; then
|
||||
die "PR creation failed http=$http body=$body"
|
||||
fi
|
||||
|
||||
pr_url=$(printf '%s' "$body" | jq -r '.html_url')
|
||||
log "PR opened: $pr_url"
|
||||
|
||||
# --- Record PR URL in state file ---
|
||||
jq --arg url "$pr_url" '.contribution_pr_url = $url' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
log "state file updated with PR URL"
|
||||
71
.claude/skills/setup-project/scripts/stability-gate.sh
Executable file
71
.claude/skills/setup-project/scripts/stability-gate.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env bash
|
||||
# 10-minute deploy stability gate for setup-project skill.
|
||||
# Polls pod readiness + HTTP 200 on target URL every 30s for 20 iterations.
|
||||
# Requires 18/20 probes to succeed (tolerates 2 blips for restarts/DNS propagation).
|
||||
#
|
||||
# Usage:
|
||||
# stability-gate.sh <namespace> <app-label> <url>
|
||||
#
|
||||
# Example:
|
||||
# stability-gate.sh myapp myapp https://myapp.viktorbarzin.me
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Stable (>=18/20 probes OK)
|
||||
# 1 - Unstable (<18/20 probes OK)
|
||||
# 2 - Usage error
|
||||
|
||||
set -u
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "Usage: $0 <namespace> <app-label> <url>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NS="$1"
|
||||
APP="$2"
|
||||
URL="$3"
|
||||
|
||||
TOTAL_PROBES=20
|
||||
MIN_SUCCESSES=18
|
||||
INTERVAL_SECONDS=30
|
||||
|
||||
ok_count=0
|
||||
fail_count=0
|
||||
|
||||
echo "stability-gate: ns=$NS app=$APP url=$URL"
|
||||
echo "stability-gate: $TOTAL_PROBES probes x ${INTERVAL_SECONDS}s (need $MIN_SUCCESSES/$TOTAL_PROBES)"
|
||||
|
||||
for i in $(seq 1 "$TOTAL_PROBES"); do
|
||||
probe_ok=true
|
||||
|
||||
if ! kubectl wait --for=condition=Ready pod -l "app=$APP" -n "$NS" --timeout=25s >/dev/null 2>&1; then
|
||||
probe_ok=false
|
||||
fi
|
||||
|
||||
status=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$URL" || echo "000")
|
||||
if [ "$status" != "200" ]; then
|
||||
probe_ok=false
|
||||
fi
|
||||
|
||||
if [ "$probe_ok" = "true" ]; then
|
||||
ok_count=$((ok_count + 1))
|
||||
printf " probe %2d/%d: OK (http=%s)\n" "$i" "$TOTAL_PROBES" "$status"
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
printf " probe %2d/%d: FAIL (http=%s)\n" "$i" "$TOTAL_PROBES" "$status"
|
||||
fi
|
||||
|
||||
if [ "$i" -lt "$TOTAL_PROBES" ]; then
|
||||
sleep "$INTERVAL_SECONDS"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "stability-gate: results ok=$ok_count fail=$fail_count"
|
||||
|
||||
if [ "$ok_count" -ge "$MIN_SUCCESSES" ]; then
|
||||
echo "stability-gate: PASS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "stability-gate: FAIL (need $MIN_SUCCESSES, got $ok_count)" >&2
|
||||
exit 1
|
||||
24
.claude/skills/setup-project/templates/Dockerfile.README.md
Normal file
24
.claude/skills/setup-project/templates/Dockerfile.README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Build notes
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
docker build --platform linux/amd64 -t {{IMAGE_NAME}}:{{TAG}} .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
docker run --rm -p {{CONTAINER_PORT}}:{{CONTAINER_PORT}} {{IMAGE_NAME}}:{{TAG}}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
{{ENV_VARS_TABLE}}
|
||||
|
||||
## Notes
|
||||
|
||||
- Built for `linux/amd64`; multi-arch not tested.
|
||||
- Image size: `{{IMAGE_SIZE}}`, base: `{{BASE_IMAGE}}`.
|
||||
- Runs as a non-root user.
|
||||
{{EXTRA_NOTES}}
|
||||
25
.claude/skills/setup-project/templates/PR_BODY.md
Normal file
25
.claude/skills/setup-project/templates/PR_BODY.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
## Add a working Dockerfile
|
||||
|
||||
### Why
|
||||
{{REASON_PARAGRAPH}}
|
||||
|
||||
### What this adds
|
||||
- `Dockerfile` — {{DOCKERFILE_SHAPE}}
|
||||
- `.dockerignore`
|
||||
- `BUILD.md` with the build command and notes
|
||||
|
||||
### Tested
|
||||
- Built and pushed to a private registry, deployed to a Kubernetes cluster.
|
||||
- Pod has been Ready and serving HTTP 200 at the ingress for 10+ minutes of continuous probing before this PR was opened.
|
||||
- Image size: {{IMAGE_SIZE}}, base: {{BASE_IMAGE}}
|
||||
- Platform tested: `linux/amd64`
|
||||
|
||||
### Build command
|
||||
```
|
||||
docker build --platform linux/amd64 -t {{IMAGE_TAG}} .
|
||||
```
|
||||
|
||||
Happy to iterate on base image, build args, or multi-arch support if you'd prefer a different shape. Thanks for the project!
|
||||
|
||||
---
|
||||
<sub>Contributed after self-hosting this project. Filed by the repo owner's deployment workflow; feel free to mention me (@ViktorBarzin) with any follow-ups.</sub>
|
||||
Loading…
Add table
Add a link
Reference in a new issue