From dec5f8eba7a259737f65c23de24e4cd035484a2e Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 2 Mar 2026 20:12:29 +0100 Subject: [PATCH] ci: make anno apply/reject gates API-hard (approved/rejected label present) --- .gitea/workflows/anno-apply-pr.yml | 148 +++++++++++++++-------------- .gitea/workflows/anno-reject.yml | 67 ++++++++----- 2 files changed, 118 insertions(+), 97 deletions(-) diff --git a/.gitea/workflows/anno-apply-pr.yml b/.gitea/workflows/anno-apply-pr.yml index 3236948..7cb9e04 100644 --- a/.gitea/workflows/anno-apply-pr.yml +++ b/.gitea/workflows/anno-apply-pr.yml @@ -17,13 +17,11 @@ defaults: shell: bash concurrency: - group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }} + group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} cancel-in-progress: true jobs: apply-approved: - # ✅ Job ne démarre QUE si state/approved (ou workflow_dispatch) - if: ${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'state/approved' }} runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -35,12 +33,11 @@ jobs: git --version node --version npm --version - curl --version | head -n 1 - name: Derive context (event.json / workflow_dispatch) env: INPUT_ISSUE: ${{ inputs.issue }} - FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} + FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} run: | set -euo pipefail export EVENT_JSON="/var/run/act/workflow/event.json" @@ -84,10 +81,12 @@ jobs: throw new Error("No issue number in event.json or workflow_dispatch input"); } - const labelName = - ev?.label?.name || - ev?.label || - "workflow_dispatch"; + // label name: best-effort (non-bloquant) + let labelName = "workflow_dispatch"; + const lab = ev?.label; + if (typeof lab === "string") labelName = lab; + else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name; + else if (ev?.label?.name) labelName = ev.label.name; const u = new URL(cloneUrl); const origin = u.origin; @@ -98,7 +97,6 @@ jobs: function sh(s){ return JSON.stringify(String(s)); } - // ✅ defaults antifragiles (empêchent les steps "always" de faire n'importe quoi) process.stdout.write([ `CLONE_URL=${sh(cloneUrl)}`, `OWNER=${sh(owner)}`, @@ -106,39 +104,57 @@ jobs: `DEFAULT_BRANCH=${sh(defaultBranch)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, - `API_BASE=${sh(apiBase)}`, - `SKIP=${sh("0")}`, - `SKIP_REASON=${sh("")}`, - `APPLY_RC=${sh("999")}`, - `NOOP=${sh("1")}` + `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "✅ context:" - sed -n '1,160p' /tmp/anno.env + sed -n '1,120p' /tmp/anno.env - - name: Fetch issue + gate on Type (skip Proposer) + - name: Early gate (label event fast-skip, but tolerant) + run: | + set -euo pipefail + source /tmp/anno.env + + 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 "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)" + + - name: Fetch issue + hard gate on labels + Type 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; } - # ✅ on écrit le JSON dans un fichier (FINI JSON.parse('-')) - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ + curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ -o /tmp/issue.json - node --input-type=module - /tmp/issue.json >> /tmp/anno.env <<'NODE' + node --input-type=module - <<'NODE' >> /tmp/anno.env import fs from "node:fs"; - const issue = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + + 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); @@ -155,6 +171,14 @@ jobs: 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")}`); + process.stdout.write(out.join("\n") + "\n"); + process.exit(0); + } + if (!type) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); @@ -171,7 +195,7 @@ jobs: process.stdout.write(out.join("\n") + "\n"); NODE - echo "✅ issue type gating:" + echo "✅ gating result:" grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true - name: Comment issue if skipped (Proposer / unsupported / missing Type) @@ -184,24 +208,28 @@ 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" + exit 0 + fi + test -n "${FORGE_TOKEN:-}" || exit 0 - test -n "${API_BASE:-}" || 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." + 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.\n✅ Action : traitement manuel si nécessaire." + MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment." 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." + MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: type/media|type/reference|type/comment" fi PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + 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" \ @@ -245,6 +273,7 @@ jobs: [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } npm run build + test -f dist/para-index.json || { echo "❌ missing dist/para-index.json after build" ls -la dist | sed -n '1,200p' || true @@ -262,6 +291,7 @@ jobs: 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; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } @@ -292,7 +322,7 @@ jobs: END_SHA="$(git rev-parse HEAD)" if [[ "$RC" -ne 0 ]]; then - echo "NOOP=1" >> /tmp/anno.env + echo "NOOP=0" >> /tmp/anno.env exit 0 fi @@ -310,13 +340,15 @@ jobs: run: | set -euo pipefail source /tmp/anno.env || true - [[ "${SKIP:-0}" != "1" ]] || exit 0 + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - RC="${APPLY_RC:-999}" - [[ "$RC" != "0" ]] || { echo "ℹ️ no failure detected"; exit 0; } + RC="${APPLY_RC:-0}" + if [[ "$RC" == "0" ]]; then + echo "ℹ️ no failure detected" + exit 0 + fi test -n "${FORGE_TOKEN:-}" || exit 0 - test -n "${API_BASE:-}" || exit 0 if [[ -f /tmp/apply.log ]]; then BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" @@ -327,33 +359,7 @@ jobs: 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")" - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -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: Comment issue if no-op (already applied) - if: ${{ always() }} - env: - FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} - run: | - set -euo pipefail - source /tmp/anno.env || true - [[ "${SKIP:-0}" != "1" ]] || exit 0 - - [[ "${APPLY_RC:-999}" == "0" ]] || exit 0 - [[ "${NOOP:-1}" == "1" ]] || exit 0 - - test -n "${FORGE_TOKEN:-}" || exit 0 - test -n "${API_BASE:-}" || exit 0 - - MSG="ℹ️ Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)." - PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" - - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + 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" \ @@ -368,9 +374,9 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - [[ "${APPLY_RC:-999}" == "0" ]] || { echo "ℹ️ apply not ok -> skip push"; exit 0; } - [[ "${NOOP:-1}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; } - test -n "${BRANCH:-}" || { echo "ℹ️ no BRANCH -> 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); @@ -392,10 +398,8 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - [[ "${APPLY_RC:-999}" == "0" ]] || { echo "ℹ️ apply not ok -> skip PR"; exit 0; } - [[ "${NOOP:-1}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; } - test -n "${BRANCH:-}" || { echo "ℹ️ no BRANCH -> skip PR"; exit 0; } - test -n "${END_SHA:-}" || { echo "ℹ️ no END_SHA -> 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." @@ -405,8 +409,7 @@ jobs: console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true })); ' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")" - PR_JSON="$(curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + PR_JSON="$(curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \ @@ -422,13 +425,14 @@ jobs: 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")" - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ --data-binary "$C_PAYLOAD" + echo "✅ PR: $PR_URL" + - name: Finalize (fail job if apply failed) if: ${{ always() }} run: | @@ -437,7 +441,7 @@ jobs: [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - RC="${APPLY_RC:-999}" + RC="${APPLY_RC:-0}" if [[ "$RC" != "0" ]]; then echo "❌ apply failed (rc=$RC)" exit "$RC" diff --git a/.gitea/workflows/anno-reject.yml b/.gitea/workflows/anno-reject.yml index 3c9016a..a983ebf 100644 --- a/.gitea/workflows/anno-reject.yml +++ b/.gitea/workflows/anno-reject.yml @@ -17,13 +17,11 @@ defaults: shell: bash concurrency: - group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }} + group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} cancel-in-progress: true jobs: reject: - # ✅ Job ne démarre QUE si state/rejected (ou workflow_dispatch) - if: ${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'state/rejected' }} runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm @@ -33,12 +31,11 @@ jobs: run: | set -euo pipefail node --version - curl --version | head -n 1 - name: Derive context (event.json / workflow_dispatch) env: INPUT_ISSUE: ${{ inputs.issue }} - FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }} + FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} run: | set -euo pipefail export EVENT_JSON="/var/run/act/workflow/event.json" @@ -78,14 +75,20 @@ jobs: throw new Error("No issue number in event.json or workflow_dispatch input"); } - const labelName = - ev?.label?.name || - ev?.label || - "workflow_dispatch"; + // label name: best-effort (non-bloquant) + let labelName = "workflow_dispatch"; + const lab = ev?.label; + if (typeof lab === "string") labelName = lab; + else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name; - const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim()) - ? String(process.env.FORGE_API).trim().replace(/\/+$/,"") - : (cloneUrl ? new URL(cloneUrl).origin : ""); + 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)); } @@ -101,26 +104,39 @@ jobs: echo "✅ context:" sed -n '1,120p' /tmp/reject.env - - name: Comment + close (only if not conflicting with state/approved) + - name: Early gate (fast-skip, tolerant) + run: | + set -euo pipefail + source /tmp/reject.env + echo "ℹ️ event label = $LABEL_NAME" + + if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then + echo "ℹ️ label=$LABEL_NAME => skip early" + echo "SKIP=1" >> /tmp/reject.env + echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env + exit 0 + fi + + - name: Comment + close (only if label state/rejected is PRESENT now, and no conflict) env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} run: | set -euo pipefail source /tmp/reject.env + [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; } - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ + curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ -o /tmp/reject.issue.json - # conflict guard: approved + rejected => do nothing, comment warning - node --input-type=module - /tmp/reject.issue.json > /tmp/reject.flags <<'NODE' + node --input-type=module - <<'NODE' > /tmp/reject.flags import fs from "node:fs"; - const issue = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + const issue = JSON.parse(fs.readFileSync("/tmp/reject.issue.json","utf8")); const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : []; const hasApproved = labels.includes("state/approved"); const hasRejected = labels.includes("state/rejected"); @@ -129,11 +145,16 @@ jobs: source /tmp/reject.flags + # Do nothing unless state/rejected is truly present now (anti payload weird) + if [[ "${HAS_REJECTED:-0}" != "1" ]]; then + echo "ℹ️ state/rejected not present -> skip" + exit 0 + fi + if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then 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 --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + 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" \ @@ -142,20 +163,16 @@ jobs: exit 0 fi - # comment reject MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)." PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X POST \ + 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" - # close issue - curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ - -X PATCH \ + curl -fsS -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \