Compare commits

...

24 Commits

Author SHA1 Message Date
69c91cb661 docs: formalize localhost auto-sync architecture
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 47s
CI / build-and-anchors (pull_request) Successful in 45s
2026-03-16 21:14:41 +01:00
04fee32fdb Merge pull request 'proposer: apply 2 tickets on /archicrat-ia/prologue/' (#272) from bot/proposer-270-39655773c199 into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 26s
CI / build-and-anchors (push) Successful in 43s
SMOKE / smoke (push) Successful in 8s
Deploy staging+live (annotations) / deploy (push) Successful in 9m20s
Reviewed-on: #272
2026-03-16 13:57:16 +01:00
archicratie-bot
fbddf5c3fc edit: apply ticket #271 (/archicrat-ia/prologue/#p-17-b8c5bf21)
All checks were successful
CI / build-and-anchors (push) Successful in 50s
SMOKE / smoke (push) Successful in 6s
CI / build-and-anchors (pull_request) Successful in 46s
2026-03-16 12:54:46 +00:00
archicratie-bot
bad748df3a edit: apply ticket #270 (/archicrat-ia/prologue/#p-7-64a0ca9c) 2026-03-16 12:54:21 +00:00
0066cf8601 Merge pull request 'fix(actions): harden proposer queue against duplicate batch PRs' (#269) from hotfix/proposer-close-verify into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 21s
Deploy staging+live (annotations) / deploy (push) Successful in 33s
CI / build-and-anchors (push) Successful in 44s
SMOKE / smoke (push) Successful in 4s
Reviewed-on: #269
2026-03-16 13:42:52 +01:00
5d3473d66c fix(actions): harden proposer queue against duplicate batch PRs
All checks were successful
SMOKE / smoke (push) Successful in 2s
CI / build-and-anchors (push) Successful in 40s
CI / build-and-anchors (pull_request) Successful in 39s
2026-03-16 13:39:09 +01:00
f9d34110e4 Merge pull request 'proposer: apply 2 tickets on /archicrat-ia/prologue/' (#266) from bot/proposer-264-20260316-120249 into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 20s
CI / build-and-anchors (push) Successful in 41s
SMOKE / smoke (push) Successful in 7s
Deploy staging+live (annotations) / deploy (push) Successful in 7m47s
Reviewed-on: #266
2026-03-16 13:18:27 +01:00
archicratie-bot
84e9c3ead4 edit: apply ticket #265 (/archicrat-ia/prologue/#p-5-85126fa5)
All checks were successful
CI / build-and-anchors (pull_request) Successful in 43s
SMOKE / smoke (push) Successful in 4s
CI / build-and-anchors (push) Successful in 39s
2026-03-16 12:03:37 +00:00
archicratie-bot
72e59175fc edit: apply ticket #264 (/archicrat-ia/prologue/#p-4-8ed4f807) 2026-03-16 12:03:13 +00:00
81b69ac6d5 Merge pull request 'fix(actions): verify proposer issue closure after PR creation' (#263) from hotfix/proposer-close-verify into main
All checks were successful
CI / build-and-anchors (push) Successful in 43s
Deploy staging+live (annotations) / deploy (push) Successful in 47s
Proposer Apply (Queue) / apply-proposer (push) Successful in 33s
SMOKE / smoke (push) Successful in 2s
Reviewed-on: #263
2026-03-16 12:54:26 +01:00
513ae72e85 fix(actions): verify proposer issue closure after PR creation
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 43s
CI / build-and-anchors (pull_request) Successful in 47s
2026-03-16 12:52:47 +01:00
4c4dd1c515 Merge pull request 'fix(actions): remove fragile heredocs from proposer PR step' (#262) from hotfix/proposer-no-heredoc-pr-step into main
All checks were successful
Deploy staging+live (annotations) / deploy (push) Successful in 39s
CI / build-and-anchors (push) Successful in 46s
SMOKE / smoke (push) Successful in 7s
Proposer Apply (Queue) / apply-proposer (push) Successful in 1m30s
Reviewed-on: #262
2026-03-16 12:34:12 +01:00
46b15ed6ab fix(actions): remove fragile heredocs from proposer PR step
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 44s
CI / build-and-anchors (pull_request) Successful in 40s
2026-03-16 12:30:43 +01:00
a015e72f7c Merge pull request 'proposer: apply ticket #257' (#261) from bot/proposer-257-20260316-111742 into main
All checks were successful
CI / build-and-anchors (push) Successful in 42s
SMOKE / smoke (push) Successful in 8s
Proposer Apply (Queue) / apply-proposer (push) Successful in 1m26s
Deploy staging+live (annotations) / deploy (push) Successful in 7m52s
Reviewed-on: #261
2026-03-16 12:21:55 +01:00
archicratie-bot
d5df7d77a0 edit: apply ticket #257 (/archicrat-ia/prologue/#p-0-d7974f88)
All checks were successful
CI / build-and-anchors (push) Successful in 42s
CI / build-and-anchors (pull_request) Successful in 40s
SMOKE / smoke (push) Successful in 4s
2026-03-16 11:18:06 +00:00
ec3ceee862 Merge pull request 'fix(actions): tolerate empty label payload in proposer gate' (#260) from debug/proposer-257 into main
Some checks failed
CI / build-and-anchors (push) Successful in 46s
Deploy staging+live (annotations) / deploy (push) Successful in 48s
SMOKE / smoke (push) Successful in 2s
Proposer Apply (Queue) / apply-proposer (push) Failing after 1m39s
Reviewed-on: #260
2026-03-16 12:16:12 +01:00
867475c3ff fix(actions): tolerate empty label payload in proposer gate
All checks were successful
SMOKE / smoke (push) Successful in 8s
CI / build-and-anchors (push) Successful in 45s
CI / build-and-anchors (pull_request) Successful in 39s
2026-03-16 12:14:01 +01:00
b024c5557c Merge pull request 'fix(editorial): preserve frontmatter in apply-ticket' (#259) from hotfix/preserve-frontmatter-apply-ticket into main
All checks were successful
CI / build-and-anchors (push) Successful in 47s
Proposer Apply (Queue) / apply-proposer (push) Successful in 38s
SMOKE / smoke (push) Successful in 11s
Deploy staging+live (annotations) / deploy (push) Successful in 9m38s
Reviewed-on: #259
2026-03-16 11:52:07 +01:00
93306f360d fix(editorial): preserve frontmatter in apply-ticket
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 51s
CI / build-and-anchors (pull_request) Successful in 44s
2026-03-16 11:48:08 +01:00
52847d999d Merge pull request 'fix(actions): silence anno workflow on proposer tickets' (#258) from hotfix/fix-proposer-runtime-v2 into main
Some checks failed
Deploy staging+live (annotations) / deploy (push) Successful in 51s
CI / build-and-anchors (push) Successful in 49s
SMOKE / smoke (push) Successful in 5s
Proposer Apply (Queue) / apply-proposer (push) Failing after 48s
Reviewed-on: #258
2026-03-16 11:11:28 +01:00
b9629b43ff fix(actions): silence anno workflow on proposer tickets
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 49s
CI / build-and-anchors (pull_request) Successful in 44s
2026-03-16 11:06:11 +01:00
06482a9f8d Merge pull request 'fix(actions): make proposer queue runtime-safe' (#254) from hotfix/fix-proposer-runtime-v2 into main
All checks were successful
Proposer Apply (Queue) / apply-proposer (push) Successful in 21s
CI / build-and-anchors (push) Successful in 46s
SMOKE / smoke (push) Successful in 6s
Deploy staging+live (annotations) / deploy (push) Successful in 42s
Reviewed-on: #254
2026-03-16 00:59:50 +01:00
f2e4ae5ac2 fix(actions): make proposer queue runtime-safe
All checks were successful
SMOKE / smoke (push) Successful in 7s
CI / build-and-anchors (push) Successful in 44s
CI / build-and-anchors (pull_request) Successful in 42s
2026-03-16 00:58:10 +01:00
71baf0f6da Merge pull request 'fix(actions): repair proposer workflow yaml' (#253) from hotfix/fix-proposer-workflow into main
Some checks failed
CI / build-and-anchors (push) Successful in 48s
Deploy staging+live (annotations) / deploy (push) Successful in 1m0s
SMOKE / smoke (push) Successful in 4s
Proposer Apply (Queue) / apply-proposer (push) Failing after 4s
Reviewed-on: #253
2026-03-16 00:42:17 +01:00
7 changed files with 2149 additions and 386 deletions

View File

@@ -41,7 +41,7 @@ jobs:
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/anno.env
import fs from "node:fs";
@@ -66,7 +66,10 @@ jobs:
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
if (m?.groups) {
owner = owner || m.groups.o;
repo = repo || m.groups.r;
}
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
@@ -81,7 +84,6 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input");
}
// label name: best-effort (non-bloquant)
let labelName = "workflow_dispatch";
const lab = ev?.label;
if (typeof lab === "string") labelName = lab;
@@ -95,7 +97,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
function sh(s){ return JSON.stringify(String(s)); }
function sh(s) { return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
@@ -108,7 +110,7 @@ jobs:
].join("\n") + "\n");
NODE
echo "context:"
echo "context:"
sed -n '1,120p' /tmp/anno.env
- name: Early gate (label event fast-skip, but tolerant)
@@ -116,18 +118,16 @@ jobs:
set -euo pipefail
source /tmp/anno.env
echo " event label = $LABEL_NAME"
echo "event label = $LABEL_NAME"
# 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 "label=$LABEL_NAME => skip early"
echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
exit 0
fi
echo "continue to API gating (issue=$ISSUE_NUMBER)"
echo "continue to API gating (issue=$ISSUE_NUMBER)"
- name: Fetch issue + hard gate on labels + Type
env:
@@ -135,9 +135,9 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
@@ -148,11 +148,12 @@ jobs:
node --input-type=module - <<'NODE' >> /tmp/anno.env
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const title = String(issue.title || "");
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json", "utf8"));
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 labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) {
@@ -164,14 +165,12 @@ jobs:
const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase();
const allowed = new Set(["type/media","type/reference","type/comment"]);
const proposer = new Set(["type/correction","type/fact-check"]);
const allowedAnno = new Set(["type/media", "type/reference", "type/comment"]);
const proposerTypes = new Set(["type/correction", "type/fact-check"]);
const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
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")}`);
@@ -182,23 +181,23 @@ jobs:
if (!type) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowed.has(type)) {
} else if (allowedAnno.has(type)) {
// proceed
} else if (proposer.has(type)) {
} else if (proposerTypes.has(type)) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:" + type)}`);
} else {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:" + type)}`);
}
process.stdout.write(out.join("\n") + "\n");
NODE
echo "gating result:"
echo "gating result:"
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 (unsupported / missing Type only)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -208,9 +207,13 @@ jobs:
[[ "${SKIP:-0}" == "1" ]] || exit 0
# IMPORTANT: do NOT comment for "not_approved_label_present" (avoid spam on other label events)
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
echo " skip reason=${SKIP_REASON} -> no comment"
echo "skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
if [[ "${SKIP_REASON:-}" == proposer_type:* ]]; then
echo "proposer ticket detected -> anno stays silent"
exit 0
fi
@@ -219,15 +222,13 @@ jobs:
REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}"
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."
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."
if [[ "$REASON" == unsupported_type:* ]]; then
MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment."
else
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: type/media|type/reference|type/comment"
MSG="Ticket #${ISSUE_NUMBER} ignored: missing or unreadable 'Type:'. Expected: type/media|type/reference|type/comment"
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")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
@@ -239,7 +240,7 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
rm -rf .git
git init -q
@@ -252,16 +253,16 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm ci --no-audit --no-fund
- name: Check apply script exists
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
test -f scripts/apply-annotation-ticket.mjs || {
echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
@@ -270,16 +271,16 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
npm run build
test -f dist/para-index.json || {
echo "missing dist/para-index.json after build"
echo "missing dist/para-index.json after build"
ls -la dist | sed -n '1,200p' || true
exit 1
}
echo "dist/para-index.json present"
echo "dist/para-index.json present"
- name: Apply ticket on bot branch (strict+verify, commit)
continue-on-error: true
@@ -290,10 +291,10 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${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; }
[[ "${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; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
@@ -340,11 +341,11 @@ jobs:
run: |
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then
echo " no failure detected"
echo "no failure detected"
exit 0
fi
@@ -356,8 +357,8 @@ jobs:
BODY="(no apply log found)"
fi
MSG="apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
MSG="apply-annotation-ticket failed (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
@@ -374,9 +375,9 @@ jobs:
source /tmp/anno.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; }
test -d .git || { echo " no git repo -> 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; }
test -d .git || { echo "no git repo -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
@@ -398,8 +399,8 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> 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; }
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."
@@ -420,10 +421,10 @@ jobs:
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; }
test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; }
MSG="PR créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
MSG="PR created for 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" \
@@ -431,7 +432,7 @@ jobs:
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD"
echo "PR: $PR_URL"
echo "PR: $PR_URL"
- name: Finalize (fail job if apply failed)
if: ${{ always() }}
@@ -439,11 +440,11 @@ jobs:
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then
echo "apply failed (rc=$RC)"
echo "apply failed (rc=$RC)"
exit "$RC"
fi
echo "apply ok"
echo "apply ok"

View File

@@ -45,7 +45,7 @@ jobs:
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/proposer.env
import fs from "node:fs";
@@ -55,7 +55,7 @@ jobs:
const cloneUrl =
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");
@@ -70,8 +70,12 @@ jobs:
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
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";
@@ -94,11 +98,15 @@ jobs:
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;
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));
}
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
`OWNER=${sh(owner)}`,
@@ -111,168 +119,32 @@ jobs:
].join("\n") + "\n");
NODE
echo "✅ context:"
echo "Context:"
sed -n '1,200p' /tmp/proposer.env
- name: Early gate
- name: Early gate (tolerant on empty issue label payload)
run: |
set -euo pipefail
source /tmp/proposer.env
echo "event=$EVENT_NAME label=${LABEL_NAME:-<empty>}"
if [[ "$EVENT_NAME" == "issues" ]]; then
if [[ "$LABEL_NAME" != "state/approved" ]]; then
echo " issues/labeled but label=$LABEL_NAME -> skip"
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip"
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="label_not_state_approved"' >> /tmp/proposer.env
echo 'SKIP_REASON="label_not_state_approved_event"' >> /tmp/proposer.env
exit 0
fi
fi
echo "✅ proceed"
- name: Select next proposer batch (by path)
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; }
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_API="$API_BASE"
node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env
cat /tmp/proposer.pick.env >> /tmp/proposer.env
source /tmp/proposer.pick.env
if [[ "${TARGET_FOUND:-0}" != "1" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env
echo " no target batch"
exit 0
fi
echo "✅ target batch:"
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
- name: Inspect open proposer PRs
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \
-o /tmp/open_pulls.json
export TARGET_ISSUES="${TARGET_ISSUES:-}"
node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs";
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json","utf8"));
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const proposerOpen = Array.isArray(pulls)
? pulls.filter(pr => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
: [];
const current = proposerOpen.find((pr) => {
const ref = String(pr?.head?.ref || "");
const title = String(pr?.title || "");
const body = String(pr?.body || "");
return issues.some(n =>
ref.startsWith(`bot/proposer-${n}-`) ||
title.includes(`#${n}`) ||
body.includes(`#${n}`) ||
body.includes(`ticket #${n}`)
);
});
const out = [];
if (current) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(current.html_url || current.url || ""))}`);
} else if (proposerOpen.length > 0) {
const first = proposerOpen[0];
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`);
}
process.stdout.write(out.join("\n") + (out.length ? "\n" : ""));
NODE
- name: Comment issue if queued / skipped
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${EVENT_NAME:-}" != "push" ]] || exit 0
test -n "${FORGE_TOKEN:-}" || exit 0
ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}"
if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then
ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}"
fi
[[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0
case "${SKIP_REASON:-}" in
queue_busy_open_proposer_pr)
MSG=" Ticket mis en file dattente Proposer.\n\nUne PR Proposer est déjà ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}\n\nLe workflow reprendra automatiquement le prochain lot après intégration sur main."
;;
issue_already_has_open_pr)
MSG=" Ce ticket a déjà une PR Proposer ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}"
;;
explicit_issue_missing_chemin)
MSG=" Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Chemin** est manquant ou illisible."
;;
explicit_issue_missing_type)
MSG=" Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Type** est manquant ou illisible."
;;
explicit_issue_not_approved)
MSG=" Proposer Apply: ce ticket nest pas actuellement marqué **state/approved**."
;;
explicit_issue_rejected)
MSG=" Proposer Apply: ce ticket porte **state/rejected** et nentre donc pas dans la file Proposer."
;;
no_open_approved_proposer_issue)
MSG=" Aucun ticket Proposer approuvé nest actuellement en attente."
;;
*)
MSG=" Proposer Apply: skip — ${SKIP_REASON:-raison non précisée}."
;;
esac
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_TO_COMMENT/comments" \
--data-binary "$PAYLOAD" || true
echo "Proceed to API-based selection/gating"
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
rm -rf .git
git init -q
@@ -285,7 +157,7 @@ jobs:
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
APP_DIR="."
if [[ -d "site" && -f "site/package.json" ]]; then
@@ -293,15 +165,253 @@ jobs:
fi
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
echo "APP_DIR=$APP_DIR"
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; }
echo "APP_DIR=$APP_DIR"
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: Select next proposer batch (by path)
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
}
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_API="$API_BASE"
cd "$APP_DIR"
test -f scripts/pick-proposer-issue.mjs || {
echo "missing scripts/pick-proposer-issue.mjs in APP_DIR=$APP_DIR"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env
cat /tmp/proposer.pick.env >> /tmp/proposer.env
source /tmp/proposer.pick.env
if [[ "${TARGET_FOUND:-0}" != "1" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env
echo "No target batch"
exit 0
fi
echo "Target batch:"
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
- name: Derive deterministic batch identity
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
export TARGET_ISSUES TARGET_CHEMIN
node --input-type=module - <<'NODE'
import fs from "node:fs";
import crypto from "node:crypto";
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.sort((a, b) => Number(a) - Number(b));
const chemin = String(process.env.TARGET_CHEMIN || "").trim();
const keySource = `${chemin}::${issues.join(",")}`;
const hash = crypto.createHash("sha1").update(keySource).digest("hex").slice(0, 12);
const primary = issues[0] || "0";
const batchBranch = `bot/proposer-${primary}-${hash}`;
fs.appendFileSync(
"/tmp/proposer.env",
[
`BATCH_KEY=${JSON.stringify(keySource)}`,
`BATCH_HASH=${JSON.stringify(hash)}`,
`BATCH_BRANCH=${JSON.stringify(batchBranch)}`
].join("\n") + "\n"
);
NODE
echo "Batch identity:"
grep -E '^(BATCH_KEY|BATCH_HASH|BATCH_BRANCH)=' /tmp/proposer.env
- name: Inspect open proposer PRs
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \
-o /tmp/open_pulls.json
export TARGET_ISSUES="${TARGET_ISSUES:-}"
export BATCH_BRANCH="${BATCH_BRANCH:-}"
export BATCH_KEY="${BATCH_KEY:-}"
node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs";
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8"));
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const batchBranch = String(process.env.BATCH_BRANCH || "");
const batchKey = String(process.env.BATCH_KEY || "");
const proposerOpen = Array.isArray(pulls)
? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
: [];
const sameBatch = proposerOpen.find((pr) => {
const ref = String(pr?.head?.ref || "");
const title = String(pr?.title || "");
const body = String(pr?.body || "");
if (batchBranch && ref === batchBranch) return true;
if (batchKey && body.includes(`Batch-Key: ${batchKey}`)) return true;
return issues.some((n) =>
ref.startsWith(`bot/proposer-${n}-`) ||
title.includes(`#${n}`) ||
body.includes(`#${n}`) ||
body.includes(`ticket #${n}`)
);
});
const out = [];
if (sameBatch) {
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(sameBatch.html_url || sameBatch.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(sameBatch?.head?.ref || ""))}`);
} else if (proposerOpen.length > 0) {
const first = proposerOpen[0];
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`);
}
process.stdout.write(out.join("\n") + (out.length ? "\n" : ""));
NODE
- name: Guard on remote batch branch before heavy work
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
if git ls-remote --exit-code --heads origin "$BATCH_BRANCH" >/dev/null 2>&1; then
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="batch_branch_exists_without_pr"' >> /tmp/proposer.env
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
echo "Remote batch branch already exists -> skip duplicate materialization"
exit 0
fi
echo "Remote batch branch is free"
- name: Comment issue if queued / skipped
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${EVENT_NAME:-}" != "push" ]] || exit 0
if [[ "${SKIP_REASON:-}" == "label_not_state_approved_event" || "${SKIP_REASON:-}" == "label_not_state_approved" ]]; then
echo "Skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || exit 0
ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}"
if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then
ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}"
fi
[[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0
case "${SKIP_REASON:-}" in
queue_busy_open_proposer_pr)
MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main."
;;
issue_already_has_open_pr)
MSG="This batch already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
;;
batch_branch_exists_without_pr)
MSG="This batch already has a remote batch branch (${OPEN_PR_BRANCH:-"(unknown branch)"}). Manual inspection is required before any new proposer PR is created."
;;
batch_branch_already_materialized)
MSG="This batch was already materialized by another run on branch ${OPEN_PR_BRANCH:-"(unknown branch)"}. No duplicate PR was created."
;;
explicit_issue_missing_chemin)
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
;;
explicit_issue_missing_type)
MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable."
;;
explicit_issue_not_approved)
MSG="Proposer Apply: this ticket is not currently labeled state/approved."
;;
explicit_issue_rejected)
MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue."
;;
no_open_approved_proposer_issue)
MSG="No approved proposer ticket is currently waiting."
;;
*)
MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}."
;;
esac
export MSG
node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json
const msg = process.env.MSG || "";
process.stdout.write(JSON.stringify({ body: msg }));
NODE
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \
--data-binary @/tmp/proposer.skip.comment.json || true
- name: NPM harden
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
@@ -313,6 +423,7 @@ jobs:
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm ci --no-audit --no-fund
@@ -321,6 +432,7 @@ jobs:
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm run build
@@ -333,14 +445,13 @@ jobs:
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
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-${TARGET_PRIMARY_ISSUE}-${TS}"
BR="$BATCH_BRANCH"
echo "BRANCH=$BR" >> /tmp/proposer.env
git checkout -b "$BR"
@@ -353,13 +464,16 @@ jobs:
RC=0
FAILED_ISSUE=""
for ISSUE in $TARGET_ISSUES; do
echo "" >>"$LOG"
echo "== ticket #$ISSUE ==" >>"$LOG"
echo "" >> "$LOG"
echo "== ticket #$ISSUE ==" >> "$LOG"
set +e
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >>"$LOG" 2>&1
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >> "$LOG" 2>&1
STEP_RC=$?
set -e
if [[ "$STEP_RC" -ne 0 ]]; then
RC="$STEP_RC"
FAILED_ISSUE="$ISSUE"
@@ -370,10 +484,11 @@ jobs:
echo "APPLY_RC=$RC" >> /tmp/proposer.env
echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env
echo "== apply log (tail) =="
echo "Apply log (tail):"
tail -n 220 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/proposer.env
exit 0
@@ -400,7 +515,7 @@ jobs:
git fetch origin "$DEFAULT_BRANCH"
set +e
git rebase "origin/$DEFAULT_BRANCH" >>"$LOG" 2>&1
git rebase "origin/$DEFAULT_BRANCH" >> "$LOG" 2>&1
RC=$?
set -e
@@ -410,7 +525,7 @@ jobs:
echo "REBASE_RC=$RC" >> /tmp/proposer.env
echo "== rebase log (tail) =="
echo "Rebase log (tail):"
tail -n 220 "$LOG" || true
- name: Comment issues on failure
@@ -426,7 +541,7 @@ jobs:
REBASE_RC="${REBASE_RC:-0}"
if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then
echo " no failure detected"
echo "No failure detected"
exit 0
fi
@@ -438,22 +553,58 @@ jobs:
BODY="(no proposer log found)"
fi
export BODY APPLY_RC REBASE_RC FAILED_ISSUE
if [[ "$APPLY_RC" != "0" ]]; then
MSG="❌ Batch Proposer en échec sur le ticket #${FAILED_ISSUE:-"(inconnu)"} (rc=${APPLY_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
export FAILURE_KIND="apply"
else
MSG="❌ Rebase Proposer en échec sur main (rc=${REBASE_RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
export FAILURE_KIND="rebase"
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
node --input-type=module - <<'NODE' > /tmp/proposer.failure.comment.json
const body = process.env.BODY || "";
const applyRc = process.env.APPLY_RC || "0";
const rebaseRc = process.env.REBASE_RC || "0";
const failedIssue = process.env.FAILED_ISSUE || "unknown";
const kind = process.env.FAILURE_KIND || "apply";
const msg =
kind === "apply"
? `Batch proposer failed on ticket #${failedIssue} (rc=${applyRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`
: `Rebase proposer failed on main (rc=${rebaseRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`;
process.stdout.write(JSON.stringify({ body: msg }));
NODE
for ISSUE in ${TARGET_ISSUES:-}; do
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary "$PAYLOAD" || true
--data-binary @/tmp/proposer.failure.comment.json || true
done
- name: Late guard against duplicate batch materialization
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
REMOTE_SHA="$(git ls-remote --heads origin "$BATCH_BRANCH" | awk 'NR==1 {print $1}')"
if [[ -n "${REMOTE_SHA:-}" && "${REMOTE_SHA}" != "${END_SHA:-}" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="batch_branch_already_materialized"' >> /tmp/proposer.env
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
echo "Remote batch branch already exists at $REMOTE_SHA -> skip duplicate push/PR"
exit 0
fi
echo "Late guard OK"
- name: Push bot branch
if: ${{ always() }}
env:
@@ -462,10 +613,10 @@ jobs:
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${REBASE_RC:-0}" == "0" ]] || { echo " rebase failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip push"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "Apply failed -> skip push"; exit 0; }
[[ "${REBASE_RC:-0}" == "0" ]] || { echo "Rebase 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);
@@ -489,7 +640,35 @@ jobs:
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
[[ -n "${BRANCH:-}" ]] || { echo " BRANCH unset -> skip PR"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; }
OPEN_PRS_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100")"
export OPEN_PRS_JSON BATCH_BRANCH BATCH_KEY
EXISTING_PR_URL="$(node --input-type=module -e '
const pulls = JSON.parse(process.env.OPEN_PRS_JSON || "[]");
const branch = String(process.env.BATCH_BRANCH || "");
const key = String(process.env.BATCH_KEY || "");
const current = Array.isArray(pulls)
? pulls.find((pr) => {
const ref = String(pr?.head?.ref || "");
const body = String(pr?.body || "");
return (branch && ref === branch) || (key && body.includes(`Batch-Key: ${key}`));
})
: null;
process.stdout.write(current ? String(current.html_url || current.url || "") : "");
')"
if [[ -n "${EXISTING_PR_URL:-}" ]]; then
echo "PR already exists for this batch: $EXISTING_PR_URL"
exit 0
fi
if [[ "${TARGET_COUNT:-0}" == "1" ]]; then
PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}"
@@ -497,73 +676,97 @@ jobs:
PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}"
fi
PR_PAYLOAD="$(
TITLE="$PR_TITLE" \
CHEMIN="$TARGET_CHEMIN" \
ISSUES="$TARGET_ISSUES" \
BRANCH="$BRANCH" \
END_SHA="${END_SHA:-unknown}" \
DEFAULT_BRANCH="$DEFAULT_BRANCH" \
OWNER="$OWNER" \
node --input-type=module <<'NODE'
const issues = String(process.env.ISSUES || "")
export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER BATCH_KEY
node --input-type=module -e '
import fs from "node:fs";
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const body = [
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map(n => `#${n}`).join(", ")} (state/approved).`,
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`,
"",
`- Chemin: ${process.env.CHEMIN || "(inconnu)"}`,
`- Chemin: ${process.env.TARGET_CHEMIN || "(inconnu)"}`,
"- Tickets:",
...issues.map(n => ` - #${n}`),
`- Branche: ${process.env.BRANCH}`,
...issues.map((n) => ` - #${n}`),
`- Branche: ${process.env.BRANCH || ""}`,
`- Commit: ${process.env.END_SHA || "unknown"}`,
`- Batch-Key: ${process.env.BATCH_KEY || ""}`,
"",
"Merge si CI OK."
].join("\n");
console.log(JSON.stringify({
title: process.env.TITLE,
body,
base: process.env.DEFAULT_BRANCH,
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
}));
NODE
)"
fs.writeFileSync(
"/tmp/proposer.pr.json",
JSON.stringify({
title: process.env.PR_TITLE || "proposer: apply tickets",
body,
base: process.env.DEFAULT_BRANCH || "main",
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
})
);
'
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")"
--data-binary @/tmp/proposer.pr.json)"
PR_URL="$(node --input-type=module -e '
const pr = JSON.parse(process.argv[1] || "{}");
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
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; }
test -n "$PR_URL" || {
echo "PR URL missing. Raw: $PR_JSON"
exit 1
}
for ISSUE in $TARGET_ISSUES; do
MSG="✅ PR Proposer créée pour le ticket #${ISSUE} : ${PR_URL}\n\nLe ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR."
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
export ISSUE PR_URL
node --input-type=module -e '
import fs from "node:fs";
const issue = process.env.ISSUE || "";
const url = process.env.PR_URL || "";
const msg =
`PR proposer creee pour le ticket #${issue} : ${url}\n\n` +
`Le ticket est cloture automatiquement ; la discussion peut se poursuivre dans la PR.`;
fs.writeFileSync(
"/tmp/proposer.issue.close.comment.json",
JSON.stringify({ body: msg })
);
'
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary "$C_PAYLOAD"
--data-binary @/tmp/proposer.issue.close.comment.json
curl -fsS -X PATCH \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \
--data-binary '{"state":"closed"}'
ISSUE_STATE="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" | \
node --input-type=module -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { const j = JSON.parse(s || "{}"); process.stdout.write(String(j.state || "")); });')"
[[ "$ISSUE_STATE" == "closed" ]] || {
echo "Issue #$ISSUE is still not closed after PATCH"
exit 1
}
done
echo "PR: $PR_URL"
echo "PR: $PR_URL"
- name: Finalize
if: ${{ always() }}
@@ -573,13 +776,13 @@ jobs:
[[ "${SKIP:-0}" != "1" ]] || exit 0
if [[ "${APPLY_RC:-0}" != "0" ]]; then
echo "❌ apply failed (rc=${APPLY_RC})"
echo "Apply failed (rc=${APPLY_RC})"
exit "${APPLY_RC}"
fi
if [[ "${REBASE_RC:-0}" != "0" ]]; then
echo "❌ rebase failed (rc=${REBASE_RC})"
echo "Rebase failed (rc=${REBASE_RC})"
exit "${REBASE_RC}"
fi
echo "✅ proposer queue ok"
echo "Proposer queue OK"

View File

@@ -0,0 +1,964 @@
# OPS — Localhost auto-sync
## Objet
Ce document fige larchitecture locale qui permet de garder le **localhost éditorial** aligné sur `origin/main`, sans dépendre de la mémoire de lopérateur.
But recherché :
- quand une PR est mergée sur `main`, le **localhost** doit pouvoir se réaligner automatiquement ;
- le serveur local Astro doit tourner depuis un **worktree dédié** ;
- le dépôt de développement principal ne doit pas être pollué par ce mécanisme ;
- lexploitation doit rester simple à diagnostiquer ;
- linstallation doit pouvoir être **réappliquée proprement** après oubli, incident, ou redémarrage machine.
---
## Principes
Larchitecture locale repose sur **trois espaces distincts** :
1. **Repo canonique de développement**
- Chemin :
`/Volumes/FunIA/dev/archicratie-edition/site`
- Usage :
développement normal, nouvelles fonctionnalités, corrections manuelles, branches de travail, commits, PR.
2. **Worktree localhost dédié**
- Chemin :
`~/ops-local/archicratie/localhost-worktree`
- Branche locale :
`localhost-sync`
- Usage :
exécuter `astro dev` sur une copie locale réalignée automatiquement sur `origin/main`.
3. **Ops local hors repo**
- Chemin :
`~/ops-local/archicratie`
- Usage :
scripts dexploitation, logs, état, automatisation LaunchAgent.
---
## Pourquoi cette séparation
Il ne faut pas utiliser le dépôt de développement principal comme serveur localhost permanent.
Sinon on mélange :
- travail en cours ;
- commits non poussés ;
- essais temporaires ;
- état réellement publié sur `main`.
Le résultat devient ambigu.
Le **worktree localhost** sert donc de **miroir exécutable de `main`**, tandis que le repo canonique reste lespace de développement.
---
## Vue densemble
Flux logique :
1. `origin/main` avance après merge dune PR.
2. Un agent local de sync :
- fetch `origin/main` depuis le repo canonique ;
- réaligne le worktree localhost ;
- exécute `npm ci` si nécessaire ;
- déclenche le redémarrage de lagent Astro local.
3. Un agent Astro local :
- démarre `astro dev` depuis le worktree localhost ;
- écoute sur `127.0.0.1:4321`.
4. Le site local accessible sur `http://127.0.0.1:4321` reflète alors `main`.
---
## Référence des chemins
### Repo canonique
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
### Worktree localhost
```text
/Users/s-funia/ops-local/archicratie/localhost-worktree
```
### Ops local
```text
/Users/s-funia/ops-local/archicratie
```
### Scripts principaux
```text
/Users/s-funia/ops-local/archicratie/auto-sync-localhost.sh
/Users/s-funia/ops-local/archicratie/run-astro-localhost.sh
/Users/s-funia/ops-local/archicratie/doctor-localhost.sh
/Users/s-funia/ops-local/archicratie/install-localhost-sync.sh
```
### Logs ops
```text
/Users/s-funia/ops-local/archicratie/logs/auto-sync-localhost.log
/Users/s-funia/ops-local/archicratie/logs/astro-localhost.log
```
### État du dernier sync
```text
/Users/s-funia/ops-local/archicratie/last-sync.env
```
### LaunchAgents
```text
~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
```
### Logs launchd
```text
~/Library/Logs/archicratie-localhost-sync.out.log
~/Library/Logs/archicratie-localhost-sync.err.log
~/Library/Logs/archicratie-localhost-astro.out.log
~/Library/Logs/archicratie-localhost-astro.err.log
```
---
## Architecture retenue
### 1. Le repo canonique
Le repo canonique reste la source locale de travail :
```bash
cd /Volumes/FunIA/dev/archicratie-edition/site
```
Cest ici quon fait :
- création de branches ;
- développement ;
- tests manuels de nouvelles fonctionnalités ;
- commits ;
- pushes ;
- PR.
### 2. Le worktree localhost
Le localhost ne tourne **pas** depuis le repo canonique.
Il tourne depuis un worktree dédié :
```bash
cd ~/ops-local/archicratie/localhost-worktree
git branch --show-current
```
Branche attendue :
```text
localhost-sync
```
Ce worktree suit `origin/main` via le script dauto-sync.
### 3. Le dossier ops local
Les scripts dexploitation ne doivent pas être mis dans le repo, ni sur un volume externe soumis à des restrictions de lancement.
Ils sont placés sous `HOME` :
```text
~/ops-local/archicratie
```
Motif :
- compatibilité avec `launchd` ;
- logs persistants ;
- visibilité claire ;
- indépendance du dépôt.
---
## Pourquoi les scripts et le worktree localhost ne sont plus sur `/Volumes/...`
Deux difficultés réelles ont été rencontrées.
### 1. Exécution LaunchAgent depuis un volume externe
Erreur observée :
```text
/bin/bash: /Volumes/FunIA/dev/_ops-local/archicratie/auto-sync-localhost.sh: Operation not permitted
```
Conclusion :
- le LaunchAgent peut échouer si le script est lancé depuis un chemin sur volume externe ;
- le plus robuste est de placer les scripts ops sous `HOME`.
### 2. Exécution Astro depuis lancien localhost sous `/Volumes/...`
Erreur observée :
```text
Error: EPERM: operation not permitted, open '/Volumes/FunIA/dev/archicratie-localhost/node_modules/astro/bin/astro.mjs'
```
Conclusion :
- lancien localhost situé sur `/Volumes/FunIA/dev/archicratie-localhost` nest **plus** une base fiable pour lexploitation automatisée ;
- le worktree localhost doit lui aussi être placé sous `HOME`.
Donc :
- **bon choix** : `~/ops-local/archicratie`
- **mauvais choix** : `/Volumes/FunIA/dev/_ops-local/...`
- **ancien chemin obsolète** : `/Volumes/FunIA/dev/archicratie-localhost`
---
## Rôle exact du script `auto-sync-localhost.sh`
Le script a quatre responsabilités :
1. **Vérifier lenvironnement**
- `git`
- `bash`
- `node`
- `npm`
2. **Garantir le runtime Node**
- Node `22.x`
- npm `10.x`
3. **Réaligner le worktree localhost**
- fetch du repo canonique ;
- sassurer que le worktree localhost existe ;
- checkout `localhost-sync` ;
- reset hard sur `origin/main` ;
- clean si nécessaire ;
- `npm ci` si nécessaire.
4. **Déclencher le redémarrage dAstro**
- relancer lagent LaunchAgent Astro ;
- laisser `run-astro-localhost.sh` porter le démarrage réel du serveur.
---
## Rôle exact du script `run-astro-localhost.sh`
Ce script a une responsabilité unique :
- démarrer `astro dev` **depuis le worktree localhost** ;
- sur `127.0.0.1:4321` ;
- avec le bon runtime Node ;
- en produisant les logs Astro dédiés.
Il ne doit jamais démarrer depuis le repo canonique.
---
## Rôle exact du script `doctor-localhost.sh`
Le doctor sert à produire un **diagnostic lisible et opératoire** sur :
- le runtime Node ;
- les LaunchAgents ;
- le worktree localhost ;
- lalignement sur `origin/main` ;
- létat du serveur Astro ;
- la réponse HTTP de `localhost:4321` ;
- les fichiers détat et les logs.
Cest la **commande de référence** pour savoir si linstallation est saine.
---
## Rôle exact du script `install-localhost-sync.sh`
Le script dinstallation sert à **reconstruire proprement toute larchitecture locale** :
- écrire ou réécrire `auto-sync-localhost.sh` ;
- écrire ou réécrire `run-astro-localhost.sh` ;
- écrire ou réécrire les deux LaunchAgents ;
- recharger les LaunchAgents ;
- exécuter `doctor-localhost.sh` en fin dinstallation.
Cest la **commande de réinstallation standard** après oubli, casse, dérive ou changement de configuration.
---
## Runtime Node requis
Le projet exige :
```json
{
"node": ">=22 <23",
"npm": ">=10 <11"
}
```
Le runtime retenu localement est donc :
```text
/opt/homebrew/opt/node@22/bin/node
/opt/homebrew/opt/node@22/bin/npm
```
Vérification :
```bash
"$(brew --prefix node@22)/bin/node" -v
"$(brew --prefix node@22)/bin/npm" -v
```
Résultat attendu :
```text
v22.x
10.x
```
---
## LaunchAgents
Larchitecture finale repose sur **deux LaunchAgents**, avec des responsabilités distinctes.
### 1. LaunchAgent sync
Fichier :
```text
~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
```
Rôle :
- réaligner périodiquement le worktree localhost sur `origin/main` ;
- relancer lagent Astro si nécessaire.
### 2. LaunchAgent Astro
Fichier :
```text
~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
```
Rôle :
- exécuter `run-astro-localhost.sh` ;
- lancer `astro dev` depuis le worktree localhost.
---
## Contenu de référence du LaunchAgent sync
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>me.archicratie.localhost-sync</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/s-funia/ops-local/archicratie/auto-sync-localhost.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>60</integer>
<key>WorkingDirectory</key>
<string>/Users/s-funia</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/s-funia</string>
<key>PATH</key>
<string>/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>LANG</key>
<string>fr_FR.UTF-8</string>
<key>LC_ALL</key>
<string>fr_FR.UTF-8</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/s-funia/Library/Logs/archicratie-localhost-sync.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/s-funia/Library/Logs/archicratie-localhost-sync.err.log</string>
</dict>
</plist>
```
---
## Contenu de référence du LaunchAgent Astro
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>me.archicratie.localhost-astro</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/s-funia/ops-local/archicratie/run-astro-localhost.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>WorkingDirectory</key>
<string>/Users/s-funia</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/s-funia</string>
<key>PATH</key>
<string>/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>LANG</key>
<string>fr_FR.UTF-8</string>
<key>LC_ALL</key>
<string>fr_FR.UTF-8</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/s-funia/Library/Logs/archicratie-localhost-astro.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/s-funia/Library/Logs/archicratie-localhost-astro.err.log</string>
</dict>
</plist>
```
---
## Installation / réinstallation des LaunchAgents
### Vérifier les plists
```bash
plutil -lint ~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
plutil -lint ~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
```
### Recharger proprement
```bash
launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/me.archicratie.localhost-sync.plist 2>/dev/null || true
launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/me.archicratie.localhost-astro.plist 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
launchctl kickstart -k "gui/$(id -u)/me.archicratie.localhost-sync"
launchctl kickstart -k "gui/$(id -u)/me.archicratie.localhost-astro"
```
### Inspecter létat
```bash
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,160p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,160p'
```
État attendu :
- pas derreur `Operation not permitted` ;
- pas derreur `EX_CONFIG` ;
- le LaunchAgent sync tourne périodiquement ;
- le LaunchAgent Astro peut être vu comme `running`, `spawn scheduled` ou `not running` selon le moment dobservation ;
- létat réel de vérité reste le **doctor** et la **réponse HTTP sur 4321**.
---
## Vérifications de bon fonctionnement
### 1. Vérifier que le worktree suit bien `origin/main`
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
```
Les deux SHA doivent être identiques.
### 2. Vérifier la branche du worktree localhost
```bash
git -C ~/ops-local/archicratie/localhost-worktree branch --show-current
```
Résultat attendu :
```text
localhost-sync
```
### 3. Vérifier le dernier état sync
```bash
cat ~/ops-local/archicratie/last-sync.env
```
Exemple attendu :
```text
LAST_SYNC_AT="2026-03-16 20:29:43"
LAST_SYNC_SHA="a1bfbf4405d1342c635caec5b219c14b83b36d5e"
LAST_SYNC_STATUS="noop"
```
### 4. Vérifier les logs de sync
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
```
### 5. Vérifier les logs Astro
```bash
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
```
### 6. Vérifier quAstro écoute bien sur 4321
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
```
### 7. Vérifier le processus Astro exact
```bash
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
Attendu :
- commande contenant `astro dev`
- cwd = `~/ops-local/archicratie/localhost-worktree`
### 8. Vérifier le contenu servi
Exemple :
```bash
curl -s http://127.0.0.1:4321/archicrat-ia/prologue/ | grep -n "taxe Zucman"
```
Le texte renvoyé doit correspondre à la version attendue sur `main`.
### 9. Vérifier globalement toute linstallation
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
Verdict attendu :
```text
✅ aucun problème bloquant détecté
✅ aucun avertissement
```
---
## Interprétation des statuts
### `status=updated`
Le worktree localhost a été réaligné sur un nouveau SHA ou nettoyé, puis lagent Astro a été relancé.
### `status=noop`
Le worktree localhost était déjà aligné et le serveur local tournait correctement.
Aucun changement de contenu nétait nécessaire.
---
## Usage normal au quotidien
### Cas A — travail produit / publié
Quand on veut simplement consulter localement létat réel de `main` :
- on laisse tourner les LaunchAgents ;
- on consulte `http://127.0.0.1:4321`.
Dans ce mode, le localhost doit être considéré comme :
**un miroir local exécutable de `origin/main`**.
### Cas B — développement de nouvelles fonctionnalités
Quand on veut modifier du code, tester un comportement, créer une branche :
```bash
cd /Volumes/FunIA/dev/archicratie-edition/site
git switch -c feat/ma-branche
npm run dev
```
Dans ce mode, on travaille dans le **repo canonique**, pas dans le worktree localhost.
---
## Règle dor
Il existe désormais **deux usages distincts** :
### 1. Voir ce qui est réellement sur `main`
Utiliser :
```text
http://127.0.0.1:4321
```
qui sert depuis :
```text
~/ops-local/archicratie/localhost-worktree
```
### 2. Développer / tester du neuf
Utiliser :
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
avec branche locale, commandes manuelles, tests de dev.
---
## Ce quil ne faut pas faire
### Ne pas développer dans le worktree localhost
Le worktree localhost est un miroir piloté.
Il peut être reset automatiquement.
Donc :
- pas de commits dedans ;
- pas de modifications de fond dedans ;
- pas de dev feature dedans.
### Ne pas utiliser le repo canonique comme miroir auto-sync
Sinon on mélange :
- environnement de dev ;
- état publié ;
- serveur local permanent.
### Ne pas conserver lancien chemin localhost comme référence
Le chemin :
```text
/Volumes/FunIA/dev/archicratie-localhost
```
nest plus la référence opérationnelle.
Il est obsolète pour cette architecture.
### Ne pas déplacer les scripts ops sur un volume externe
Sinon `launchd` peut échouer avec :
```text
Operation not permitted
```
---
## Procédure de redémarrage machine
Après reboot, le comportement attendu est :
1. les LaunchAgents se rechargent ;
2. le script dauto-sync sexécute ;
3. le worktree localhost est réaligné ;
4. Astro redémarre sur `127.0.0.1:4321`.
### Vérification rapide après reboot
```bash
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,120p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,120p'
tail -n 80 ~/ops-local/archicratie/logs/auto-sync-localhost.log
lsof -nP -iTCP:4321 -sTCP:LISTEN
```
---
## Procédure de secours manuelle
### Forcer un resync
```bash
~/ops-local/archicratie/auto-sync-localhost.sh
```
### Forcer un diagnostic complet
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
### Réinstaller tout le dispositif
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## Symptômes fréquents et diagnostic
### Symptôme 1 — localhost ne montre pas les dernières modifs
Vérifier :
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
```
Si les SHA diffèrent :
- le sync na pas tourné ;
- ou le LaunchAgent sync ne sexécute pas.
### Symptôme 2 — SHA bon, mais contenu web pas à jour
Vérifier :
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
Cause probable :
- Astro tourne depuis le mauvais dossier ;
- ou un ancien `astro dev` manuel tourne encore ailleurs.
### Symptôme 3 — LaunchAgent présent, mais rien ne tourne
Vérifier :
```bash
tail -n 80 ~/Library/Logs/archicratie-localhost-sync.err.log
tail -n 80 ~/Library/Logs/archicratie-localhost-astro.err.log
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
```
Causes possibles :
- mauvais PATH ;
- Node 22 absent ;
- script non exécutable ;
- erreur dans un plist ;
- ancien chemin encore référencé ;
- worktree localhost absent ;
- `node_modules` non installés dans le worktree.
### Symptôme 4 — erreur `Operation not permitted`
Cause probable :
- script ops ou agent lancé depuis un chemin sous `/Volumes/...`.
Résolution :
- conserver les scripts sous `~/ops-local/archicratie`.
### Symptôme 5 — erreur `EBADENGINE`
Cause probable :
- Node 23 utilisé à la place de Node 22.
Résolution :
- forcer PATH avec `node@22` dans les LaunchAgents et dans les scripts.
### Symptôme 6 — erreur `EPERM` sur `astro.mjs`
Cause probable :
- Astro essaie encore de démarrer depuis lancien emplacement localhost ;
- ou une incohérence persiste dans les chemins des scripts.
Résolution :
- vérifier que **tous** les scripts pointent vers `~/ops-local/archicratie/localhost-worktree` ;
- vérifier que `doctor-localhost.sh` confirme le bon cwd ;
- réinstaller via `install-localhost-sync.sh`.
---
## Commandes de contrôle essentielles
### État Git
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
git -C ~/ops-local/archicratie/localhost-worktree branch --show-current
```
### État LaunchAgents
```bash
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,160p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,160p'
```
### État logs
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
tail -n 80 ~/Library/Logs/archicratie-localhost-sync.err.log
tail -n 80 ~/Library/Logs/archicratie-localhost-astro.err.log
```
### État serveur
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
curl -I -s http://127.0.0.1:4321/ | head -n 5
```
### Vérification contenu
```bash
curl -s http://127.0.0.1:4321/archicrat-ia/prologue/ | grep -n "taxe Zucman"
```
---
## Décision dexploitation finale
La politique retenue est la suivante :
- **repo canonique** = espace de développement ;
- **worktree localhost** = miroir automatique de `main` ;
- **ops sous HOME** = scripts, logs, automation ;
- **LaunchAgent sync** = réalignement périodique ;
- **LaunchAgent astro** = exécution dAstro ;
- **Astro local** = lancé uniquement depuis le worktree localhost.
Cette séparation rend le dispositif plus :
- lisible ;
- robuste ;
- opérable ;
- antifragile.
---
## Résumé opératoire
### Pour voir la vérité de `main`
Ouvrir :
```text
http://127.0.0.1:4321
```
Le serveur doit provenir de :
```text
~/ops-local/archicratie/localhost-worktree
```
### Pour développer
Travailler dans :
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
avec tes commandes habituelles.
### Pour réparer vite
```bash
~/ops-local/archicratie/doctor-localhost.sh
~/ops-local/archicratie/auto-sync-localhost.sh
```
### Pour tout réinstaller proprement
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## Mémoire courte
Si un jour plus rien nest clair, repartir de ces six commandes :
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,120p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,120p'
lsof -nP -iTCP:4321 -sTCP:LISTEN
~/ops-local/archicratie/doctor-localhost.sh
```
Et lire :
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
```
---
## Statut actuel visé
Quand tout fonctionne correctement :
- `~/ops-local/archicratie/localhost-worktree` pointe sur le même SHA que `origin/main` ;
- `astro dev` écoute sur `127.0.0.1:4321` ;
- son cwd est `~/ops-local/archicratie/localhost-worktree` ;
- le contenu servi correspond au contenu mergé sur `main`.
Cest létat de référence à préserver.

View File

@@ -1,51 +1,147 @@
# START-HERE — Archicratie / Édition Web (v2)
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO)
# START-HERE — Archicratie / Édition Web (v3)
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO → localhost auto-sync)
## 0) TL;DR (la règle dor)
- **Gitea = source canonique**.
- **main est protégé** : toute modification passe par **branche → PR → CI → merge**.
- **Le NAS nest pas la source** : si un hotfix est fait sur NAS, on **backporte** via PR immédiatement.
- **Le site est statique Astro** : la prod sert du HTML (nginx), laccès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
- **Gitea = source canonique**.
- **`main` est protégée** : toute modification passe par **branche → PR → CI → merge**.
- **Le NAS nest pas la source** : si un hotfix est fait sur NAS, il doit être **backporté immédiatement** via PR.
- **Le site est statique Astro** : la prod sert du HTML via nginx ; laccès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
- **Le localhost automatique nest pas le repo de dev** : il tourne depuis un **worktree dédié**, synchronisé sur `origin/main`.
---
## 1) Architecture mentale (ultra simple)
- **DEV (Mac Studio)** : édition + tests + commit + push
- **Gitea** : dépôt canon + PR + CI (CI.yaml)
- **NAS (DS220+)** : déploiement “blue/green”
- `web_blue` (staging upstream) → `127.0.0.1:8081`
- `web_green` (live upstream)`127.0.0.1:8082`
- **Edge (Traefik)** : route les hosts
- **DEV canonique (Mac Studio)** : édition, dev, tests, commits, pushes
- **Gitea** : dépôt canonique, PR, CI, workflows éditoriaux
- **NAS (DS220+)** : déploiement blue/green
- `web_blue` → staging upstream → `127.0.0.1:8081`
- `web_green` → live upstream → `127.0.0.1:8082`
- **Edge (Traefik)** : routage des hosts
- `staging.archicratie...` → 8081
- `archicratie...` → 8082
- **Authelia** devant, via middleware `chain-auth@file`
- **Localhost auto-sync**
- un **repo canonique de développement**
- un **worktree localhost miroir de `origin/main`**
- un **agent de sync**
- un **agent Astro**
---
## 2) Répertoires & conventions (repo)
### 2.1 Contenu canon (édition)
- `src/content/**` : contenu MD / MDX canon (Astro content collections)
- `src/pages/**` : routes Astro (index, [...slug], etc.)
- `src/components/**` : composants UI (SiteNav, TOC, SidePanel, etc.)
- `src/layouts/**` : layouts (EditionLayout, SiteLayout)
- `src/content/**` : contenu MD / MDX canon
- `src/pages/**` : routes Astro
- `src/components/**` : composants UI
- `src/layouts/**` : layouts
- `src/styles/**` : CSS global
### 2.2 Annotations (pré-Édition “tickets”)
- `src/annotations/<workKey>/<slug>.yml`
- Exemple : `src/annotations/archicrat-ia/prologue.yml`
- Objectif : stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
- Exemple :
`src/annotations/archicrat-ia/prologue.yml`
Objectif :
stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
### 2.3 Scripts (tooling / build)
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans dist
- `scripts/dedupe-ids-dist.mjs` : retire IDs dupliqués dans dist
- `scripts/build-para-index.mjs` : index paragraphes (postbuild / predev)
- `scripts/build-annotations-index.mjs` : index annotations (postbuild / predev)
- `scripts/check-anchors.mjs` : contrat stabilité dancres (CI)
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans `dist`
- `scripts/dedupe-ids-dist.mjs` : retrait IDs dupliqués
- `scripts/build-para-index.mjs` : index paragraphes
- `scripts/build-annotations-index.mjs` : index annotations
- `scripts/check-anchors.mjs` : contrat stabilité dancres
- `scripts/check-annotations*.mjs` : sanity YAML + médias
> Important : les scripts sont **partie intégrante** de la stabilité (IDs/ancres/indexation).
> On évite “la magie” : tout est scripté + vérifié.
> Important : ces scripts ne sont pas accessoires.
> Ils font partie du contrat de stabilité éditoriale.
## 3) Workflow Git “pro” (main protégé)
### 3.1 Cycle standard (toute modif)
en bash :
---
## 3) Les trois espaces à ne jamais confondre
### 3.1 Repo canonique de développement
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
Usage :
- développement normal
- branches de travail
- nouvelles fonctionnalités
- corrections manuelles
- commits
- pushes
- PR
### 3.2 Worktree localhost miroir de `main`
```text
/Users/s-funia/ops-local/archicratie/localhost-worktree
```
Branche attendue :
```text
localhost-sync
```
Usage :
- exécuter le localhost automatique
- refléter `origin/main`
- ne jamais servir despace de développement
### 3.3 Ops local hors repo
```text
/Users/s-funia/ops-local/archicratie
```
Usage :
- scripts dexploitation
- état
- logs
- automatisation `launchd`
---
## 4) Pourquoi cette séparation existe
Il ne faut pas utiliser le repo canonique de développement comme serveur localhost permanent.
Sinon on mélange :
- travail en cours
- commits non poussés
- essais temporaires
- état réellement publié sur `main`
Le résultat devient ambigu.
La séparation retenue est donc :
- **repo canonique** = espace de développement
- **worktree localhost** = miroir exécutable de `origin/main`
- **ops local** = scripts et automatisation
Cest cette séparation qui rend le système lisible, robuste et opérable.
---
## 5) Workflow Git “pro” (main protégée)
### 5.1 Cycle standard (toute modif)
```bash
git checkout main
git pull --ff-only
@@ -60,37 +156,48 @@ npm run test:anchors
git add -A
git commit -m "xxx: description claire"
git push -u origin "$BR"
```
### 3.2 PR vers main
### 5.2 PR vers `main`
Ouvrir PR dans Gitea
- ouvrir une PR dans Gitea
- attendre une CI verte
- merger
- laisser les workflows faire le reste
CI doit être verte
### 5.3 Cas spécial : hotfix prod (NAS)
Merge PR → main
On peut faire un hotfix durgence côté NAS si nécessaire.
### 3.3 Cas spécial : hotfix prod (NAS)
Mais létat final doit toujours revenir dans Gitea :
On peut faire un hotfix “urgence” en prod/staging si nécessaire…
- branche
- PR
- CI
- merge
MAIS : létat final doit revenir dans Gitea : branche → PR → CI → merge.
---
## 4) Déploiement (NAS) — principe
### 4.1 Release pack
## 6) Déploiement (NAS) — principe
On génère un pack “reproductible” (source + config + scripts) puis on déploie.
### 6.1 Release pack
### 4.2 Blue/Green
On génère un pack reproductible, puis on déploie.
web_blue = staging upstream (8081)
### 6.2 Blue/Green
web_green = live upstream (8082)
- `web_blue` = staging (`8081`)
- `web_green` = live (`8082`)
Edge Traefik sélectionne quel host pointe vers quel upstream.
Le reverse-proxy choisit lupstream selon le host demandé.
## 5) Check-list “≤ 10 commandes” (happy path complet)
### 5.1 DEV (Mac)
---
## 7) Happy path complet
### 7.1 DEV (Mac)
```bash
git checkout main && git pull --ff-only
git checkout -b chore/my-change-$(date +%Y%m%d)
@@ -99,55 +206,258 @@ rm -rf .astro node_modules/.vite dist
npm run build
npm run test:anchors
npm run dev
```
### 5.2 Push + PR
### 7.2 Push + PR
```bash
git add -A
git commit -m "chore: my change"
git push -u origin chore/my-change-YYYYMMDD
# ouvrir PR dans Gitea
```
### 5.3 Déploiement NAS (résumé)
Puis ouvrir la PR dans Gitea.
Voir docs/runbooks/DEPLOY-BLUE-GREEN.md.
### 7.3 Déploiement NAS
## 6) Problèmes “classiques” + diagnostic rapide
### 6.1 “Le staging ne ressemble pas au local”
Voir :
# Comparer upstream direct 8081 vs 8082 :
```text
docs/runbooks/DEPLOY-BLUE-GREEN.md
```
---
## 8) Localhost auto-sync — ce quil faut retenir
Le localhost automatique sert à voir **la vérité de `main`**, pas à développer du neuf.
### 8.1 Scripts principaux
#### Script de sync
```text
~/ops-local/archicratie/auto-sync-localhost.sh
```
Rôle :
- fetch `origin/main`
- réaligner le worktree localhost
- lancer `npm ci` si besoin
- redéclencher lagent Astro si nécessaire
#### Script Astro
```text
~/ops-local/archicratie/run-astro-localhost.sh
```
Rôle :
- lancer `astro dev`
- depuis le bon worktree
- avec le bon runtime Node
- sur `127.0.0.1:4321`
> Oui : ce script est nécessaire.
> Il isole proprement le lancement du serveur Astro dans un contexte `launchd` stable.
### 8.2 LaunchAgents
#### Agent sync
```text
~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
```
#### Agent Astro
```text
~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
```
### 8.3 Document de référence
Pour tout le détail dexploitation du localhost automatique, lire :
```text
docs/OPS-LOCALHOST-AUTO-SYNC.md
```
---
## 9) Règle dor : il y a deux usages locaux distincts
### 9.1 Voir ce qui est réellement sur `main`
Utiliser :
```text
http://127.0.0.1:4321
```
Ce localhost doit être considéré comme :
**un miroir local exécutable de `origin/main`**
### 9.2 Développer / tester une nouvelle fonctionnalité
Utiliser le repo canonique :
```bash
cd /Volumes/FunIA/dev/archicratie-edition/site
npm run dev
```
Donc :
- **localhost auto-sync** = vérité de `main`
- **localhost de dev manuel** = expérimentation en cours
Il ne faut pas les confondre.
---
## 10) Ce quil ne faut pas faire
### 10.1 Ne pas développer dans le worktree localhost
Le worktree localhost est piloté automatiquement.
Il peut être :
- réaligné
- nettoyé
- redémarré
Donc :
- pas de commits dedans
- pas de dev feature dedans
- pas dexpérimentation de fond dedans
### 10.2 Ne pas utiliser le repo canonique comme miroir auto-sync
Sinon on mélange :
- espace de dev
- état publié
- serveur local permanent
### 10.3 Ne pas remettre les scripts ops sur un volume externe
Les scripts dops doivent rester sous `HOME`.
Le fait de les mettre sous `/Volumes/...` a déjà provoqué des erreurs du type :
```text
Operation not permitted
```
### 10.4 Ne pas supprimer `run-astro-localhost.sh`
Ce script fait partie de larchitecture actuelle.
Le supprimer reviendrait à réintroduire le flou entre sync Git et exécution dAstro.
---
## 11) Commandes de contrôle essentielles
### 11.1 État global
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
### 11.2 État Git
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
git -C ~/ops-local/archicratie/localhost-worktree branch --show-current
```
### 11.3 État LaunchAgents
```bash
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,160p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,160p'
```
### 11.4 État logs
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
tail -n 80 ~/Library/Logs/archicratie-localhost-sync.err.log
tail -n 80 ~/Library/Logs/archicratie-localhost-astro.err.log
```
### 11.5 État serveur
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
### 11.6 Vérification contenu
```bash
curl -s http://127.0.0.1:4321/archicrat-ia/prologue/ | grep -n "taxe Zucman"
```
---
## 12) Problèmes classiques + diagnostic
### 12.1 “Le staging ne ressemble pas au local”
Comparer les upstream directs :
```bash
curl -sS http://127.0.0.1:8081/ | head -n 2
curl -sS http://127.0.0.1:8082/ | head -n 2
```
# Vérifier quel routeur edge répond (header diag) :
Vérifier le routeur edge :
```bash
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router'
```
# Lire docs/runbooks/EDGE-TRAEFIK.md.
Voir :
### 6.2 Canonical incorrect (localhost en prod)
```text
docs/runbooks/EDGE-TRAEFIK.md
```
Cause racine : site dans Astro = PUBLIC_SITE non injecté au build.
### 12.2 Canonical incorrect
Fix canonique : voir docs/runbooks/ENV-PUBLIC_SITE.md.
Cause probable : `PUBLIC_SITE` mal injecté au build.
Test :
```bash
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -1
```
### 6.3 Contrat “anchors” en échec après migration dURL
Voir :
Quand on déplace des routes (ex: /archicratie/archicrat-ia/* → /archicrat-ia/*), le test dancres peut échouer même si les IDs nont pas changé, car les pages ont changé de chemin.
```text
docs/runbooks/ENV-PUBLIC_SITE.md
```
# Procédure safe :
### 12.3 Contrat anchors en échec après migration dURL
Backup baseline :
Procédure safe :
```bash
cp -a tests/anchors-baseline.json /tmp/anchors-baseline.json.bak.$(date +%F-%H%M%S)
Mettre à jour les clés (chemins) sans toucher aux IDs :
node - <<'NODE'
import fs from 'fs';
const p='tests/anchors-baseline.json';
@@ -161,16 +471,213 @@ fs.writeFileSync(p, JSON.stringify(out,null,2)+'\n');
console.log('updated keys:', Object.keys(j).length, '->', Object.keys(out).length);
NODE
Re-run :
npm run test:anchors
```
## 7) Ce que létape 9 doit faire (orientation)
### 12.4 “Le localhost auto-sync ne montre pas les dernières modifs”
Stabiliser le pipeline “tickets → YAML annotations”
Commande réflexe :
Formaliser la spec YAML + merge + anti-doublon (voir docs/EDITORIAL-ANNOTATIONS-SPEC.md)
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
Durcir lonboarding (ce START-HERE + runbooks)
Puis :
Éviter les régressions par tests (anchors / annotations / smoke)
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
```
Si les SHA diffèrent :
- le sync na pas tourné
- ou lagent sync a un problème
### 12.5 “Le SHA est bon mais le contenu web est faux”
Vérifier quel Astro écoute réellement :
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
Attendu :
- commande contenant `astro dev`
- cwd = `~/ops-local/archicratie/localhost-worktree`
### 12.6 Erreur `EBADENGINE`
Cause probable :
- Node 23 utilisé au lieu de Node 22
Résolution :
- forcer `node@22` dans les scripts et les LaunchAgents
### 12.7 Erreur `Operation not permitted`
Cause probable :
- scripts dops placés sous `/Volumes/...`
Résolution :
- garder les scripts sous :
```text
~/ops-local/archicratie
```
### 12.8 Erreur `EPERM` sur `astro.mjs`
Cause probable :
- ancien worktree sur volume externe
- ancien chemin résiduel
- Astro lancé depuis un mauvais emplacement
Résolution :
- worktree localhost sous :
```text
~/ops-local/archicratie/localhost-worktree
```
- scripts cohérents avec ce chemin
- réinstallation propre via :
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## 13) Redémarrage machine
Après reboot, le comportement attendu est :
1. le LaunchAgent sync se recharge
2. le LaunchAgent Astro se recharge
3. le worktree localhost est réaligné
4. Astro redémarre sur `127.0.0.1:4321`
### Vérification rapide après reboot
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
Si nécessaire :
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## 14) Procédure de secours manuelle
### Forcer un sync
```bash
~/ops-local/archicratie/auto-sync-localhost.sh
```
### Réinstaller proprement le dispositif local
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
### Diagnostic complet
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
---
## 15) Décision dexploitation finale
La politique retenue est la suivante :
- **repo canonique** = espace de développement
- **worktree localhost** = miroir automatique de `main`
- **ops sous HOME** = scripts, logs, automation
- **LaunchAgent sync** = réalignement Git
- **LaunchAgent Astro** = exécution stable du serveur local
- **Astro local** = lancé uniquement depuis le worktree localhost
Cette séparation rend le dispositif plus :
- lisible
- robuste
- opérable
- antifragile
---
## 16) Résumé opératoire
### Pour voir la vérité de `main`
Ouvrir :
```text
http://127.0.0.1:4321
```
Le serveur doit provenir de :
```text
/Users/s-funia/ops-local/archicratie/localhost-worktree
```
### Pour développer
Travailler dans :
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
avec les commandes habituelles.
### Pour réparer vite
```bash
~/ops-local/archicratie/doctor-localhost.sh
~/ops-local/archicratie/auto-sync-localhost.sh
```
---
## 17) Mémoire courte
Si un jour plus rien nest clair, repartir de ces commandes :
```bash
~/ops-local/archicratie/doctor-localhost.sh
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
lsof -nP -iTCP:4321 -sTCP:LISTEN
```
Puis lire :
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
```
---
## 18) Statut actuel visé
Quand tout fonctionne correctement :
- le worktree localhost pointe sur le même SHA que `origin/main`
- `astro dev` écoute sur `127.0.0.1:4321`
- son cwd est `~/ops-local/archicratie/localhost-worktree`
- le contenu servi correspond au contenu mergé sur `main`
Cest létat de référence à préserver.

View File

@@ -9,8 +9,9 @@ import { spawnSync } from "node:child_process";
*
* Conçu pour:
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
* - retrouver le bon paragraphe dans le .mdx
* - retrouver le bon paragraphe dans le .mdx/.md
* - remplacer proprement
* - ne JAMAIS toucher au frontmatter
* - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit)
@@ -137,28 +138,17 @@ function scoreText(candidate, targetText) {
let hit = 0;
for (const w of tgtSet) if (blkSet.has(w)) hit++;
// Bonus si un long préfixe ressemble
const tgtNorm = normalizeText(stripMd(targetText));
const blkNorm = normalizeText(stripMd(candidate));
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
// Ratio bonus (0..100)
const ratio = hit / Math.max(1, tgtSet.size);
const ratioBonus = Math.round(ratio * 100);
return prefixBonus + hit + ratioBonus;
}
function bestBlockMatchIndex(blocks, targetText) {
let best = { i: -1, score: -1 };
for (let i = 0; i < blocks.length; i++) {
const sc = scoreText(blocks[i], targetText);
if (sc > best.score) best = { i, score: sc };
}
return best;
}
function rankedBlockMatches(blocks, targetText, limit = 5) {
return blocks
.map((b, i) => ({
@@ -170,11 +160,6 @@ function rankedBlockMatches(blocks, targetText, limit = 5) {
.slice(0, limit);
}
function splitParagraphBlocks(mdxText) {
const raw = String(mdxText ?? "").replace(/\r\n/g, "\n");
return raw.split(/\n{2,}/);
}
function isLikelyExcerpt(s) {
const t = String(s || "").trim();
if (!t) return true;
@@ -184,6 +169,89 @@ function isLikelyExcerpt(s) {
return false;
}
/* --------------------------- frontmatter / structure ------------------------ */
function normalizeNewlines(s) {
return String(s ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
}
function splitMdxFrontmatter(src) {
const text = normalizeNewlines(src);
const m = text.match(/^---\n[\s\S]*?\n---\n?/);
if (!m) {
return {
hasFrontmatter: false,
frontmatter: "",
body: text,
};
}
const frontmatter = m[0];
const body = text.slice(frontmatter.length);
return {
hasFrontmatter: true,
frontmatter,
body,
};
}
function joinMdxFrontmatter(frontmatter, body) {
if (!frontmatter) return String(body ?? "");
return String(frontmatter) + String(body ?? "");
}
function assertFrontmatterIntegrity({ hadFrontmatter, originalFrontmatter, finalText, filePath }) {
if (!hadFrontmatter) return;
const text = normalizeNewlines(finalText);
if (!text.startsWith("---\n")) {
throw new Error(`Frontmatter perdu pendant la mise à jour de ${filePath}`);
}
if (!text.startsWith(originalFrontmatter)) {
throw new Error(`Frontmatter altéré pendant la mise à jour de ${filePath}`);
}
}
function splitParagraphBlocksPreserve(bodyText) {
const text = normalizeNewlines(bodyText);
if (!text) {
return { blocks: [], separators: [] };
}
const blocks = [];
const separators = [];
const re = /(\n{2,})/g;
let last = 0;
let m;
while ((m = re.exec(text))) {
blocks.push(text.slice(last, m.index));
separators.push(m[1]);
last = m.index + m[1].length;
}
blocks.push(text.slice(last));
return { blocks, separators };
}
function joinParagraphBlocksPreserve(blocks, separators) {
if (!Array.isArray(blocks) || blocks.length === 0) return "";
let out = "";
for (let i = 0; i < blocks.length; i++) {
out += blocks[i];
if (i < separators.length) out += separators[i];
}
return out;
}
/* ------------------------------ utils système ------------------------------ */
function run(cmd, args, opts = {}) {
@@ -263,7 +331,9 @@ function pickSection(body, markers) {
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter((x) => x.i >= 0)
.sort((a, b) => a.i - b.i)[0];
if (!idx) return "";
const start = idx.i + idx.m.length;
const tail = text.slice(start);
@@ -278,11 +348,13 @@ function pickSection(body, markers) {
"\n## Proposition",
"\n## Problème",
];
let end = tail.length;
for (const s of stops) {
const j = tail.toLowerCase().indexOf(s.toLowerCase());
if (j >= 0 && j < end) end = j;
}
return tail.slice(0, end).trim();
}
@@ -310,8 +382,6 @@ function extractAnchorIdAnywhere(text) {
function extractCheminFromAnyUrl(text) {
const s = String(text || "");
// Exemple: http://localhost:4321/archicratie/prologue/#p-3-xxxx
// ou: /archicratie/prologue/#p-3-xxxx
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : "";
}
@@ -412,7 +482,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
"User-Agent": "archicratie-apply-ticket/2.1",
},
});
if (!res.ok) {
@@ -428,7 +498,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.0",
"User-Agent": "archicratie-apply-ticket/2.1",
};
if (comment) {
@@ -437,7 +507,11 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
}
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
const res = await fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "closed" }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
@@ -541,10 +615,9 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`);
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
console.error("➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.");
process.exit(2);
}
@@ -565,7 +638,6 @@ async function main() {
ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1);
// fallback si ticket mal formé
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin);
@@ -604,7 +676,6 @@ async function main() {
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
await ensureBuildIfNeeded(distHtmlPath);
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel;
let distText = "";
@@ -621,18 +692,24 @@ async function main() {
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
}
const original = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original);
const originalRaw = await fs.readFile(contentFile, "utf-8");
const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw);
const split = splitParagraphBlocksPreserve(originalBody);
const blocks = split.blocks;
const separators = split.separators;
if (!blocks.length) {
throw new Error(`Aucun bloc éditorial exploitable dans ${path.relative(CWD, contentFile)}`);
}
const ranked = rankedBlockMatches(blocks, targetText, 5);
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
const runnerUp = ranked[1] || null;
// seuil absolu
if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
console.error("Top candidates:");
for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
@@ -640,7 +717,6 @@ async function main() {
process.exit(2);
}
// seuil relatif : si le 2e est trop proche du 1er, on refuse aussi
if (runnerUp) {
const ambiguityGap = best.score - runnerUp.score;
if (ambiguityGap < 15) {
@@ -659,7 +735,16 @@ async function main() {
const nextBlocks = blocks.slice();
nextBlocks[best.i] = afterBlock;
const updated = nextBlocks.join("\n\n");
const updatedBody = joinParagraphBlocksPreserve(nextBlocks, separators);
const updatedRaw = joinMdxFrontmatter(frontmatter, updatedBody);
assertFrontmatterIntegrity({
hadFrontmatter: hasFrontmatter,
originalFrontmatter: frontmatter,
finalText: updatedRaw,
filePath: path.relative(CWD, contentFile),
});
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
@@ -673,16 +758,15 @@ async function main() {
return;
}
// backup uniquement si on écrit
const relContentFile = path.relative(CWD, contentFile);
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
await fs.mkdir(path.dirname(bakPath), { recursive: true });
if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, original, "utf-8");
await fs.writeFile(bakPath, originalRaw, "utf-8");
}
await fs.writeFile(contentFile, updated, "utf-8");
await fs.writeFile(contentFile, updatedRaw, "utf-8");
console.log("✅ Applied.");
let aliasChanged = false;
@@ -703,13 +787,11 @@ async function main() {
if (aliasChanged) {
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
// MàJ dist sans rebuild complet (inject seulement)
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
} else {
console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
}
// garde-fous rapides
run("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD });
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
run("npm", ["run", "test:anchors"], { cwd: CWD });
@@ -741,7 +823,6 @@ async function main() {
return;
}
// mode manuel
console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log(
@@ -758,4 +839,4 @@ async function main() {
main().catch((e) => {
console.error("💥", e?.message || e);
process.exit(1);
});
});

View File

@@ -1 +1,9 @@
{}
{
"/archicrat-ia/prologue/": {
"p-0-d7974f88": "p-0-e729df02",
"p-17-b8c5bf21": "p-17-3deef56b",
"p-4-8ed4f807": "p-4-90b2a1cc",
"p-5-85126fa5": "p-5-d788c546",
"p-7-64a0ca9c": "p-7-4efdb1d4"
}
}

View File

@@ -12,7 +12,7 @@ source:
kind: docx
path: "sources/docx/archicrat-ia/Prologue—Archicratie-fondation_et_finalite_sociopolitique_et_historique-version_officielle.docx"
---
Nous vivons dans une époque saturée de diagnostics sur les formes de domination, les mutations du pouvoir, les détournements de la souveraineté. Depuis une vingtaine dannées, les appellations saccumulent : *démocratie illibérale*, *ploutocratie*, *happycratie*, *gouvernement algorithmique*, *démocrature*… À travers ces tentatives de nommer le désordre du présent, un fait se répète, de manière sourde : la scène politique semble désorientée. Les catégories héritées — *État*, *pouvoir*, *représentation*, *volonté générale*, *contrat social* — apparaissent de moins en moins capables de décrire ce qui nous gouverne effectivement.
Nous vivons une époque saturée de diagnostics sur les formes de domination, les mutations du pouvoir, les détournements de la souveraineté. Depuis une vingtaine dannées, les appellations saccumulent : démocratie illibérale, ploutocratie, happycratie, gouvernement algorithmique, démocrature… À travers ces tentatives de nommer le désordre du présent, un fait se répète, de manière sourde : la scène politique semble désorientée. Les catégories héritées — État, pouvoir, représentation, volonté générale, contrat social — apparaissent de moins en moins capables de décrire ce qui nous gouverne effectivement.
Cest cette perte de prise sur le réel que ce livre souhaite prendre au sérieux. Non pour lui ajouter un terme de plus au lexique fatigué des contre-pouvoirs ou des impuissances, mais pour repartir dun point plus fondamental, presque en-deçà de la question politique classique. Ce point, cest celui de la *tenue dun monde commun* — cest-à-dire la possibilité, pour des êtres dissemblables, vulnérables, inégaux, traversés de contradictions et situés dans des temporalités hétérogènes, de coexister sans sannihiler.
@@ -20,14 +20,13 @@ Cette tenue du monde néquivaut ni à la paix civile, ni à la stabilité des
Le terme nest pas trivial. Il ne sagit pas simplement dune viabilité partagée, ni dune coexistence pacifique, ni même dune durabilité écologique élargie. Il sagit dun état dynamique, instable, fragile, dans lequel un ensemble — une société, dun système biologique, dune formation historique, dun milieu technique ou dun monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*.
La *co-viabilité* ne désigne ni un état déquilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce quil parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces dinertie et dinnovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. Cest cette disposition active, faite de compromis fragiles et dajustements toujours révisables, que nous tenons pour première, et non dérivée.
La co-viabilité ne désigne ni un état déquilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce quil parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces dinertie et dinnovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. Cest cette disposition active, faite de compromis fragiles et dajustements toujours révisables, que nous tenons pour première.
Ce qui revient à dire que la question politique — au sens fort — na peut-être jamais été qui commande ? Mais bien plus : *Comment un ordre tient-il malgré ce qui le défait ?* *Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous leffet de ses propres contradictions ?* *Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ?*
Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (*Économie et société*, 1922) rappelait que ce qui fait tenir un ordre, ce nest pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (*La dynamique de lOccident*, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche sinscrit dans ce sillage : travailler cette interrogation sur les *conditions de viabilité dun monde commun*.
Ce qui revient à dire que la question politique — au sens fort — na peut-être jamais été qui commande ? Mais bien plus : Comment un ordre tient-il malgré ce qui le défait ? Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous leffet de ses propres contradictions ? Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ? Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (Économie et société, 1922) rappelait que ce qui fait tenir un ordre, ce nest pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (La dynamique de lOccident, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche sinscrit dans ce sillage : travailler cette interrogation sur les conditions de viabilité dun monde commun soumis à ses propres tensions constitutives.
Ce changement de perspective implique une rupture profonde dans la manière même de poser la question politique. Pendant des siècles, les sociétés ont pensé le politique à partir de principes transcendants — Dieu, Nature, Volonté générale, Pacte social. Ces principes, supposés extérieurs aux conflits du présent, garantissaient lordre en surplomb. Comme le rappelle Michel Foucault, il ny a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. Cest précisément cette exigence — trouver dans les relations elles-mêmes les ressources nécessaires pour maintenir des mondes vivables — qui définit notre époque.
Ce qui émerge nest pas de nouveaux principes, ni une nouvelle idéologie, mais une exigence beaucoup plus modeste, mais aussi beaucoup plus difficile à satisfaire : celle de trouver dans les relations elles-mêmes — entre groupes, entre institutions, entre individus, entre temporalités — les ressources nécessaires pour maintenir leurs mondes viables. Autrement dit : cest *dans* les tensions, *à même* les conflits, *au sein* des alliances, *au cœur* des désaccords et des polémiques, que semble se construire la régulation. Non plus *au-dessus*, par un décret transcendant, mais *au-dedans*, par un agencement toujours révisable. Cest cela que nous voulons dire — sans technicité inutile — quand nous parlons dun déplacement vers une *instance de régulation située de co-viabilité* : un espace commun où les forces hétérogènes, souvent antagonistes, peuvent coexister, se contredire, se confronter, séprouver, sans se détruire mutuellement.
Ce qui émerge nest pas de nouveaux principes, ni une nouvelle idéologie, mais une exigence beaucoup plus modeste, mais aussi beaucoup plus difficile à satisfaire : celle de trouver dans les relations elles-mêmes — entre groupes, entre institutions, entre individus, entre temporalités — les ressources nécessaires pour maintenir leurs mondes viables. Autrement dit : cest dans les tensions, à même les conflits, au sein des alliances, au cœur des désaccords et des polémiques, que semble se construire la régulation. Non plus au-dessus, par un décret transcendant, mais au-dedans, par un agencement toujours révisable. Cest cela que nous voulons dire — sans technicité inutile — quand nous parlons dun déplacement vers une instance de régulation située de co-viabilité : un espace commun où les forces hétérogènes, souvent antagonistes, peuvent coexister, se contredire, se confronter, séprouver, sans pour autant systématiquement se détruire mutuellement.
Penser le politique depuis cette approche, cest renoncer à lidée même quun ordre puisse se fonder définitivement, une fois pour toutes. Cest reconnaître que ce qui fait tenir une société nest jamais un principe unique, un commandement souverain, une légitimité première, mais *un espace dépreuve toujours rejoué* où se négocient, se recadrent, sopposent, sajustent des forces hétérogènes dont laccord est constamment partiel, toujours temporaire, perpétuellement instable.
@@ -47,7 +46,7 @@ Cela ne veut pas dire que le politique ait disparu, mais plutôt quil tend pe
Cest un marché carbone qui, au nom de seuils agrégés à léchelle continentale, conduit à la fermeture dun site industriel local, sans quaucune figure politique ne puisse rendre visible ni opposable larbitrage opéré. Cest un algorithme de régulation hospitalière qui, face à une tension budgétaire ou épidémiologique, déprogramme automatiquement des interventions chirurgicales — sans quaucun médecin, aucun patient, aucun responsable politique ne puisse véritablement en discuter les critères. Cest une plateforme numérique de traitement des titres de séjour qui suspend une demande pour “anomalie de saisie”, sans contact humain, sans justification claire, sans voie de recours instituée. Cest un logiciel de pilotage budgétaire, adossé à des indicateurs defficience, qui impose la réduction dune politique sociale sans passage par une arène délibérative. Cest aussi un score algorithmique de risque bancaire qui écarte discrètement une famille dun prêt, bien avant quelle ait pu formuler son projet.
Contrairement aux apparences, ce qui soffre au regard nest plus la figure massive du pouvoir trônant dans la clarté de ses apparats, mais la trame patiente dune régulation en mouvement. Disparues, les instances fixes ; effacée, la demeure solennelle de lautorité. Le réel geste de gouvernance sinsinue insidieusement dans des protocoles, se glisse sournoisement dans la routine, sentrelace irrémédiablement dans les habitudes, se ramifie inextricablement dans dinnombrables appareils sans visage. Nul acte inaugural nen marque ostensiblement la naissance, nulle proclamation nen scande les rythmes. On constate seulement que la régulation avance sans fracas, tisse patiemment la toile discrète sur laquelle se déplacent nos vies. Ce nest plus tant le décret ni la loi qui pèsent,bien plus les enchevêtrements de normes, limperceptible maillage de procédures et lajustement continu de directives flexibles.
Contrairement aux apparences, ce qui soffre au regard nest plus la figure massive du pouvoir trônant dans la clarté de ses apparats, mais la trame patiente dune régulation en mouvement. Disparues, les instances fixes ; effacée, la demeure solennelle de lautorité. Le réel geste de gouvernance sinsinue dans des protocoles, se glisse sournoisement dans la routine, sentrelace irrémédiablement dans les habitudes, se ramifie inextricablement dans dinnombrables appareils sans visage. Nul acte inaugural nen marque ostensiblement la naissance, nulle proclamation nen scande les rythmes. On constate seulement que la régulation avance sans fracas, tisse patiemment la toile discrète sur laquelle se déplacent nos vies. Ce nest plus tant le décret ni la loi qui pèsent, bien plus les enchevêtrements de normes, limperceptible maillage de procédures et lajustement continu de directives flexibles.
La contrainte naccable plus par lostentation de lordre, mais sinocule par la subtilité des systèmes. Ainsi, il sagit désormais de façonner, par lagencement soigné déquilibres, de données, de flux, où chacun se trouve relié, indexé, impliqué à même cette dentelle administrative, sans jamais croiser le centre, sans jamais savoir nommer celui ou ce qui agit. La régulation moderne tresse ainsi un univers de seuils mobiles et dagencements souples, où lon ne peut jamais tout à fait fixer le moment ni le lieu du pouvoir agissant — mais où, à chaque pli de la vie collective, se lit lempreinte dune architecture invisible.