Compare commits

...

28 Commits

Author SHA1 Message Date
497bddd05d ci: fix anno apply/reject JSON parsing + hard gates
All checks were successful
SMOKE / smoke (push) Successful in 8s
CI / build-and-anchors (push) Successful in 48s
CI / build-and-anchors (pull_request) Successful in 40s
2026-03-02 12:47:37 +01:00
7c8e49c1a9 ci: stabilize anno apply/reject (event parsing + strict gating)
Some checks failed
CI / build-and-anchors (push) Successful in 52s
CI / build-and-anchors (pull_request) Successful in 41s
SMOKE / smoke (push) Failing after 12m55s
2026-03-02 11:10:53 +01:00
901d28b89b Merge pull request 'deploy: pin nas-deploy image by digest' (#159) from chore/pin-nas-deploy-image-digest into main
All checks were successful
SMOKE / smoke (push) Successful in 12s
CI / build-and-anchors (push) Successful in 36s
Deploy staging+live (annotations) / deploy (push) Successful in 45s
Reviewed-on: #159
2026-02-28 20:24:46 +01:00
43e2862c89 deploy: pin nas-deploy image by digest
All checks were successful
SMOKE / smoke (push) Successful in 6s
CI / build-and-anchors (push) Successful in 39s
CI / build-and-anchors (pull_request) Successful in 37s
2026-02-28 20:22:17 +01:00
73fb38c4d1 Merge pull request 'deploy: use prebaked nas-deploy image; remove apt-get step' (#158) from chore/deploy-use-prebaked-image into main
All checks were successful
SMOKE / smoke (push) Successful in 9s
CI / build-and-anchors (push) Successful in 36s
Deploy staging+live (annotations) / deploy (push) Successful in 39s
Reviewed-on: #158
2026-02-28 19:51:27 +01:00
a81d206aba deploy: use prebaked nas-deploy image; remove apt-get step
All checks were successful
SMOKE / smoke (push) Successful in 3s
CI / build-and-anchors (push) Successful in 39s
CI / build-and-anchors (pull_request) Successful in 36s
2026-02-28 19:49:25 +01:00
9801ea3cea Merge pull request 'ci: lock deploy workflow to nas-deploy runner' (#157) from chore/lock-nas-deploy into main
All checks were successful
SMOKE / smoke (push) Successful in 16s
CI / build-and-anchors (push) Successful in 44s
Deploy staging+live (annotations) / deploy (push) Successful in 3m40s
Reviewed-on: #157
2026-02-28 17:45:35 +01:00
c11189fe11 ci: lock deploy workflow to nas-deploy runner
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 40s
2026-02-28 17:43:19 +01:00
b47edb24cf Merge pull request 'ci: fix YAML newlines after runs-on (mac-ci)' (#156) from chore/fix-yaml-runs-on-newlines into main
Some checks failed
CI / build-and-anchors (push) Successful in 41s
SMOKE / smoke (push) Successful in 3s
Deploy staging+live (annotations) / deploy (push) Has been cancelled
Reviewed-on: #156
2026-02-28 15:54:48 +01:00
be191b09a0 ci: fix YAML newlines after runs-on (mac-ci)
All checks were successful
SMOKE / smoke (push) Successful in 25s
CI / build-and-anchors (push) Successful in 1m6s
CI / build-and-anchors (pull_request) Successful in 37s
2026-02-28 15:50:48 +01:00
e06587478d Merge pull request 'ci: route CI/bots to mac runner; keep deploy on NAS' (#155) from chore/route-ci-to-mac-runner into main
All checks were successful
Deploy staging+live (annotations) / deploy (push) Successful in 1m58s
Reviewed-on: #155
2026-02-28 15:29:35 +01:00
402ffb04cd ci: route CI/bots to mac runner; keep deploy on NAS 2026-02-28 15:28:49 +01:00
1cbfc02670 Merge pull request 'ci: harden anno-reject (dispatch + conflict guard) and keep deploy concurrency safe' (#153) from chore/fix-anno-reject-close-guard into main
All checks were successful
CI / build-and-anchors (push) Successful in 2m49s
Deploy staging+live (annotations) / deploy (push) Successful in 3m7s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #153
2026-02-28 10:05:26 +01:00
28d2fbbd2f ci: harden anno-reject (dispatch + conflict guard) and keep deploy concurrency safe
All checks were successful
CI / build-and-anchors (push) Successful in 2m21s
SMOKE / smoke (push) Successful in 19s
2026-02-28 09:55:37 +01:00
225368a952 Merge pull request 'ci: anno-apply gate on supported types (skip proposer)' (#149) from chore/fix-anno-apply-gate-types into main
All checks were successful
Deploy staging+live (annotations) / deploy (push) Successful in 2m12s
CI / build-and-anchors (push) Successful in 1m45s
SMOKE / smoke (push) Successful in 17s
Reviewed-on: #149
2026-02-27 20:06:57 +01:00
3574695041 ci: anno-apply gate on supported types (skip proposer)
All checks were successful
CI / build-and-anchors (push) Successful in 1m52s
SMOKE / smoke (push) Successful in 15s
2026-02-27 20:03:16 +01:00
ea68025a1d Merge pull request 'anno: apply ticket #144' (#148) from bot/anno-144-20260227-124313 into main
All checks were successful
CI / build-and-anchors (push) Successful in 2m5s
Deploy staging+live (annotations) / deploy (push) Successful in 2m8s
SMOKE / smoke (push) Successful in 13s
Reviewed-on: #148
2026-02-27 15:49:11 +01:00
3a08698003 Merge pull request 'anno: apply ticket #143' (#147) from bot/anno-143-20260227-124037 into main
Some checks failed
CI / build-and-anchors (push) Has been cancelled
Deploy staging+live (annotations) / deploy (push) Has been cancelled
SMOKE / smoke (push) Has been cancelled
Reviewed-on: #147
2026-02-27 15:48:28 +01:00
3d583608c2 Merge pull request 'anno: apply ticket #142' (#146) from bot/anno-142-20260227-123430 into main
Some checks failed
CI / build-and-anchors (push) Has been cancelled
Deploy staging+live (annotations) / deploy (push) Has been cancelled
SMOKE / smoke (push) Successful in 21s
Reviewed-on: #146
2026-02-27 15:47:44 +01:00
archicratie-bot
01ae95ab43 anno: apply ticket #144 (archicrat-ia/chapitre-3#p-0-ace27175 type/media)
All checks were successful
CI / build-and-anchors (push) Successful in 2m0s
SMOKE / smoke (push) Successful in 18s
2026-02-27 12:43:16 +00:00
archicratie-bot
0d5821c640 anno: apply ticket #143 (archicrat-ia/chapitre-1#p-1-8a6c18bf type/comment)
All checks were successful
CI / build-and-anchors (push) Successful in 1m54s
SMOKE / smoke (push) Successful in 16s
2026-02-27 12:40:39 +00:00
archicratie-bot
2bcea39558 anno: apply ticket #142 (archicrat-ia/chapitre-1#p-0-8d27a7f5 type/reference)
All checks were successful
CI / build-and-anchors (push) Successful in 1m53s
SMOKE / smoke (push) Successful in 14s
2026-02-27 12:34:32 +00:00
af85970d4a Merge pull request 'chore/fix-build-annotations-index-shards' (#141) from chore/fix-build-annotations-index-shards into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m45s
Deploy staging+live (annotations) / deploy (push) Successful in 1m53s
SMOKE / smoke (push) Successful in 13s
Reviewed-on: #141
2026-02-27 13:21:43 +01:00
210f621487 ci: support shard annotations in checks + endpoint (pageKey inference)
All checks were successful
CI / build-and-anchors (push) Successful in 1m58s
SMOKE / smoke (push) Successful in 13s
2026-02-27 13:13:31 +01:00
8ad960dc69 anno: build-annotations-index supports shard annotations
Some checks failed
SMOKE / smoke (push) Successful in 16s
CI / build-and-anchors (push) Failing after 1m48s
2026-02-27 12:27:35 +01:00
41b0517c6c Merge pull request 'ci: deploy hotpatch-only + full rebuild warmup' (#139) from chore/fix-deploy-hotpatch-stable into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m50s
Deploy staging+live (annotations) / deploy (push) Successful in 2m12s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #139
2026-02-26 22:00:20 +01:00
d40f24e92d Merge pull request 'ci: fix hotpatch (yaml datetime -> json safe)' (#138) from chore/fix-hotpatch-json into main
Some checks failed
CI / build-and-anchors (push) Successful in 1m54s
Deploy staging+live (annotations) / deploy (push) Failing after 5m35s
SMOKE / smoke (push) Successful in 16s
Reviewed-on: #138
2026-02-26 21:32:56 +01:00
a5d68d6a7e Merge pull request 'ci: fix deploy workflow (warmup + hotpatch)' (#137) from chore/fix-deploy-warmup into main
Some checks failed
CI / build-and-anchors (push) Successful in 1m38s
Deploy staging+live (annotations) / deploy (push) Failing after 8m16s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #137
2026-02-26 21:09:21 +01:00
14 changed files with 584 additions and 195 deletions

View File

@@ -16,9 +16,13 @@ defaults:
run: run:
shell: bash shell: bash
concurrency:
group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true
jobs: jobs:
apply-approved: apply-approved:
runs-on: ubuntu-latest runs-on: mac-ci
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -29,7 +33,6 @@ jobs:
git --version git --version
node --version node --version
npm --version npm --version
npm ping --registry=https://registry.npmjs.org
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch)
env: env:
@@ -49,18 +52,15 @@ jobs:
const cloneUrl = const cloneUrl =
repoObj?.clone_url || repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json"); if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
let owner = let owner =
repoObj?.owner?.login || repoObj?.owner?.login ||
repoObj?.owner?.username || repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : ""); (repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo = let repo =
repoObj?.name || repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : ""); (repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) { if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/); 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; }
@@ -73,15 +73,15 @@ jobs:
ev?.issue?.number || ev?.issue?.number ||
ev?.issue?.index || ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
if (!issueNumber || !Number.isFinite(Number(issueNumber))) { if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
throw new Error("No issue number in event.json or workflow_dispatch input"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
// label triggered (best effort; may be missing depending on Gitea payload)
const labelName = const labelName =
ev?.label?.name || ev?.label?.name ||
ev?.label || (typeof ev?.label === "string" ? ev.label : "") ||
"workflow_dispatch"; "";
const u = new URL(cloneUrl); const u = new URL(cloneUrl);
const origin = u.origin; const origin = u.origin;
@@ -90,7 +90,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"") ? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin; : origin;
function sh(s){ return JSON.stringify(String(s)); } function sh(s){ return JSON.stringify(String(s ?? "")); }
process.stdout.write([ process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`, `CLONE_URL=${sh(cloneUrl)}`,
@@ -99,23 +99,144 @@ jobs:
`DEFAULT_BRANCH=${sh(defaultBranch)}`, `DEFAULT_BRANCH=${sh(defaultBranch)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`, `ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`, `LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(apiBase)}` `API_BASE=${sh(apiBase)}`,
// init safe defaults (avoid "unbound variable" cascades)
`SKIP=0`,
`SKIP_REASON=${sh("")}`,
`ISSUE_TYPE=${sh("")}`,
`ISSUE_TITLE=${sh("")}`,
`APPLY_RC=${sh("")}`,
`NOOP=0`,
`BRANCH=${sh("")}`,
`END_SHA=${sh("")}`
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
echo "✅ context:" echo "✅ context:"
sed -n '1,120p' /tmp/anno.env sed -n '1,160p' /tmp/anno.env
- name: Gate on label state/approved - name: Gate fast (only if label is state/approved or workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip" # workflow_dispatch => allow
echo "SKIP=1" >> /tmp/anno.env if [[ -n "${INPUT_ISSUE:-}" ]]; then
echo "✅ workflow_dispatch => proceed"
exit 0 exit 0
fi fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
# if payload provides the triggering label, we can skip without API call
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
echo " triggering label='$LABEL_NAME' (not state/approved) => skip"
echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"trigger_label_not_approved\"" >> /tmp/anno.env
exit 0
fi
echo " label unknown or approved => continue to API gating"
- name: Fetch issue + gate on state/approved + gate on Type (skip Proposer)
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
> /tmp/issue.json
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 body = String(issue.body || "").replace(/\r\n/g, "\n");
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l?.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) {
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = body.match(re);
return m ? m[1].trim() : "";
}
const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase();
const allowed = new Set(["type/media","type/reference","type/comment"]);
const proposer = new Set(["type/correction","type/fact-check"]);
const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
// main gate: only act if state/approved is actually present on the issue
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("no_state_approved")}`);
} else if (!type) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowed.has(type)) {
// proceed
} else if (proposer.has(type)) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
} else {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
}
process.stdout.write(out.join("\n") + "\n");
NODE
echo "✅ issue gating:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
- name: Comment issue if skipped (only when state/approved was present)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${SKIP_REASON:-}" != "no_state_approved" ]] || exit 0 # do not comment on normal label churn
test -n "${FORGE_TOKEN:-}" || exit 0
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** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer.\n\n✅ Action : traitement éditorial manuel."
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.\n✅ Action : traitement manuel si nécessaire."
else
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (Type: type/media|type/reference|type/comment) ou traiter manuellement."
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
- name: Checkout default branch - name: Checkout default branch
run: | run: |
@@ -135,7 +256,7 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm ci npm ci --no-audit --no-fund
- name: Check apply script exists - name: Check apply script exists
run: | run: |
@@ -154,7 +275,7 @@ jobs:
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm run build:clean npm run build
test -f dist/para-index.json || { test -f dist/para-index.json || {
echo "❌ missing dist/para-index.json after build" echo "❌ missing dist/para-index.json after build"
@@ -182,7 +303,7 @@ jobs:
START_SHA="$(git rev-parse HEAD)" START_SHA="$(git rev-parse HEAD)"
TS="$(date -u +%Y%m%d-%H%M%S)" TS="$(date -u +%Y%m%d-%H%M%S)"
BR="bot/anno-${ISSUE_NUMBER}-${TS}" BR="bot/anno-${ISSUE_NUMBER}-${TS}"
echo "BRANCH=$BR" >> /tmp/anno.env echo "BRANCH=\"$BR\"" >> /tmp/anno.env
git checkout -b "$BR" git checkout -b "$BR"
export FORGE_API="$API_BASE" export FORGE_API="$API_BASE"
@@ -195,12 +316,13 @@ jobs:
RC=$? RC=$?
set -e set -e
echo "APPLY_RC=$RC" >> /tmp/anno.env echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env
echo "== apply log (tail) ==" echo "== apply log (tail) =="
tail -n 180 "$LOG" || true tail -n 180 "$LOG" || true
END_SHA="$(git rev-parse HEAD)" END_SHA="$(git rev-parse HEAD)"
echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env
if [[ "$RC" -ne 0 ]]; then if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/anno.env echo "NOOP=0" >> /tmp/anno.env
@@ -211,7 +333,6 @@ jobs:
echo "NOOP=1" >> /tmp/anno.env echo "NOOP=1" >> /tmp/anno.env
else else
echo "NOOP=0" >> /tmp/anno.env echo "NOOP=0" >> /tmp/anno.env
echo "END_SHA=$END_SHA" >> /tmp/anno.env
fi fi
- name: Comment issue on failure (strict/verify/etc) - name: Comment issue on failure (strict/verify/etc)
@@ -220,18 +341,20 @@ jobs:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-}"
if [[ "$RC" == "0" ]]; then [[ -n "$RC" ]] || { echo " apply not executed"; exit 0; }
echo " no failure detected" [[ "$RC" != "0" ]] || { echo " no failure detected"; exit 0; }
exit 0
if [[ -f /tmp/apply.log ]]; then
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
else
BODY="(no apply log found)"
fi fi
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" 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")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \ curl -fsS -X POST \
@@ -246,10 +369,11 @@ jobs:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0 RC="${APPLY_RC:-}"
[[ "$RC" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "1" ]] || exit 0 [[ "${NOOP:-0}" == "1" ]] || exit 0
MSG=" Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)." MSG=" Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
@@ -267,11 +391,12 @@ jobs:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; } [[ "${APPLY_RC:-}" == "0" ]] || { echo " apply not ok -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " missing BRANCH -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e ' AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1); const [clone, tok] = process.argv.slice(1);
@@ -290,11 +415,13 @@ jobs:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; } [[ "${APPLY_RC:-}" == "0" ]] || { echo " apply not ok -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " missing BRANCH -> skip PR"; exit 0; }
[[ -n "${END_SHA:-}" ]] || { echo " missing END_SHA -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}" PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK." PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
@@ -333,9 +460,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-}"
[[ -n "$RC" ]] || { echo "❌ apply did not run"; exit 2; }
if [[ "$RC" != "0" ]]; then if [[ "$RC" != "0" ]]; then
echo "❌ apply failed (rc=$RC)" echo "❌ apply failed (rc=$RC)"
exit "$RC" exit "$RC"

View File

@@ -1,8 +1,13 @@
name: Anno Reject name: Anno Reject (close issue)
on: on:
issues: issues:
types: [labeled] types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to reject/close"
required: true
env: env:
NODE_OPTIONS: --dns-result-order=ipv4first NODE_OPTIONS: --dns-result-order=ipv4first
@@ -11,14 +16,26 @@ defaults:
run: run:
shell: bash shell: bash
concurrency:
group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true
jobs: jobs:
reject: reject:
runs-on: ubuntu-latest runs-on: mac-ci
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps: steps:
- name: Derive context - name: Tools sanity
run: |
set -euo pipefail
node --version
- name: Derive context (event.json / workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" export EVENT_JSON="/var/run/act/workflow/event.json"
@@ -29,58 +46,129 @@ jobs:
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8")); const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {}; const repoObj = ev?.repository || {};
const cloneUrl = const cloneUrl =
repoObj?.clone_url || repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository url");
let owner = let owner =
repoObj?.owner?.login || repoObj?.owner?.login ||
repoObj?.owner?.username || repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : ""); (repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo = let repo =
repoObj?.name || repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : ""); (repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) { if ((!owner || !repo) && cloneUrl) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/); 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"); if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const issueNumber = ev?.issue?.number || ev?.issue?.index; const issueNumber =
if (!issueNumber) throw new Error("No issue number"); ev?.issue?.number ||
ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
const labelName = ev?.label?.name || ev?.label || ""; if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
const u = new URL(cloneUrl); throw new Error("No issue number in event.json or workflow_dispatch input");
}
const labelName =
ev?.label?.name ||
(typeof ev?.label === "string" ? ev.label : "") ||
"";
let apiBase = "";
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
apiBase = String(process.env.FORGE_API).trim().replace(/\/+$/,"");
} else if (cloneUrl) {
apiBase = new URL(cloneUrl).origin;
} else {
apiBase = "";
}
function sh(s){ return JSON.stringify(String(s ?? "")); }
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([ process.stdout.write([
`OWNER=${sh(owner)}`, `OWNER=${sh(owner)}`,
`REPO=${sh(repo)}`, `REPO=${sh(repo)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`, `ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`, `LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(u.origin)}` `API_BASE=${sh(apiBase)}`,
`SKIP=0`
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
- name: Gate on label state/rejected echo "✅ context:"
sed -n '1,160p' /tmp/reject.env
- name: Gate fast (only if label is state/rejected or workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/reject.env source /tmp/reject.env
if [[ "$LABEL_NAME" != "state/rejected" ]]; then
echo " label=$LABEL_NAME => skip" if [[ -n "${INPUT_ISSUE:-}" ]]; then
echo "✅ workflow_dispatch => proceed"
exit 0 exit 0
fi fi
echo "✅ reject issue=$ISSUE_NUMBER"
- name: Comment + close issue if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/rejected" ]]; then
echo " triggering label='$LABEL_NAME' (not state/rejected) => skip"
echo "SKIP=1" >> /tmp/reject.env
exit 0
fi
echo " label unknown or rejected => continue to API gating"
- name: Comment + close (only if issue has state/rejected; conflict-guard approved+rejected)
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/reject.env source /tmp/reject.env
[[ "${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; }
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
> /tmp/issue.json
node --input-type=module - <<'NODE' > /tmp/reject.flags
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected");
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
NODE
source /tmp/reject.flags
# If issue does not actually have state/rejected -> do nothing (normal label churn)
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
echo " issue has no state/rejected => skip"
exit 0
fi
if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then
MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
echo " conflict => stop"
exit 0
fi
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)." MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
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")"
@@ -96,3 +184,5 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
--data-binary '{"state":"closed"}' --data-binary '{"state":"closed"}'
echo "✅ rejected+closed"

View File

@@ -6,7 +6,7 @@ on:
jobs: jobs:
label: label:
runs-on: ubuntu-latest runs-on: mac-ci
steps: steps:
- name: Apply labels from Type/State/Category - name: Apply labels from Type/State/Category
env: env:

View File

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

View File

@@ -26,9 +26,9 @@ concurrency:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: nas-deploy
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6
steps: steps:
- name: Tools sanity - name: Tools sanity
@@ -127,25 +127,17 @@ jobs:
echo " no annotations/media change -> skip deploy" echo " no annotations/media change -> skip deploy"
fi fi
- name: Install docker client + docker compose plugin (v2) + python yaml - name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/deploy.env source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; } [[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update # tools are prebaked in the image
apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml git --version
rm -rf /var/lib/apt/lists/*
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker version docker version
docker compose version docker compose version
python3 --version python3 -c 'import yaml; print("PyYAML OK")'
# Reuse existing compose project name if containers already exist # Reuse existing compose project name if containers already exist
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)" PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -1,28 +1,106 @@
#!/usr/bin/env node
// scripts/build-annotations-index.mjs // scripts/build-annotations-index.mjs
// Construit dist/annotations-index.json à partir de src/annotations/**/*.yml
// Supporte:
// - monolith : src/annotations/<pageKey>.yml
// - shard : src/annotations/<pageKey>/<paraId>.yml (paraId = p-<n>-...)
// Invariants:
// - doc.schema === 1
// - doc.page (si présent) == pageKey déduit du chemin
// - shard: doc.paras doit contenir EXACTEMENT la clé paraId (sinon fail)
//
// Deep-merge non destructif (media/refs/comments dédupliqués), tri stable.
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import YAML from "yaml"; import YAML from "yaml";
function parseArgs(argv) { const ROOT = process.cwd();
const out = { const ANNO_ROOT = path.join(ROOT, "src", "annotations");
inDir: "src/annotations", const DIST_DIR = path.join(ROOT, "dist");
outFile: "dist/annotations-index.json", const OUT = path.join(DIST_DIR, "annotations-index.json");
};
for (let i = 0; i < argv.length; i++) { function assert(cond, msg) {
const a = argv[i]; if (!cond) throw new Error(msg);
}
if (a === "--in" && argv[i + 1]) out.inDir = argv[++i]; function isObj(x) {
else if (a.startsWith("--in=")) out.inDir = a.slice("--in=".length); return !!x && typeof x === "object" && !Array.isArray(x);
}
function isArr(x) {
return Array.isArray(x);
}
if (a === "--out" && argv[i + 1]) out.outFile = argv[++i]; function normPath(s) {
else if (a.startsWith("--out=")) out.outFile = a.slice("--out=".length); return String(s || "")
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "");
}
function paraNum(pid) {
const m = String(pid).match(/^p-(\d+)-/i);
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
}
function stableSortByTs(arr) {
if (!Array.isArray(arr)) return;
arr.sort((a, b) => {
const ta = Date.parse(a?.ts || "") || 0;
const tb = Date.parse(b?.ts || "") || 0;
if (ta !== tb) return ta - tb;
return JSON.stringify(a).localeCompare(JSON.stringify(b));
});
}
function keyMedia(x) { return String(x?.src || ""); }
function keyRef(x) {
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
}
function keyComment(x) { return String(x?.text || "").trim(); }
function uniqUnion(dst, src, keyFn) {
const out = isArr(dst) ? [...dst] : [];
const seen = new Set(out.map((x) => keyFn(x)));
for (const it of (isArr(src) ? src : [])) {
const k = keyFn(it);
if (!k) continue;
if (!seen.has(k)) {
seen.add(k);
out.push(it);
}
} }
return out; return out;
} }
async function exists(p) { function deepMergeEntry(dst, src) {
try { await fs.access(p); return true; } catch { return false; } if (!isObj(dst) || !isObj(src)) return;
for (const [k, v] of Object.entries(src)) {
if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; }
if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; }
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
if (isObj(v)) {
if (!isObj(dst[k])) dst[k] = {};
deepMergeEntry(dst[k], v);
continue;
}
if (isArr(v)) {
const cur = isArr(dst[k]) ? dst[k] : [];
const seen = new Set(cur.map((x) => JSON.stringify(x)));
const out = [...cur];
for (const it of v) {
const s = JSON.stringify(it);
if (!seen.has(s)) { seen.add(s); out.push(it); }
}
dst[k] = out;
continue;
}
// scalar: set only if missing/empty
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
}
} }
async function walk(dir) { async function walk(dir) {
@@ -30,111 +108,116 @@ async function walk(dir) {
const ents = await fs.readdir(dir, { withFileTypes: true }); const ents = await fs.readdir(dir, { withFileTypes: true });
for (const e of ents) { for (const e of ents) {
const p = path.join(dir, e.name); const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(p))); if (e.isDirectory()) out.push(...await walk(p));
else out.push(p); else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
} }
return out; return out;
} }
function inferPageKeyFromFile(inDirAbs, fileAbs) { function inferExpectedFromRel(relNoExt) {
// src/annotations/<page>.yml -> "<page>" const parts = relNoExt.split("/").filter(Boolean);
const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/"); const last = parts.at(-1) || "";
return rel.replace(/\.(ya?ml|json)$/i, ""); const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
const paraId = isShard ? last : null;
return { isShard, pageKey, paraId };
} }
function assert(cond, msg) { function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId) {
if (!cond) throw new Error(msg); assert(isObj(doc), `${relFile}: doc must be an object`);
} assert(doc.schema === 1, `${relFile}: schema must be 1`);
assert(isObj(doc.paras), `${relFile}: missing object key "paras"`);
function isPlainObject(x) { const gotPage = doc.page != null ? normPath(doc.page) : "";
return !!x && typeof x === "object" && !Array.isArray(x); const expPage = normPath(expectedPageKey);
}
function normalizePageKey(s) { if (gotPage) {
// pas de / en tête/fin
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
}
function validateAndNormalizeDoc(doc, pageKey, fileRel) {
assert(isPlainObject(doc), `${fileRel}: document must be an object`);
assert(doc.schema === 1, `${fileRel}: schema must be 1`);
if (doc.page != null) {
assert( assert(
normalizePageKey(doc.page) === pageKey, gotPage === expPage,
`${fileRel}: page mismatch (page="${doc.page}" vs path="${pageKey}")` `${relFile}: page mismatch (page="${doc.page}" vs path="${expectedPageKey}")`
);
} else {
doc.page = expPage;
}
if (expectedParaId) {
const keys = Object.keys(doc.paras || {}).map(String);
assert(
keys.includes(expectedParaId),
`${relFile}: shard mismatch: must contain paras["${expectedParaId}"]`
);
assert(
keys.length === 1 && keys[0] === expectedParaId,
`${relFile}: shard invariant violated: shard file must contain ONLY paras["${expectedParaId}"] (got: ${keys.join(", ")})`
); );
} }
assert(isPlainObject(doc.paras), `${fileRel}: missing object key "paras"`);
const parasOut = Object.create(null); return doc;
for (const [paraId, entry] of Object.entries(doc.paras)) {
assert(/^p-\d+-/i.test(paraId), `${fileRel}: invalid para id "${paraId}"`);
// entry peut être vide, mais doit être un objet si présent
assert(entry == null || isPlainObject(entry), `${fileRel}: paras.${paraId} must be an object`);
const e = entry ? { ...entry } : {};
// Sanity checks (non destructifs : on nécrase pas, on vérifie juste les types)
if (e.refs != null) assert(Array.isArray(e.refs), `${fileRel}: paras.${paraId}.refs must be an array`);
if (e.authors != null) assert(Array.isArray(e.authors), `${fileRel}: paras.${paraId}.authors must be an array`);
if (e.quotes != null) assert(Array.isArray(e.quotes), `${fileRel}: paras.${paraId}.quotes must be an array`);
if (e.media != null) assert(Array.isArray(e.media), `${fileRel}: paras.${paraId}.media must be an array`);
if (e.comments_editorial != null) assert(Array.isArray(e.comments_editorial), `${fileRel}: paras.${paraId}.comments_editorial must be an array`);
parasOut[paraId] = e;
}
return parasOut;
}
async function readDoc(fileAbs) {
const raw = await fs.readFile(fileAbs, "utf8");
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
return YAML.parse(raw);
} }
async function main() { async function main() {
const { inDir, outFile } = parseArgs(process.argv.slice(2)); const pages = {};
const CWD = process.cwd(); const errors = [];
const inDirAbs = path.isAbsolute(inDir) ? inDir : path.join(CWD, inDir); await fs.mkdir(DIST_DIR, { recursive: true });
const outAbs = path.isAbsolute(outFile) ? outFile : path.join(CWD, outFile);
// antifragile const files = await walk(ANNO_ROOT);
if (!(await exists(inDirAbs))) {
console.log(` annotations-index: skip (input missing): ${inDir}`);
process.exit(0);
}
const files = (await walk(inDirAbs)).filter((p) => /\.(ya?ml|json)$/i.test(p)); for (const fp of files) {
if (!files.length) { const rel = normPath(path.relative(ANNO_ROOT, fp));
console.log(` annotations-index: skip (no .yml/.yaml/.json found in): ${inDir}`); const relNoExt = rel.replace(/\.ya?ml$/i, "");
process.exit(0); const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt);
}
const pages = Object.create(null);
let paraCount = 0;
for (const f of files) {
const fileRel = path.relative(CWD, f).replace(/\\/g, "/");
const pageKey = normalizePageKey(inferPageKeyFromFile(inDirAbs, f));
assert(pageKey, `${fileRel}: cannot infer page key`);
let doc;
try { try {
doc = await readDoc(f); const raw = await fs.readFile(fp, "utf8");
const doc = YAML.parse(raw) || {};
if (!isObj(doc) || doc.schema !== 1) continue;
validateAndNormalizeDoc(
doc,
`src/annotations/${rel}`,
pageKey,
isShard ? paraId : null
);
const pg = (pages[pageKey] ??= { paras: {} });
if (isShard) {
const entry = doc.paras[paraId];
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
stableSortByTs(pg.paras[paraId].media);
stableSortByTs(pg.paras[paraId].refs);
stableSortByTs(pg.paras[paraId].comments_editorial);
} else {
for (const [pid, entry] of Object.entries(doc.paras || {})) {
const p = String(pid);
if (!isObj(pg.paras[p])) pg.paras[p] = {};
if (isObj(entry)) deepMergeEntry(pg.paras[p], entry);
stableSortByTs(pg.paras[p].media);
stableSortByTs(pg.paras[p].refs);
stableSortByTs(pg.paras[p].comments_editorial);
}
}
} catch (e) { } catch (e) {
throw new Error(`${fileRel}: parse failed: ${String(e?.message ?? e)}`); errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
}
} }
const paras = validateAndNormalizeDoc(doc, pageKey, fileRel); for (const [pageKey, pg] of Object.entries(pages)) {
const keys = Object.keys(pg.paras || {});
// 1 fichier = 1 page (canon) keys.sort((a, b) => {
assert(!pages[pageKey], `${fileRel}: duplicate page "${pageKey}" (only one file per page)`); const ia = paraNum(a);
pages[pageKey] = { paras }; const ib = paraNum(b);
paraCount += Object.keys(paras).length; if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
return String(a).localeCompare(String(b));
});
const next = {};
for (const k of keys) next[k] = pg.paras[k];
pg.paras = next;
} }
const out = { const out = {
@@ -143,17 +226,21 @@ async function main() {
pages, pages,
stats: { stats: {
pages: Object.keys(pages).length, pages: Object.keys(pages).length,
paras: paraCount, paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
errors: errors.length,
}, },
errors,
}; };
await fs.mkdir(path.dirname(outAbs), { recursive: true }); if (errors.length) {
await fs.writeFile(outAbs, JSON.stringify(out), "utf8"); throw new Error(`${errors[0].file}: ${errors[0].error}`);
}
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> ${path.relative(CWD, outAbs)}`); await fs.writeFile(OUT, JSON.stringify(out), "utf8");
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> dist/annotations-index.json`);
} }
main().catch((e) => { main().catch((e) => {
console.error("FAIL: build-annotations-index crashed:", e); console.error(`FAIL: build-annotations-index crashed: ${e?.stack || e?.message || e}`);
process.exit(1); process.exit(1);
}); });

View File

@@ -48,6 +48,9 @@ async function main() {
let missing = 0; let missing = 0;
const notes = []; const notes = [];
// Optim: éviter de vérifier 100 fois le même fichier media
const seenMedia = new Set(); // src string
for (const f of files) { for (const f of files) {
const rel = path.relative(CWD, f).replace(/\\/g, "/"); const rel = path.relative(CWD, f).replace(/\\/g, "/");
const raw = await fs.readFile(f, "utf8"); const raw = await fs.readFile(f, "utf8");
@@ -70,6 +73,10 @@ async function main() {
const src = String(m?.src || ""); const src = String(m?.src || "");
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
// dédupe
if (seenMedia.has(src)) continue;
seenMedia.add(src);
checked++; checked++;
const p = toPublicPathFromUrl(src); const p = toPublicPathFromUrl(src);
if (!p) continue; if (!p) continue;

View File

@@ -27,11 +27,6 @@ function escRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function inferPageKeyFromFile(fileAbs) {
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
return rel.replace(/\.(ya?ml|json)$/i, "");
}
function normalizePageKey(s) { function normalizePageKey(s) {
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, ""); return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
} }
@@ -40,6 +35,31 @@ function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x); return !!x && typeof x === "object" && !Array.isArray(x);
} }
function isParaId(s) {
return /^p-\d+-/i.test(String(s || ""));
}
/**
* Supporte:
* - monolith: src/annotations/<pageKey>.yml -> pageKey = rel sans ext
* - shard : src/annotations/<pageKey>/<paraId>.yml -> pageKey = dirname(rel), paraId = basename
*
* shard seulement si le fichier est dans un sous-dossier (anti cas pathologique).
*/
function inferFromFile(fileAbs) {
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
const relNoExt = rel.replace(/\.(ya?ml|json)$/i, "");
const parts = relNoExt.split("/").filter(Boolean);
const base = parts[parts.length - 1] || "";
const dirParts = parts.slice(0, -1);
const isShard = dirParts.length > 0 && isParaId(base);
const pageKey = isShard ? dirParts.join("/") : relNoExt;
const paraId = isShard ? base : "";
return { pageKey: normalizePageKey(pageKey), paraId };
}
async function loadAliases() { async function loadAliases() {
if (!(await exists(ALIASES_PATH))) return {}; if (!(await exists(ALIASES_PATH))) return {};
try { try {
@@ -83,7 +103,11 @@ async function main() {
const aliases = await loadAliases(); const aliases = await loadAliases();
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p)); const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
let pages = 0; // perf: cache HTML par page (shards = beaucoup de fichiers pour 1 page)
const htmlCache = new Map(); // pageKey -> html
const missingDistPage = new Set(); // pageKey
let pagesSeen = new Set();
let checked = 0; let checked = 0;
let failures = 0; let failures = 0;
const notes = []; const notes = [];
@@ -107,7 +131,7 @@ async function main() {
continue; continue;
} }
const pageKey = normalizePageKey(inferPageKeyFromFile(f)); const { pageKey, paraId: shardParaId } = inferFromFile(f);
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) { if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
failures++; failures++;
@@ -121,20 +145,44 @@ async function main() {
continue; continue;
} }
// shard invariant (fort) : doit contenir paras[paraId]
if (shardParaId) {
if (!Object.prototype.hasOwnProperty.call(doc.paras, shardParaId)) {
failures++;
notes.push(`- SHARD MISMATCH: ${rel} (expected paras["${shardParaId}"] present)`);
continue;
}
// si extras -> warning (non destructif)
const keys = Object.keys(doc.paras);
if (!(keys.length === 1 && keys[0] === shardParaId)) {
notes.push(`- WARN shard has extra paras: ${rel} (expected only "${shardParaId}", got ${keys.join(", ")})`);
}
}
pagesSeen.add(pageKey);
const distFile = path.join(DIST_DIR, pageKey, "index.html"); const distFile = path.join(DIST_DIR, pageKey, "index.html");
if (!(await exists(distFile))) { if (!(await exists(distFile))) {
if (!missingDistPage.has(pageKey)) {
missingDistPage.add(pageKey);
failures++; failures++;
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`); notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
} else {
notes.push(`- WARN missing page already reported: dist/${pageKey}/index.html (from ${rel})`);
}
continue; continue;
} }
pages++; let html = htmlCache.get(pageKey);
const html = await fs.readFile(distFile, "utf8"); if (!html) {
html = await fs.readFile(distFile, "utf8");
htmlCache.set(pageKey, html);
}
for (const paraId of Object.keys(doc.paras)) { for (const paraId of Object.keys(doc.paras)) {
checked++; checked++;
if (!/^p-\d+-/i.test(paraId)) { if (!isParaId(paraId)) {
failures++; failures++;
notes.push(`- INVALID ID: ${rel} (${paraId})`); notes.push(`- INVALID ID: ${rel} (${paraId})`);
continue; continue;
@@ -158,6 +206,7 @@ async function main() {
} }
const warns = notes.filter((x) => x.startsWith("- WARN")); const warns = notes.filter((x) => x.startsWith("- WARN"));
const pages = pagesSeen.size;
if (failures > 0) { if (failures > 0) {
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`); console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);

View File

@@ -0,0 +1,10 @@
schema: 1
page: archicrat-ia/chapitre-1
paras:
p-0-8d27a7f5:
refs:
- url: https://auth.archicratie.trans-hands.synology.me/authenticated
label: Lien web
kind: (livre / article / vidéo / site / autre) Site
ts: 2026-02-27T12:34:31.704Z
fromIssue: 142

View File

@@ -0,0 +1,9 @@
schema: 1
page: archicrat-ia/chapitre-1
paras:
p-1-8a6c18bf:
comments_editorial:
- text: Yeaha
status: new
ts: 2026-02-27T12:40:39.462Z
fromIssue: 143

View File

@@ -0,0 +1,12 @@
schema: 1
page: archicrat-ia/chapitre-3
paras:
p-0-ace27175:
media:
- type: image
src: /media/archicrat-ia/chapitre-3/p-0-ace27175/Capture_d_e_cran_2025-05-05_a_19.20.40.png
caption: "[Media] p-0-ace27175 — Chapitre 3 — Philosophies du pouvoir et
archicration"
credit: ""
ts: 2026-02-27T12:43:14.259Z
fromIssue: 144

View File

@@ -57,25 +57,24 @@ function deepMergeEntry(dst: any, src: any) {
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; } if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
if (isObj(v)) { if (isObj(v)) {
if (!isObj(dst[k])) dst[k] = {}; if (!isObj((dst as any)[k])) (dst as any)[k] = {};
deepMergeEntry(dst[k], v); deepMergeEntry((dst as any)[k], v);
continue; continue;
} }
if (isArr(v)) { if (isArr(v)) {
const cur = isArr(dst[k]) ? dst[k] : []; const cur = isArr((dst as any)[k]) ? (dst as any)[k] : [];
const seen = new Set(cur.map((x:any) => JSON.stringify(x))); const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
const out = [...cur]; const out = [...cur];
for (const it of v) { for (const it of v) {
const s = JSON.stringify(it); const s = JSON.stringify(it);
if (!seen.has(s)) { seen.add(s); out.push(it); } if (!seen.has(s)) { seen.add(s); out.push(it); }
} }
dst[k] = out; (dst as any)[k] = out;
continue; continue;
} }
// scalar: set only if missing/empty if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v;
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
} }
} }
@@ -93,7 +92,7 @@ async function walk(dir: string): Promise<string[]> {
function inferExpected(relNoExt: string) { function inferExpected(relNoExt: string) {
const parts = relNoExt.split("/").filter(Boolean); const parts = relNoExt.split("/").filter(Boolean);
const last = parts.at(-1) || ""; const last = parts.at(-1) || "";
const isShard = /^p-\d+-/i.test(last); const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt; const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
const paraId = isShard ? last : null; const paraId = isShard ? last : null;
return { isShard, pageKey, paraId }; return { isShard, pageKey, paraId };
@@ -136,6 +135,12 @@ export const GET: APIRoute = async () => {
if (!(paraId in doc.paras)) { if (!(paraId in doc.paras)) {
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`); throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
} }
// ✅ invariant aligné avec build-annotations-index
const keys = Object.keys(doc.paras).map(String);
if (!(keys.length === 1 && keys[0] === paraId)) {
throw new Error(`shard invariant violated: shard must contain ONLY paras["${paraId}"] (got: ${keys.join(", ")})`);
}
const entry = doc.paras[paraId]; const entry = doc.paras[paraId];
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {}; if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry); if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
@@ -159,8 +164,7 @@ export const GET: APIRoute = async () => {
} }
} }
// sort paras for (const [pk, pg] of Object.entries(pages)) {
for (const [pageKey, pg] of Object.entries(pages)) {
const keys = Object.keys(pg.paras || {}); const keys = Object.keys(pg.paras || {});
keys.sort((a, b) => { keys.sort((a, b) => {
const ia = paraNum(a); const ia = paraNum(a);
@@ -185,7 +189,6 @@ export const GET: APIRoute = async () => {
errors, errors,
}; };
// 🔥 comportement “pro CI” : si erreurs => build fail
if (errors.length) { if (errors.length) {
throw new Error(`${errors[0].file}: ${errors[0].error}`); throw new Error(`${errors[0].file}: ${errors[0].error}`);
} }