Sync from deal-sage/deal-sage@13a420d134
This commit is contained in:
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercise the pipeline scripts end to end against a stub DealSage, without
|
||||
# Gitea. Builds a throwaway app repo, then runs three scenarios:
|
||||
# 1. auto policy: checks pass (one advisory fails), build, upload, promote → live
|
||||
# 2. a required check fails: nothing is allocated
|
||||
# 3. manual policy: built and kept on the branch channel, not promoted
|
||||
# Usage: templates/ci-actions/test/run-local.sh
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SCRIPTS="$HERE/../pipeline/scripts"
|
||||
PORT="${PORT:-8765}"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'kill ${STUB_PID:-} 2>/dev/null || true; rm -rf "$WORK"' EXIT
|
||||
pass=0; fail=0
|
||||
check() { if eval "$2"; then echo " ok $1"; pass=$((pass+1)); else echo " FAIL $1"; fail=$((fail+1)); fi; }
|
||||
|
||||
make_app() { # dir required_check_cmd
|
||||
local d="$1" reqcmd="$2"
|
||||
mkdir -p "$d/.dealsage" "$d/.gitea/workflows" "$d/src/lib"
|
||||
cat > "$d/package.json" <<JSON
|
||||
{ "name": "fixture-app", "private": true, "scripts": {
|
||||
"build": "mkdir -p dist/assets && echo '<html>ok</html>' > dist/index.html && echo 'console.log(1)' > dist/assets/app.js",
|
||||
"typecheck": "$reqcmd" } }
|
||||
JSON
|
||||
cat > "$d/.dealsage/app.json" <<JSON
|
||||
{ "appId": "11111111-2222-4333-8444-555555555555",
|
||||
"checks": [
|
||||
{ "id": "typecheck", "name": "TypeScript", "run": "npm run --silent typecheck" },
|
||||
{ "id": "lint", "name": "Lint", "run": "echo 'lint problem' && exit 3", "required": false },
|
||||
{ "id": "conventions", "uses": "conventions" } ],
|
||||
"dependencies": { "objectTypes": [ { "apiName": "contract_agreement", "schemaVersion": 2 } ] } }
|
||||
JSON
|
||||
printf 'name: dealsage\non: push\njobs:\n p:\n steps:\n - uses: https://git.example/dealsage-platform/ci-actions/pipeline@v1\n' > "$d/.gitea/workflows/publish.yml"
|
||||
: > "$d/src/lib/dealsage.ts"; : > "$d/src/Root.tsx"; : > "$d/src/main.tsx"
|
||||
printf '# Agent guide\n<!-- dealsage:generated:start -->\n<!-- dealsage:generated:end -->\n' > "$d/AGENTS.md"
|
||||
git -C "$d" init -q -b feature/x && git -C "$d" -c user.name=t -c user.email=t@t add -A \
|
||||
&& git -C "$d" -c user.name=t -c user.email=t@t commit -qm "Add fixture app"
|
||||
}
|
||||
|
||||
run_pipeline() { # dir -> runs manifest, checks, build, publish; echoes the failing stage or "ok"
|
||||
local d="$1" ghenv ghout ghsum
|
||||
ghenv="$(mktemp)"; ghout="$(mktemp)"; ghsum="$(mktemp)"
|
||||
export GITHUB_ENV="$ghenv" GITHUB_OUTPUT="$ghout" GITHUB_STEP_SUMMARY="$ghsum"
|
||||
export GITHUB_SHA="$(git -C "$d" rev-parse HEAD)" GITHUB_REF_NAME="feature/x" GITHUB_ACTOR="tester"
|
||||
export DEALSAGE_FUNCTIONS_URL="http://127.0.0.1:$PORT" PUBLISH_TOKEN="test-publish-token"
|
||||
export SUPABASE_ANON_KEY="anon" SUPABASE_URL="https://example.supabase.co" CUSTOM_APPS_WORKER_URL="http://127.0.0.1:$PORT"
|
||||
export MANIFEST_PATH=".dealsage/app.json"
|
||||
unset APP_ID MANIFEST_JSON CHECKS_JSON CHECKS_RESULT_JSON
|
||||
local stage
|
||||
for stage in manifest run-checks build publish; do
|
||||
( cd "$d" && bash "$SCRIPTS/$stage.sh" ) > "$WORK/$stage.log" 2>&1 || { echo "$stage"; cat "$ghenv" > "$WORK/last.env"; return 0; }
|
||||
# composite steps share state through GITHUB_ENV (heredoc form); load it.
|
||||
eval "$(python3 - "$ghenv" <<'PY'
|
||||
import re,sys,shlex
|
||||
txt=open(sys.argv[1]).read()
|
||||
for m in re.finditer(r'^(\w+)<<__DS_EOF__\n(.*?)\n__DS_EOF__$', txt, re.S|re.M):
|
||||
print(f"export {m.group(1)}={shlex.quote(m.group(2))}")
|
||||
PY
|
||||
)"
|
||||
done
|
||||
cp "$ghout" "$WORK/last.out"; cp "$ghsum" "$WORK/last.summary"
|
||||
echo ok
|
||||
}
|
||||
|
||||
start_stub() { # policy
|
||||
: > "$WORK/requests.jsonl"
|
||||
STUB_LOG="$WORK/requests.jsonl" STUB_POLICY="$1" python3 "$HERE/stub_server.py" "$PORT" & STUB_PID=$!
|
||||
for _ in $(seq 1 30); do curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break; sleep 0.1; done
|
||||
}
|
||||
stop_stub() { kill "$STUB_PID" 2>/dev/null || true; wait "$STUB_PID" 2>/dev/null || true; }
|
||||
req() { jq -c "$1" "$WORK/requests.jsonl"; }
|
||||
|
||||
echo "Scenario 1: auto policy"
|
||||
make_app "$WORK/app1" "true"; start_stub auto
|
||||
r="$(run_pipeline "$WORK/app1")"
|
||||
check "pipeline completed" "[ '$r' = ok ]"
|
||||
check "four checks recorded (typecheck, lint, conventions, build)" "[ \"\$(req 'select(.path|endswith(\"/record-app-check\"))' | wc -l)\" = 4 ]"
|
||||
check "advisory lint failure recorded as failed" "req 'select(.body.checkId==\"lint\") | .body.status' | grep -q '\"failed\"'"
|
||||
check "conventions passed" "req 'select(.body.checkId==\"conventions\") | .body.status' | grep -q '\"passed\"'"
|
||||
check "check payload carries commit, branch and log tail" "req 'select(.body.checkId==\"typecheck\") | .body | (.commitSha|length)==40 and .branch==\"feature/x\" and (.logTail|length)>0' | grep -q true"
|
||||
check "allocate sent branch, message, author, manifest and check results" "req 'select(.body.action==\"allocate\") | .body | .branch==\"feature/x\" and .commitMessage==\"Add fixture app\" and .author==\"t\" and (.manifest.dependencies.objectTypes|length)==1 and (.checks|length)==3' | grep -q true"
|
||||
check "two files uploaded through the Worker with the upload token" "[ \"\$(req 'select(.method==\"PUT\" and .auth==\"Bearer upl-token\")' | wc -l)\" = 2 ]"
|
||||
check "promote requested after upload" "req 'select(.body.action==\"promote\") | .body.version' | grep -q 7"
|
||||
check "outputs: version=7 promoted=true" "grep -q '^version=7$' '$WORK/last.out' && grep -q '^promoted=true$' '$WORK/last.out'"
|
||||
stop_stub
|
||||
|
||||
echo "Scenario 2: required check fails"
|
||||
make_app "$WORK/app2" "echo 'type error' && exit 1"; start_stub auto
|
||||
r="$(run_pipeline "$WORK/app2")"
|
||||
check "stopped at run-checks" "[ '$r' = run-checks ]"
|
||||
check "failure still recorded" "req 'select(.body.checkId==\"typecheck\") | .body.status' | grep -q '\"failed\"'"
|
||||
check "nothing allocated" "! req 'select(.body.action==\"allocate\")' | grep -q ."
|
||||
check "advisory and platform checks still ran" "[ \"\$(req 'select(.path|endswith(\"/record-app-check\"))' | wc -l)\" = 3 ]"
|
||||
stop_stub
|
||||
|
||||
echo "Scenario 3: manual policy"
|
||||
make_app "$WORK/app3" "true"; start_stub manual
|
||||
r="$(run_pipeline "$WORK/app3")"
|
||||
check "pipeline completed" "[ '$r' = ok ]"
|
||||
check "outputs: promoted=false, channel=branch/feature/x" "grep -q '^promoted=false$' '$WORK/last.out' && grep -q '^channel=branch/feature/x$' '$WORK/last.out'"
|
||||
check "summary mentions the channel" "grep -q 'channel' '$WORK/last.summary'"
|
||||
stop_stub
|
||||
|
||||
echo; echo "$pass passed, $fail failed"
|
||||
[ "$fail" = 0 ]
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub of the DealSage endpoints the pipeline talks to, for local tests.
|
||||
|
||||
Records every request as JSON lines in the file named by STUB_LOG, and answers
|
||||
like the real functions would:
|
||||
POST /record-app-version allocate -> version 7 + upload token
|
||||
promote -> promoted per STUB_POLICY (auto|manual)
|
||||
fail -> ok
|
||||
POST /record-app-check -> {"id": "..."}
|
||||
PUT /__upload/<app>/<v>/<path> -> 204
|
||||
"""
|
||||
import json, os, sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
LOG = os.environ.get("STUB_LOG", "/tmp/stub-requests.jsonl")
|
||||
POLICY = os.environ.get("STUB_POLICY", "auto")
|
||||
seq = {"n": 0}
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # quiet
|
||||
pass
|
||||
|
||||
def _record(self, body):
|
||||
with open(LOG, "a") as f:
|
||||
f.write(json.dumps({"method": self.command, "path": self.path,
|
||||
"auth": self.headers.get("Authorization"), "body": body}) + "\n")
|
||||
|
||||
def _json(self, code, obj):
|
||||
data = json.dumps(obj).encode()
|
||||
self.send_response(code); self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(data))); self.end_headers(); self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("content-length") or 0)
|
||||
raw = self.rfile.read(n).decode() if n else ""
|
||||
try: body = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError: body = {"_raw": raw}
|
||||
self._record(body)
|
||||
if self.headers.get("Authorization") != "Bearer test-publish-token":
|
||||
return self._json(401, {"error": "bad token"})
|
||||
if self.path.endswith("/record-app-version"):
|
||||
a = body.get("action")
|
||||
if a == "allocate":
|
||||
return self._json(200, {"version": 7, "r2Prefix": f"apps/{body.get('appId')}/7/", "uploadToken": "upl-token"})
|
||||
if a == "promote":
|
||||
if POLICY == "auto":
|
||||
return self._json(200, {"version": 7, "currentVersion": 7, "promoted": True, "channel": "live"})
|
||||
return self._json(200, {"version": 7, "currentVersion": 6, "promoted": False, "channel": "branch/feature/x"})
|
||||
if a == "fail":
|
||||
return self._json(200, {"ok": True})
|
||||
return self._json(400, {"error": "bad action"})
|
||||
if self.path.endswith("/record-app-check"):
|
||||
seq["n"] += 1
|
||||
return self._json(200, {"id": f"chk-{seq['n']}"})
|
||||
self._json(404, {"error": "no route"})
|
||||
|
||||
def do_PUT(self):
|
||||
n = int(self.headers.get("content-length") or 0)
|
||||
self.rfile.read(n)
|
||||
self._record({"bytes": n})
|
||||
if not self.path.startswith("/__upload/"):
|
||||
return self._json(404, {"error": "no route"})
|
||||
if self.headers.get("Authorization") != "Bearer upl-token":
|
||||
return self._json(401, {"error": "bad upload token"})
|
||||
self.send_response(204); self.end_headers()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||
HTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||
Reference in New Issue
Block a user