Compare commits

..

1 Commits

Author SHA1 Message Date
6f81f572cd ci: fix anno workflows JSON parsing (node stdin argv) + harden guards
All checks were successful
SMOKE / smoke (push) Successful in 40s
CI / build-and-anchors (push) Successful in 2m13s
2026-02-28 10:33:43 +01:00
22 changed files with 208 additions and 728 deletions

View File

@@ -17,12 +17,12 @@ defaults:
shell: bash shell: bash
concurrency: concurrency:
group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
apply-approved: apply-approved:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -37,7 +37,7 @@ jobs:
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch)
env: env:
INPUT_ISSUE: ${{ inputs.issue }} INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" export EVENT_JSON="/var/run/act/workflow/event.json"
@@ -81,12 +81,10 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
// label name: best-effort (non-bloquant) const labelName =
let labelName = "workflow_dispatch"; ev?.label?.name ||
const lab = ev?.label; ev?.label ||
if (typeof lab === "string") labelName = lab; "workflow_dispatch";
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name;
else if (ev?.label?.name) labelName = ev.label.name;
const u = new URL(cloneUrl); const u = new URL(cloneUrl);
const origin = u.origin; const origin = u.origin;
@@ -111,25 +109,19 @@ jobs:
echo "✅ context:" echo "✅ context:"
sed -n '1,120p' /tmp/anno.env sed -n '1,120p' /tmp/anno.env
- name: Early gate (label event fast-skip, but tolerant) - name: Gate on label state/approved
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
echo " event label = $LABEL_NAME" if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
# Fast skip on obvious non-approved label events (avoid noise),
# BUT do NOT skip if label payload is weird/unknown.
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
echo " label=$LABEL_NAME => skip early"
echo "SKIP=1" >> /tmp/anno.env echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
exit 0 exit 0
fi fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
echo "✅ continue to API gating (issue=$ISSUE_NUMBER)" - name: Fetch issue + gate on Type (skip Proposer)
- name: Fetch issue + hard gate on labels + Type
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
@@ -139,22 +131,24 @@ jobs:
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \ ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
-o /tmp/issue.json
node --input-type=module - <<'NODE' >> /tmp/anno.env # ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json >> /tmp/anno.env <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); const fp = process.argv[2] || "";
const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const title = String(issue.title || ""); const title = String(issue.title || "");
const body = String(issue.body || "").replace(/\r\n/g, "\n"); const body = String(issue.body || "").replace(/\r\n/g, "\n");
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) { function pickLine(key) {
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi"); const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = body.match(re); const m = body.match(re);
@@ -171,14 +165,6 @@ jobs:
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`); out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
// HARD gate: must currently have state/approved (avoids depending on event payload)
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`);
process.stdout.write(out.join("\n") + "\n");
process.exit(0);
}
if (!type) { if (!type) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
@@ -195,7 +181,7 @@ jobs:
process.stdout.write(out.join("\n") + "\n"); process.stdout.write(out.join("\n") + "\n");
NODE NODE
echo "✅ gating result:" echo "✅ issue type gating:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
- name: Comment issue if skipped (Proposer / unsupported / missing Type) - name: Comment issue if skipped (Proposer / unsupported / missing Type)
@@ -207,24 +193,18 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${LABEL_NAME:-}" == "state/approved" || "${LABEL_NAME:-}" == "workflow_dispatch" ]] || exit 0
# IMPORTANT: do NOT comment for "not_approved_label_present" (avoid spam on other label events) test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
echo " skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || exit 0
REASON="${SKIP_REASON:-}" REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}" TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == proposer_type:* ]]; then if [[ "$REASON" == proposer_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors**.\n✅ Aucun traitement automatique." MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer et n'ajoute **jamais** state/approved automatiquement.\n\n✅ Action : traitement éditorial manuel."
elif [[ "$REASON" == unsupported_type:* ]]; then elif [[ "$REASON" == unsupported_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment." MSG=" Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment.\n✅ Action : traitement manuel si nécessaire."
else else
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: type/media|type/reference|type/comment" MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (ajouter 'Type: type/media|type/reference|type/comment') ou traiter manuellement."
fi fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
@@ -272,7 +252,7 @@ jobs:
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm run build npm run build:clean
test -f dist/para-index.json || { test -f dist/para-index.json || {
echo "❌ missing dist/para-index.json after build" echo "❌ missing dist/para-index.json after build"
@@ -291,7 +271,6 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -d .git || { echo "❌ not a git repo (checkout failed)"; echo "APPLY_RC=90" >> /tmp/anno.env; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
@@ -348,7 +327,7 @@ jobs:
exit 0 exit 0
fi fi
test -n "${FORGE_TOKEN:-}" || exit 0 test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
if [[ -f /tmp/apply.log ]]; then if [[ -f /tmp/apply.log ]]; then
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
@@ -365,6 +344,29 @@ jobs:
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD" --data-binary "$PAYLOAD"
- name: Comment issue if no-op (already applied)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "1" ]] || exit 0
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
MSG=" Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
- name: Push bot branch - name: Push bot branch
if: ${{ always() }} if: ${{ always() }}
env: env:
@@ -376,7 +378,10 @@ jobs:
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; } [[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
test -d .git || { echo " no git repo -> skip push"; exit 0; } test -d .git || { echo " no git repo -> skip push"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip push"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e ' AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1); const [clone, tok] = process.argv.slice(1);
@@ -401,6 +406,10 @@ jobs:
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; } [[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip PR"; exit 0; }
test -n "${END_SHA:-}" || { echo " missing END_SHA -> skip PR"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}" PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK." PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."

View File

@@ -17,12 +17,12 @@ defaults:
shell: bash shell: bash
concurrency: concurrency:
group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
reject: reject:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -35,7 +35,7 @@ jobs:
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch)
env: env:
INPUT_ISSUE: ${{ inputs.issue }} INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" export EVENT_JSON="/var/run/act/workflow/event.json"
@@ -75,11 +75,10 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
// label name: best-effort (non-bloquant) const labelName =
let labelName = "workflow_dispatch"; ev?.label?.name ||
const lab = ev?.label; ev?.label ||
if (typeof lab === "string") labelName = lab; "workflow_dispatch";
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name;
let apiBase = ""; let apiBase = "";
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) { if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
@@ -104,20 +103,19 @@ jobs:
echo "✅ context:" echo "✅ context:"
sed -n '1,120p' /tmp/reject.env sed -n '1,120p' /tmp/reject.env
- name: Early gate (fast-skip, tolerant) - name: Gate on label state/rejected only
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/reject.env source /tmp/reject.env
echo " event label = $LABEL_NAME"
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip early" echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/reject.env echo "SKIP=1" >> /tmp/reject.env
echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env
exit 0 exit 0
fi fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
- name: Comment + close (only if label state/rejected is PRESENT now, and no conflict) - name: Comment + close (only if not conflicting with state/approved)
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
@@ -128,29 +126,33 @@ jobs:
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; } test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
curl -fsS \ ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
-o /tmp/reject.issue.json
node --input-type=module - <<'NODE' > /tmp/reject.flags # ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json > /tmp/reject.flags <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/reject.issue.json","utf8"));
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : []; const fp = process.argv[2] || "";
const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved"); const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected"); const hasRejected = labels.includes("state/rejected");
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`); process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
NODE NODE
source /tmp/reject.flags source /tmp/reject.flags
# Do nothing unless state/rejected is truly present now (anti payload weird)
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
echo " state/rejected not present -> skip"
exit 0
fi
if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then
MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance." MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"

View File

@@ -4,37 +4,22 @@ on:
issues: issues:
types: [opened, edited] types: [opened, edited]
concurrency:
group: auto-label-${{ github.event.issue.number || github.event.issue.index || 'manual' }}
cancel-in-progress: true
jobs: jobs:
label: label:
runs-on: mac-ci runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps: steps:
- name: Apply labels from Type/State/Category - name: Apply labels from Type/State/Category
env: env:
# IMPORTANT: préfère FORGE_BASE (LAN) si défini, sinon FORGE_API FORGE_BASE: ${{ vars.FORGE_API || vars.FORGE_BASE }}
FORGE_BASE: ${{ vars.FORGE_BASE || vars.FORGE_API || vars.FORGE_API_BASE }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
REPO_FULL: ${{ gitea.repository }} REPO_FULL: ${{ gitea.repository }}
EVENT_PATH: ${{ github.event_path }} EVENT_PATH: ${{ github.event_path }}
NODE_OPTIONS: --dns-result-order=ipv4first
run: | run: |
python3 - <<'PY' python3 - <<'PY'
import json, os, re, time, urllib.request, urllib.error, socket import json, os, re, urllib.request, urllib.error
forge = (os.environ.get("FORGE_BASE") or "").rstrip("/")
if not forge:
raise SystemExit("Missing FORGE_BASE/FORGE_API repo variable (e.g. http://192.168.1.20:3000)")
token = os.environ.get("FORGE_TOKEN") or ""
if not token:
raise SystemExit("Missing secret FORGE_TOKEN")
forge = os.environ["FORGE_BASE"].rstrip("/")
token = os.environ["FORGE_TOKEN"]
owner, repo = os.environ["REPO_FULL"].split("/", 1) owner, repo = os.environ["REPO_FULL"].split("/", 1)
event_path = os.environ["EVENT_PATH"] event_path = os.environ["EVENT_PATH"]
@@ -61,9 +46,12 @@ jobs:
print("PARSED:", {"Type": t, "State": s, "Category": c}) print("PARSED:", {"Type": t, "State": s, "Category": c})
# 1) explicite depuis le body # 1) explicite depuis le body
if t: desired.add(t) if t:
if s: desired.add(s) desired.add(t)
if c: desired.add(c) if s:
desired.add(s)
if c:
desired.add(c)
# 2) fallback depuis le titre si Type absent # 2) fallback depuis le titre si Type absent
if not t: if not t:
@@ -88,56 +76,42 @@ jobs:
"Authorization": f"token {token}", "Authorization": f"token {token}",
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "archicratie-auto-label/1.1", "User-Agent": "archicratie-auto-label/1.0",
} }
def jreq(method, url, payload=None, timeout=60, retries=4, backoff=2.0): def jreq(method, url, payload=None):
data = None if payload is None else json.dumps(payload).encode("utf-8") data = None if payload is None else json.dumps(payload).encode("utf-8")
last_err = None req = urllib.request.Request(url, data=data, headers=headers, method=method)
for i in range(retries): try:
req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=20) as r:
try: b = r.read()
with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(b.decode("utf-8")) if b else None
b = r.read() except urllib.error.HTTPError as e:
return json.loads(b.decode("utf-8")) if b else None b = e.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e: raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
b = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
except (TimeoutError, socket.timeout, urllib.error.URLError) as e:
last_err = e
# retry only on network/timeout
time.sleep(backoff * (i + 1))
raise RuntimeError(f"Network/timeout after retries: {method} {url}\n{last_err}")
# labels repo # labels repo
labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000", timeout=60) or [] labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000") or []
name_to_id = {x.get("name"): x.get("id") for x in labels} name_to_id = {x.get("name"): x.get("id") for x in labels}
missing = [x for x in desired if x not in name_to_id] missing = [x for x in desired if x not in name_to_id]
if missing: if missing:
raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing))) raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing)))
wanted_ids = sorted({int(name_to_id[x]) for x in desired}) wanted_ids = [name_to_id[x] for x in desired]
# labels actuels de l'issue # labels actuels de l'issue
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or [] current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels") or []
current_ids = {int(x.get("id")) for x in current if x.get("id") is not None} current_ids = {x.get("id") for x in current if x.get("id") is not None}
final_ids = sorted(current_ids.union(wanted_ids)) final_ids = sorted(current_ids.union(wanted_ids))
# Replace labels = union (n'enlève rien) # set labels = union (n'enlève rien)
url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels" url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels"
try:
# IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422) jreq("PUT", url, {"labels": final_ids})
jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4) except Exception:
jreq("PUT", url, final_ids)
# vérif post-apply (anti "timeout mais appliqué")
post = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
post_ids = {int(x.get("id")) for x in post if x.get("id") is not None}
missing_ids = [i for i in wanted_ids if i not in post_ids]
if missing_ids:
raise RuntimeError(f"Labels not applied after PUT (missing ids): {missing_ids}")
print(f"OK labels #{number}: {sorted(desired)}") print(f"OK labels #{number}: {sorted(desired)}")
PY PY

View File

@@ -3,7 +3,7 @@ name: CI
on: on:
push: push:
pull_request: pull_request:
branches: [main] branches: [master]
workflow_dispatch: workflow_dispatch:
env: env:
@@ -15,7 +15,7 @@ defaults:
jobs: jobs:
build-and-anchors: build-and-anchors:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm

View File

@@ -26,9 +26,9 @@ concurrency:
jobs: jobs:
deploy: deploy:
runs-on: nas-deploy runs-on: ubuntu-latest
container: container:
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6 image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps: steps:
- name: Tools sanity - name: Tools sanity
@@ -47,163 +47,105 @@ jobs:
node --input-type=module <<'NODE' node --input-type=module <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8")); const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {}; const repoObj = ev?.repository || {};
const cloneUrl = const cloneUrl =
repoObj?.clone_url || repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json"); if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
const defaultBranch = repoObj?.default_branch || "main"; const defaultBranch = repoObj?.default_branch || "main";
const sha =
// Push-range (most reliable for change detection)
const before = String(ev?.before || "").trim();
const after =
(process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) || (process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) ||
String(ev?.after || ev?.sha || ev?.head_commit?.id || ev?.pull_request?.head?.sha || "").trim(); ev?.after ||
ev?.sha ||
ev?.head_commit?.id ||
ev?.pull_request?.head?.sha ||
"";
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
fs.writeFileSync("/tmp/deploy.env", [ fs.writeFileSync("/tmp/deploy.env", [
`REPO_URL=${shq(cloneUrl)}`, `REPO_URL=${shq(cloneUrl)}`,
`DEFAULT_BRANCH=${shq(defaultBranch)}`, `DEFAULT_BRANCH=${shq(defaultBranch)}`,
`BEFORE=${shq(before)}`, `SHA=${shq(sha)}`
`AFTER=${shq(after)}`
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
source /tmp/deploy.env source /tmp/deploy.env
echo "Repo URL: $REPO_URL" echo "Repo URL: $REPO_URL"
echo "Default branch: $DEFAULT_BRANCH" echo "Default branch: $DEFAULT_BRANCH"
echo "BEFORE: ${BEFORE:-<empty>}" echo "SHA: ${SHA:-<empty>}"
echo "AFTER: ${AFTER:-<empty>}"
rm -rf .git rm -rf .git
git init -q git init -q
git remote add origin "$REPO_URL" git remote add origin "$REPO_URL"
# Checkout AFTER (or default branch if missing) if [[ -n "${SHA:-}" ]]; then
if [[ -n "${AFTER:-}" ]]; then git fetch --depth 1 origin "$SHA"
git fetch --depth 50 origin "$AFTER"
git -c advice.detachedHead=false checkout -q FETCH_HEAD git -c advice.detachedHead=false checkout -q FETCH_HEAD
else else
git fetch --depth 50 origin "$DEFAULT_BRANCH" git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH" git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
AFTER="$(git rev-parse HEAD)" SHA="$(git rev-parse HEAD)"
echo "AFTER='$AFTER'" >> /tmp/deploy.env echo "SHA='$SHA'" >> /tmp/deploy.env
echo "Resolved AFTER: $AFTER" echo "Resolved SHA: $SHA"
fi fi
git log -1 --oneline git log -1 --oneline
- name: Gate — decide SKIP vs HOTPATCH vs FULL rebuild - name: Gate — decide HOTPATCH vs FULL rebuild
env: env:
INPUT_FORCE: ${{ inputs.force }} INPUT_FORCE: ${{ inputs.force }}
EVENT_JSON: /var/run/act/workflow/event.json
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/deploy.env source /tmp/deploy.env
FORCE="${INPUT_FORCE:-0}" FORCE="${INPUT_FORCE:-0}"
# Lire before/after du push depuis event.json (merge-proof) # liste fichiers touchés (utile pour copier les médias)
node --input-type=module <<'NODE' CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
import fs from "node:fs"; printf "%s\n" "$CHANGED" > /tmp/changed.txt
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const before = ev?.before || "";
const after = ev?.after || ev?.sha || "";
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
fs.writeFileSync("/tmp/gate.env", [
`EV_BEFORE=${shq(before)}`,
`EV_AFTER=${shq(after)}`
].join("\n") + "\n");
NODE
source /tmp/gate.env echo "== changed files =="
echo "$CHANGED" | sed -n '1,260p'
BEFORE="${EV_BEFORE:-}" if [[ "$FORCE" == "1" ]]; then
AFTER="${EV_AFTER:-}" echo "GO=1" >> /tmp/deploy.env
if [[ -z "${AFTER:-}" ]]; then echo "MODE='full'" >> /tmp/deploy.env
AFTER="${SHA:-}"
fi
echo "Gate ctx: BEFORE=${BEFORE:-<empty>} AFTER=${AFTER:-<empty>} FORCE=${FORCE}"
# Produire une liste CHANGED fiable :
# - si BEFORE/AFTER valides -> git diff before..after
# - sinon fallback -> diff parent1..after ou show after
CHANGED=""
Z40="0000000000000000000000000000000000000000"
if [[ -n "${BEFORE:-}" && "${BEFORE}" != "${Z40}" ]] \
&& git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
&& git cat-file -e "${AFTER}^{commit}" 2>/dev/null; then
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
else
P1="$(git rev-parse "${AFTER}^" 2>/dev/null || true)"
if [[ -n "${P1:-}" ]] && git cat-file -e "${P1}^{commit}" 2>/dev/null; then
CHANGED="$(git diff --name-only "${P1}" "${AFTER}" || true)"
else
CHANGED="$(git show --name-only --pretty="" "${AFTER}" | sed '/^$/d' || true)"
fi
fi
printf "%s\n" "${CHANGED}" > /tmp/changed.txt
echo "== changed files (first 200) =="
sed -n '1,200p' /tmp/changed.txt || true
# Flags
HAS_FULL=0
HAS_HOTPATCH=0
# FULL si build-impacting (ce que tu veux : content/anchors/pages/scripts)
if grep -qE '^(src/content/|src/anchors/|src/pages/|scripts/)' /tmp/changed.txt; then
HAS_FULL=1
fi
# HOTPATCH si annotations/media touchés
if grep -qE '^(src/annotations/|public/media/)' /tmp/changed.txt; then
HAS_HOTPATCH=1
fi
echo "Gate flags: HAS_FULL=${HAS_FULL} HAS_HOTPATCH=${HAS_HOTPATCH}"
# Décision
if [[ "${FORCE}" == "1" ]]; then
GO=1
MODE="full"
echo "✅ force=1 -> MODE=full (rebuild+restart)" echo "✅ force=1 -> MODE=full (rebuild+restart)"
elif [[ "${HAS_FULL}" == "1" ]]; then exit 0
GO=1 fi
MODE="full"
echo "✅ build-impacting change -> MODE=full (rebuild+restart)" # Auto mode: uniquement annotations/media => hotpatch only
elif [[ "${HAS_HOTPATCH}" == "1" ]]; then if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
GO=1 echo "GO=1" >> /tmp/deploy.env
MODE="hotpatch" echo "MODE='hotpatch'" >> /tmp/deploy.env
echo "✅ annotations/media change -> MODE=hotpatch" echo "✅ annotations/media change -> MODE=hotpatch"
else else
GO=0 echo "GO=0" >> /tmp/deploy.env
MODE="skip" echo "MODE='skip'" >> /tmp/deploy.env
echo " no relevant change -> skip deploy" echo " no annotations/media change -> skip deploy"
fi fi
echo "GO=${GO}" >> /tmp/deploy.env - name: Install docker client + docker compose plugin (v2) + python yaml
echo "MODE='${MODE}'" >> /tmp/deploy.env
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/deploy.env source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; } [[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
# tools are prebaked in the image apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
git --version apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml
rm -rf /var/lib/apt/lists/*
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker version docker version
docker compose version docker compose version
python3 -c 'import yaml; print("PyYAML OK")' python3 --version
# Reuse existing compose project name if containers already exist # Reuse existing compose project name if containers already exist
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)" PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"

View File

@@ -1,395 +0,0 @@
name: Proposer Apply (PR)
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to apply (Proposer: correction/fact-check)"
required: true
env:
NODE_OPTIONS: --dns-result-order=ipv4first
defaults:
run:
shell: bash
concurrency:
group: proposer-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true
jobs:
apply-proposer:
runs-on: mac-ci
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Tools sanity
run: |
set -euo pipefail
git --version
node --version
npm --version
- name: Derive context (event.json / workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/proposer.env
import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {};
const cloneUrl =
repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
let owner =
repoObj?.owner?.login ||
repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo =
repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const defaultBranch = repoObj?.default_branch || "main";
const issueNumber =
ev?.issue?.number ||
ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
throw new Error("No issue number in event.json or workflow_dispatch input");
}
const labelName =
ev?.label?.name ||
ev?.label ||
"workflow_dispatch";
const u = new URL(cloneUrl);
const origin = u.origin;
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim())
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
`OWNER=${sh(owner)}`,
`REPO=${sh(repo)}`,
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(apiBase)}`
].join("\n") + "\n");
NODE
echo "✅ context:"
sed -n '1,120p' /tmp/proposer.env
- name: Gate on label state/approved
run: |
set -euo pipefail
source /tmp/proposer.env
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/proposer.env
exit 0
fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
- name: Fetch issue + API-hard gate on (state/approved present + proposer type)
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
-o /tmp/issue.json
node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const title = String(issue.title || "");
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name||"")).filter(Boolean) : [];
function pickLine(key) {
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = body.match(re);
return m ? m[1].trim() : "";
}
const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase();
const hasApproved = labels.includes("state/approved");
const proposer = new Set(["type/correction","type/fact-check"]);
const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
out.push(`HAS_APPROVED=${hasApproved ? "1":"0"}`);
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("approved_not_present")}`);
} else if (!type) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (!proposer.has(type)) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("not_proposer:"+type)}`);
}
process.stdout.write(out.join("\n") + "\n");
NODE
echo "✅ proposer gating:"
grep -E '^(ISSUE_TYPE|HAS_APPROVED|SKIP|SKIP_REASON)=' /tmp/proposer.env || true
- name: Comment issue if skipped
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0
REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == "approved_not_present" ]]; then
MSG=" Proposer Apply: skip — le label **state/approved** n'est pas présent sur le ticket au moment du run (gate API-hard)."
elif [[ "$REASON" == "missing_type" ]]; then
MSG=" Proposer Apply: skip — champ **Type:** manquant/illisible. Attendu: type/correction ou type/fact-check."
else
MSG=" Proposer Apply: skip — Type non-Proposer (${TYPE}). (Ce workflow ne traite que correction/fact-check.)"
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD" || true
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
rm -rf .git
git init -q
git remote add origin "$CLONE_URL"
git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
git log -1 --oneline
echo "✅ workspace:"
ls -la | sed -n '1,120p'
- name: Detect app dir (repo-root vs ./site)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
APP_DIR="."
if [[ -d "site" && -f "site/package.json" ]]; then
APP_DIR="site"
fi
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
echo "✅ APP_DIR=$APP_DIR"
ls -la "$APP_DIR" | sed -n '1,120p'
test -f "$APP_DIR/package.json" || { echo "❌ package.json missing in APP_DIR=$APP_DIR"; exit 1; }
test -d "$APP_DIR/scripts" || { echo "❌ scripts/ missing in APP_DIR=$APP_DIR"; exit 1; }
- name: NPM harden (reduce flakiness)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set registry https://registry.npmjs.org
- name: Install deps (APP_DIR)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
cd "$APP_DIR"
npm ci --no-audit --no-fund
- name: Build dist baseline (APP_DIR)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
cd "$APP_DIR"
npm run build
- name: Apply ticket (alias + commit) on bot branch
continue-on-error: true
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }}
BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
START_SHA="$(git rev-parse HEAD)"
TS="$(date -u +%Y%m%d-%H%M%S)"
BR="bot/proposer-${ISSUE_NUMBER}-${TS}"
echo "BRANCH=$BR" >> /tmp/proposer.env
git checkout -b "$BR"
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_BASE="$API_BASE"
LOG="/tmp/proposer-apply.log"
set +e
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE_NUMBER" --alias --commit) >"$LOG" 2>&1
RC=$?
set -e
echo "APPLY_RC=$RC" >> /tmp/proposer.env
echo "== apply log (tail) =="
tail -n 200 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/proposer.env
exit 0
fi
if [[ "$START_SHA" == "$END_SHA" ]]; then
echo "NOOP=1" >> /tmp/proposer.env
else
echo "NOOP=0" >> /tmp/proposer.env
echo "END_SHA=$END_SHA" >> /tmp/proposer.env
fi
- name: Push bot branch
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
const u = new URL(clone);
u.username = "oauth2";
u.password = tok;
console.log(u.toString());
' "$CLONE_URL" "$FORGE_TOKEN")"
git remote set-url origin "$AUTH_URL"
git push -u origin "$BRANCH"
- name: Create PR + comment issue
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip PR"; exit 0; }
PR_TITLE="proposer: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA:-unknown}\n\nMerge si CI OK."
PR_PAYLOAD="$(node --input-type=module -e '
const [title, body, base, head] = process.argv.slice(1);
console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true }));
' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")"
PR_JSON="$(curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \
--data-binary "$PR_PAYLOAD")"
PR_URL="$(node --input-type=module -e '
const pr = JSON.parse(process.argv[1] || "{}");
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; }
MSG="✅ PR Proposer créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD"
- name: Finalize (fail job if apply failed)
if: ${{ always() }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then
echo "❌ apply failed (rc=$RC)"
exit "$RC"
fi
echo "✅ apply ok"

View File

@@ -3,7 +3,7 @@ on: [push, workflow_dispatch]
jobs: jobs:
smoke: smoke:
runs-on: mac-ci runs-on: ubuntu-latest
steps: steps:
- run: node -v && npm -v - run: node -v && npm -v
- run: echo "runner OK" - run: echo "runner OK"

View File

@@ -114,6 +114,7 @@ async function runMammoth(docxPath, assetsOutDirWebRoot) {
); );
let html = result.value || ""; let html = result.value || "";
// Mammoth gives relative src="image-xx.png" ; we will prefix later // Mammoth gives relative src="image-xx.png" ; we will prefix later
return html; return html;
} }
@@ -181,25 +182,6 @@ async function exists(p) {
try { await fs.access(p); return true; } catch { return false; } try { await fs.access(p); return true; } catch { return false; }
} }
/**
* ✅ compat:
* - ancien : collection="archicratie" + slug="archicrat-ia/chapitre-3"
* - nouveau : collection="archicrat-ia" + slug="chapitre-3"
*
* But : toujours écrire dans src/content/archicrat-ia/<slugSansPrefix>.mdx
*/
function normalizeDest(collection, slug) {
let outCollection = String(collection || "").trim();
let outSlug = String(slug || "").trim().replace(/^\/+|\/+$/g, "");
if (outCollection === "archicratie" && outSlug.startsWith("archicrat-ia/")) {
outCollection = "archicrat-ia";
outSlug = outSlug.replace(/^archicrat-ia\//, "");
}
return { outCollection, outSlug };
}
async function main() { async function main() {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const manifestPath = path.resolve(args.manifest); const manifestPath = path.resolve(args.manifest);
@@ -221,14 +203,11 @@ async function main() {
for (const it of selected) { for (const it of selected) {
const docxPath = path.resolve(it.source); const docxPath = path.resolve(it.source);
const outFile = path.resolve("src/content", it.collection, `${it.slug}.mdx`);
const { outCollection, outSlug } = normalizeDest(it.collection, it.slug);
const outFile = path.resolve("src/content", outCollection, `${outSlug}.mdx`);
const outDir = path.dirname(outFile); const outDir = path.dirname(outFile);
const assetsPublicDir = path.posix.join("/imported", outCollection, outSlug); const assetsPublicDir = path.posix.join("/imported", it.collection, it.slug);
const assetsDiskDir = path.resolve("public", "imported", outCollection, outSlug); const assetsDiskDir = path.resolve("public", "imported", it.collection, it.slug);
if (!(await exists(docxPath))) { if (!(await exists(docxPath))) {
throw new Error(`Missing source docx: ${docxPath}`); throw new Error(`Missing source docx: ${docxPath}`);
@@ -262,20 +241,18 @@ async function main() {
html = rewriteLocalImageLinks(html, assetsPublicDir); html = rewriteLocalImageLinks(html, assetsPublicDir);
body = html.trim() ? html : "<p>(Import vide)</p>"; body = html.trim() ? html : "<p>(Import vide)</p>";
} }
const defaultVersion = process.env.PUBLIC_RELEASE || "0.1.0"; const defaultVersion = process.env.PUBLIC_RELEASE || "0.1.0";
// ✅ IMPORTANT: archicrat-ia partage edition/status avec archicratie (pas de migration frontmatter)
const schemaDefaultsByCollection = { const schemaDefaultsByCollection = {
archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 }, archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 },
"archicrat-ia": { edition: "archicrat-ia", status: "essai_these", level: 1 }, ia: { edition: "ia", status: "cas_pratique", level: 1 },
ia: { edition: "ia", status: "cas_pratique", level: 1 }, traite: { edition: "traite", status: "ontodynamique", level: 1 },
traite: { edition: "traite", status: "ontodynamique", level: 1 }, glossaire: { edition: "glossaire", status: "lexique", level: 1 },
glossaire: { edition: "glossaire", status: "lexique", level: 1 }, atlas: { edition: "atlas", status: "atlas", level: 1 },
atlas: { edition: "atlas", status: "atlas", level: 1 },
}; };
const defaults = schemaDefaultsByCollection[outCollection] || { edition: outCollection, status: "draft", level: 1 }; const defaults = schemaDefaultsByCollection[it.collection] || { edition: it.collection, status: "draft", level: 1 };
const fm = [ const fm = [
"---", "---",
@@ -305,4 +282,4 @@ async function main() {
main().catch((e) => { main().catch((e) => {
console.error("\nERROR:", e?.message || e); console.error("\nERROR:", e?.message || e);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,5 +1,2 @@
{ {}
"/archicrat-ia/chapitre-3/": {
"p-1-60c7ea48": "p-1-a21087b0"
}
}

View File

@@ -10,9 +10,3 @@ paras:
credit: "" credit: ""
ts: 2026-02-27T12:43:14.259Z ts: 2026-02-27T12:43:14.259Z
fromIssue: 144 fromIssue: 144
refs:
- url: https://gitea.archicratie.trans-hands.synology.me
label: Gitea
kind: (livre / article / vidéo / site / autre) Site
ts: 2026-03-02T19:53:21.252Z
fromIssue: 169

View File

@@ -1,11 +0,0 @@
schema: 1
page: archicrat-ia/chapitre-3
paras:
p-1-60c7ea48:
refs:
- url: https://gitea.archicratie.trans-hands.synology.me
label: Gitea
kind: (livre / article / vidéo / site / autre) Site
ts: 2026-03-02T20:01:55.858Z
fromIssue: 172
# testB: hotpatch-auto gate proof

View File

@@ -3,11 +3,14 @@ import { getCollection } from "astro:content";
const { currentSlug } = Astro.props; const { currentSlug } = Astro.props;
// ✅ Après migration : TOC = collection "archicrat-ia" const entries = (await getCollection("archicratie"))
const entries = (await getCollection("archicrat-ia")) .filter((e) => e.slug.startsWith("archicrat-ia/"))
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0)); .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
const href = (slug) => `/archicrat-ia/${slug}/`; // ✅ On route lEssai-thèse sur /archicrat-ia/<slug-sans-prefix>/
// (Astro trailingSlash = always → on garde le "/" final)
const strip = (s) => String(s || "").replace(/^archicrat-ia\//, "");
const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
--- ---
<nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA"> <nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
@@ -160,4 +163,4 @@ const href = (slug) => `/archicrat-ia/${slug}/`;
const active = document.querySelector(".toc-global .toc-item.is-active"); const active = document.querySelector(".toc-global .toc-item.is-active");
if (active) active.scrollIntoView({ block: "nearest" }); if (active) active.scrollIntoView({ block: "nearest" });
})(); })();
</script> </script>

View File

@@ -11,11 +11,10 @@ summary: ""
source: source:
kind: docx kind: docx
path: "sources/docx/archicrat-ia/Chapitre_3—Philosophies_du_pouvoir_et_Archicration-pour_une_topologie_differenciee_des_regimes_regulateurs-version_officielle.docx" path: "sources/docx/archicrat-ia/Chapitre_3—Philosophies_du_pouvoir_et_Archicration-pour_une_topologie_differenciee_des_regimes_regulateurs-version_officielle.docx"
<!-- testA: full-auto gate proof -->
--- ---
Ce chapitre se tient à un point nodal de notre essai-thèse : il ouvre un espace dexploration systématique des formes conceptuelles et philosophiques à travers lesquelles le pouvoir se configure comme régime de régulation. Il ne sagit pas ici de revenir une nouvelle fois sur les fondements de lautorité, ni dinterroger la légitimité politique au sens classique du terme, ni même denquêter sur la genèse des institutions. Lambition est autre, structurelle, transversale, morphologique, elle tentera darpenter, à même les dispositifs, les pensées, les théorisations et les expériences, les modalités différentiées par lesquelles sinstaurent, séprouvent et se disputent les formes de régulation du vivre-ensemble. Ce chapitre se tient à un point nodal de notre essai-thèse : il ouvre un espace dexploration systématique des formes conceptuelles et philosophiques à travers lesquelles le pouvoir se configure comme régime de régulation. Il ne sagit pas ici de revenir une nouvelle fois sur les fondements de lautorité, ni dinterroger la légitimité politique au sens classique du terme, ni même denquêter sur la genèse des institutions. Lambition est autre, structurelle, transversale, morphologique, elle tentera darpenter, à même les dispositifs, les pensées, les théorisations et les expériences, les modalités différentiées par lesquelles sinstaurent, séprouvent et se disputent les formes de régulation du vivre-ensemble.
Dès lors, ce chapitre ne postule aucun fondement, ne cherche aucun point dorigine, ne prétend restituer aucune ontologie stable du politique. Ce quil donne à lire, cest une cartographie dynamique des régimes de régulation, traversée par des formes irréductibles, non homogènes, souvent conflictuelles, parfois incompatibles, mais toutes pensées comme des configurations singulières, et souvent complémentaires. Dès lors, ce chapitre ne postule aucun fondement, ne cherche aucun point dorigine, ne prétend restituer aucune ontologie stable du politique. Ce quil donne à lire, cest une cartographie dynamique des régimes de régulation, traversée par des formes irréductibles, non homogènes, souvent conflictuelles, parfois incompatibles, mais toutes pensées comme des configurations singulières.
Ainsi, loin dêtre une galerie illustrative de théories politiques juxtaposées, le chapitre sagence comme une topologie critique, une plongée stratigraphique dans les scènes où sarticule la régulation — entendue ici non comme stabilisation externe ou ajustement technico-fonctionnel, mais comme dispositif instituant, tension structurante, scène traversée de conflictualité et dexigence normative. Car à nos yeux, la régulation nest pas ce qui vient après le pouvoir, elle en est la forme même constitutive — son architecture, son rythme, son épaisseur. Elle est ce par quoi le pouvoir ne se contente pas dêtre exercé, mais sinstitue, se justifie, se dispute, se recompose. Ainsi, loin dêtre une galerie illustrative de théories politiques juxtaposées, le chapitre sagence comme une topologie critique, une plongée stratigraphique dans les scènes où sarticule la régulation — entendue ici non comme stabilisation externe ou ajustement technico-fonctionnel, mais comme dispositif instituant, tension structurante, scène traversée de conflictualité et dexigence normative. Car à nos yeux, la régulation nest pas ce qui vient après le pouvoir, elle en est la forme même constitutive — son architecture, son rythme, son épaisseur. Elle est ce par quoi le pouvoir ne se contente pas dêtre exercé, mais sinstitue, se justifie, se dispute, se recompose.

View File

@@ -2,7 +2,7 @@ import { defineCollection, z } from "astro:content";
const linkSchema = z.object({ const linkSchema = z.object({
type: z.enum(["definition", "appui", "transposition"]), type: z.enum(["definition", "appui", "transposition"]),
target: z.string().min(1), target: z.string().min(1), // URL interne (ex: /glossaire/archicratie/) ou slug
note: z.string().optional() note: z.string().optional()
}); });
@@ -12,6 +12,7 @@ const baseTextSchema = z.object({
version: z.string().min(1), version: z.string().min(1),
concepts: z.array(z.string().min(1)).default([]), concepts: z.array(z.string().min(1)).default([]),
links: z.array(linkSchema).default([]), links: z.array(linkSchema).default([]),
// optionnels mais utiles dès maintenant
order: z.number().int().nonnegative().optional(), order: z.number().int().nonnegative().optional(),
summary: z.string().optional() summary: z.string().optional()
}); });
@@ -49,31 +50,20 @@ const atlas = defineCollection({
}) })
}); });
// ✅ NOUVELLE collection : archicrat-ia (Essai-thèse)
// NOTE : on accepte temporairement edition/status "archicratie/modele_sociopolitique"
// si tes MDX nont pas encore été normalisés.
// Quand tu voudras "strict", on passera à edition="archicrat-ia" status="essai_these"
// + update frontmatter des 7 fichiers.
const archicratIa = defineCollection({
type: "content",
schema: baseTextSchema.extend({
edition: z.union([z.literal("archicrat-ia"), z.literal("archicratie")]),
status: z.union([z.literal("essai_these"), z.literal("modele_sociopolitique")])
})
});
// Glossaire (référentiel terminologique) // Glossaire (référentiel terminologique)
const glossaire = defineCollection({ const glossaire = defineCollection({
type: "content", type: "content",
schema: z.object({ schema: z.object({
title: z.string().min(1), title: z.string().min(1), // Titre public (souvent identique au terme)
term: z.string().min(1), term: z.string().min(1), // Terme canonique
aliases: z.array(z.string().min(1)).default([]), aliases: z.array(z.string().min(1)).default([]),
edition: z.literal("glossaire"), edition: z.literal("glossaire"),
status: z.literal("referentiel"), status: z.literal("referentiel"),
version: z.string().min(1), version: z.string().min(1),
// Micro-définition affichable en popover (courte, stable)
definitionShort: z.string().min(1), definitionShort: z.string().min(1),
concepts: z.array(z.string().min(1)).default([]), concepts: z.array(z.string().min(1)).default([]),
// Liens typés (vers ouvrages ou autres termes)
links: z.array(linkSchema).default([]) links: z.array(linkSchema).default([])
}) })
}); });
@@ -83,8 +73,5 @@ export const collections = {
archicratie, archicratie,
ia, ia,
glossaire, glossaire,
atlas, atlas
};
// ⚠️ clé avec tiret => doit être quotée
"archicrat-ia": archicratIa
};

View File

@@ -5,11 +5,12 @@ import EditionToc from "../../components/EditionToc.astro";
import LocalToc from "../../components/LocalToc.astro"; import LocalToc from "../../components/LocalToc.astro";
export async function getStaticPaths() { export async function getStaticPaths() {
// ✅ Après migration : plus de filtre par prefix, on prend toute la collection const entries = (await getCollection("archicratie"))
const entries = await getCollection("archicrat-ia"); .filter((e) => e.slug.startsWith("archicrat-ia/"));
return entries.map((entry) => ({ return entries.map((entry) => ({
params: { slug: entry.slug }, // ✅ inline : jamais de helper externe (évite "stripPrefix is not defined")
params: { slug: entry.slug.replace(/^archicrat-ia\//, "") },
props: { entry }, props: { entry },
})); }));
} }
@@ -34,4 +35,4 @@ const { Content, headings } = await entry.render();
<h1>{entry.data.title}</h1> <h1>{entry.data.title}</h1>
<Content /> <Content />
</EditionLayout> </EditionLayout>

View File

@@ -2,12 +2,13 @@
import SiteLayout from "../../layouts/SiteLayout.astro"; import SiteLayout from "../../layouts/SiteLayout.astro";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
// ✅ Après migration physique : collection = "archicrat-ia", slug = "chapitre-3" (sans prefix) const entries = (await getCollection("archicratie"))
const entries = await getCollection("archicrat-ia"); .filter((e) => e.slug.startsWith("archicrat-ia/"));
entries.sort((a, b) => (a.data.order ?? 9999) - (b.data.order ?? 9999)); entries.sort((a, b) => (a.data.order ?? 9999) - (b.data.order ?? 9999));
const href = (slug) => `/archicrat-ia/${slug}/`; const strip = (slug) => slug.replace(/^archicrat-ia\//, "");
const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
--- ---
<SiteLayout title="Essai-thèse — ArchiCraT-IA"> <SiteLayout title="Essai-thèse — ArchiCraT-IA">
@@ -18,4 +19,4 @@ const href = (slug) => `/archicrat-ia/${slug}/`;
<li><a href={href(e.slug)}>{e.data.title}</a></li> <li><a href={href(e.slug)}>{e.data.title}</a></li>
))} ))}
</ul> </ul>
</SiteLayout> </SiteLayout>