From 8132e315f4831b7be6a394fa6dd016e06b69a785 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 2 Mar 2026 18:48:39 +0100 Subject: [PATCH] ci: hard-gate anno apply/reject + fix JSON parsing --- .gitea/workflows/anno-apply-pr.yml | 140 ++++++++++++----------------- .gitea/workflows/anno-reject.yml | 74 ++++++--------- 2 files changed, 81 insertions(+), 133 deletions(-) diff --git a/.gitea/workflows/anno-apply-pr.yml b/.gitea/workflows/anno-apply-pr.yml index b2c2d36..3236948 100644 --- a/.gitea/workflows/anno-apply-pr.yml +++ b/.gitea/workflows/anno-apply-pr.yml @@ -22,6 +22,8 @@ concurrency: 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 @@ -33,6 +35,7 @@ jobs: git --version node --version npm --version + curl --version | head -n 1 - name: Derive context (event.json / workflow_dispatch) env: @@ -52,15 +55,18 @@ jobs: const cloneUrl = repoObj?.clone_url || (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); + if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json"); let owner = repoObj?.owner?.login || repoObj?.owner?.username || (repoObj?.full_name ? repoObj.full_name.split("/")[0] : ""); + let repo = repoObj?.name || (repoObj?.full_name ? repoObj.full_name.split("/")[1] : ""); + if (!owner || !repo) { const m = cloneUrl.match(/[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; } @@ -73,15 +79,15 @@ jobs: ev?.issue?.number || ev?.issue?.index || (process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0); + if (!issueNumber || !Number.isFinite(Number(issueNumber))) { throw new Error("No issue number in event.json or workflow_dispatch input"); } - // label triggered (best effort; may be missing depending on Gitea payload) const labelName = ev?.label?.name || - (typeof ev?.label === "string" ? ev.label : "") || - ""; + ev?.label || + "workflow_dispatch"; const u = new URL(cloneUrl); const origin = u.origin; @@ -90,8 +96,9 @@ 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)); } + // ✅ defaults antifragiles (empêchent les steps "always" de faire n'importe quoi) process.stdout.write([ `CLONE_URL=${sh(cloneUrl)}`, `OWNER=${sh(owner)}`, @@ -100,73 +107,38 @@ jobs: `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, `API_BASE=${sh(apiBase)}`, - - // init safe defaults (avoid "unbound variable" cascades) - `SKIP=0`, + `SKIP=${sh("0")}`, `SKIP_REASON=${sh("")}`, - `ISSUE_TYPE=${sh("")}`, - `ISSUE_TITLE=${sh("")}`, - `APPLY_RC=${sh("")}`, - `NOOP=0`, - `BRANCH=${sh("")}`, - `END_SHA=${sh("")}` + `APPLY_RC=${sh("999")}`, + `NOOP=${sh("1")}` ].join("\n") + "\n"); NODE echo "✅ context:" sed -n '1,160p' /tmp/anno.env - - name: Gate fast (only if label is state/approved or workflow_dispatch) - env: - INPUT_ISSUE: ${{ inputs.issue }} - run: | - set -euo pipefail - source /tmp/anno.env - - # workflow_dispatch => allow - if [[ -n "${INPUT_ISSUE:-}" ]]; then - echo "✅ workflow_dispatch => proceed" - exit 0 - fi - - # 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) + - name: Fetch issue + 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 \ + # ✅ on écrit le JSON dans un fichier (FINI JSON.parse('-')) + curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - > /tmp/issue.json + -o /tmp/issue.json - node --input-type=module - <<'NODE' >> /tmp/anno.env + node --input-type=module - /tmp/issue.json >> /tmp/anno.env <<'NODE' import fs from "node:fs"; - const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); + const issue = JSON.parse(fs.readFileSync(process.argv[2], "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); @@ -183,11 +155,7 @@ jobs: 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) { + if (!type) { out.push(`SKIP=1`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); } else if (allowed.has(type)) { @@ -203,10 +171,10 @@ jobs: process.stdout.write(out.join("\n") + "\n"); NODE - echo "✅ issue gating:" + echo "✅ issue type gating:" grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true - - name: Comment issue if skipped (only when state/approved was present) + - name: Comment issue if skipped (Proposer / unsupported / missing Type) if: ${{ always() }} env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -215,9 +183,9 @@ jobs: 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 + test -n "${API_BASE:-}" || exit 0 REASON="${SKIP_REASON:-}" TYPE="${ISSUE_TYPE:-}" @@ -232,7 +200,8 @@ jobs: PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" - curl -fsS -X POST \ + 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" \ @@ -276,7 +245,6 @@ 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 @@ -303,7 +271,7 @@ jobs: START_SHA="$(git rev-parse HEAD)" TS="$(date -u +%Y%m%d-%H%M%S)" BR="bot/anno-${ISSUE_NUMBER}-${TS}" - echo "BRANCH=\"$BR\"" >> /tmp/anno.env + echo "BRANCH=$BR" >> /tmp/anno.env git checkout -b "$BR" export FORGE_API="$API_BASE" @@ -316,16 +284,15 @@ jobs: RC=$? set -e - echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env + echo "APPLY_RC=$RC" >> /tmp/anno.env echo "== apply log (tail) ==" tail -n 180 "$LOG" || true END_SHA="$(git rev-parse HEAD)" - echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env if [[ "$RC" -ne 0 ]]; then - echo "NOOP=0" >> /tmp/anno.env + echo "NOOP=1" >> /tmp/anno.env exit 0 fi @@ -333,6 +300,7 @@ jobs: echo "NOOP=1" >> /tmp/anno.env else echo "NOOP=0" >> /tmp/anno.env + echo "END_SHA=$END_SHA" >> /tmp/anno.env fi - name: Comment issue on failure (strict/verify/etc) @@ -342,12 +310,14 @@ jobs: run: | set -euo pipefail source /tmp/anno.env || true - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + [[ "${SKIP:-0}" != "1" ]] || exit 0 - RC="${APPLY_RC:-}" - [[ -n "$RC" ]] || { echo "ℹ️ apply not executed"; exit 0; } + RC="${APPLY_RC:-999}" [[ "$RC" != "0" ]] || { echo "ℹ️ no failure detected"; exit 0; } + 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$//')" else @@ -357,7 +327,8 @@ 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 -X POST \ + 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" \ @@ -372,14 +343,17 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - RC="${APPLY_RC:-}" - [[ "$RC" == "0" ]] || exit 0 - [[ "${NOOP:-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 -X POST \ + 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" \ @@ -394,9 +368,9 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - [[ "${APPLY_RC:-}" == "0" ]] || { echo "ℹ️ apply not ok -> skip push"; exit 0; } - [[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip push"; exit 0; } - [[ -n "${BRANCH:-}" ]] || { echo "ℹ️ missing BRANCH -> skip push"; 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; } AUTH_URL="$(node --input-type=module -e ' const [clone, tok] = process.argv.slice(1); @@ -418,10 +392,10 @@ jobs: source /tmp/anno.env || true [[ "${SKIP:-0}" != "1" ]] || exit 0 - [[ "${APPLY_RC:-}" == "0" ]] || { echo "ℹ️ apply not ok -> 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; } + [[ "${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; } 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." @@ -431,7 +405,8 @@ 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 -X POST \ + PR_JSON="$(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/pulls" \ @@ -447,14 +422,13 @@ 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 -X POST \ + 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 "$C_PAYLOAD" - echo "✅ PR: $PR_URL" - - name: Finalize (fail job if apply failed) if: ${{ always() }} run: | @@ -463,9 +437,7 @@ jobs: [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - RC="${APPLY_RC:-}" - [[ -n "$RC" ]] || { echo "❌ apply did not run"; exit 2; } - + RC="${APPLY_RC:-999}" 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 e91ac63..3c9016a 100644 --- a/.gitea/workflows/anno-reject.yml +++ b/.gitea/workflows/anno-reject.yml @@ -22,6 +22,8 @@ concurrency: 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 @@ -31,6 +33,7 @@ jobs: run: | set -euo pipefail node --version + curl --version | head -n 1 - name: Derive context (event.json / workflow_dispatch) env: @@ -77,73 +80,47 @@ jobs: const labelName = ev?.label?.name || - (typeof ev?.label === "string" ? ev.label : "") || - ""; + ev?.label || + "workflow_dispatch"; - 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 = ""; - } + const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim()) + ? String(process.env.FORGE_API).trim().replace(/\/+$/,"") + : (cloneUrl ? new URL(cloneUrl).origin : ""); - function sh(s){ return JSON.stringify(String(s ?? "")); } + function sh(s){ return JSON.stringify(String(s)); } process.stdout.write([ `OWNER=${sh(owner)}`, `REPO=${sh(repo)}`, `ISSUE_NUMBER=${sh(issueNumber)}`, `LABEL_NAME=${sh(labelName)}`, - `API_BASE=${sh(apiBase)}`, - `SKIP=0` + `API_BASE=${sh(apiBase)}` ].join("\n") + "\n"); NODE echo "✅ context:" - sed -n '1,160p' /tmp/reject.env + sed -n '1,120p' /tmp/reject.env - - name: Gate fast (only if label is state/rejected or workflow_dispatch) - env: - INPUT_ISSUE: ${{ inputs.issue }} - run: | - set -euo pipefail - source /tmp/reject.env - - if [[ -n "${INPUT_ISSUE:-}" ]]; then - echo "✅ workflow_dispatch => proceed" - exit 0 - fi - - 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) + - name: Comment + close (only if not conflicting with state/approved) 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 \ + curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ -H "Authorization: token $FORGE_TOKEN" \ -H "Accept: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ - > /tmp/issue.json + -o /tmp/reject.issue.json - node --input-type=module - <<'NODE' > /tmp/reject.flags + # conflict guard: approved + rejected => do nothing, comment warning + node --input-type=module - /tmp/reject.issue.json > /tmp/reject.flags <<'NODE' import fs from "node:fs"; - const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8")); + const issue = JSON.parse(fs.readFileSync(process.argv[2], "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"); @@ -152,16 +129,11 @@ jobs: 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 \ + 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" \ @@ -170,16 +142,20 @@ 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 -X POST \ + 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" - curl -fsS -X PATCH \ + # close issue + curl -fsS --retry 3 --retry-delay 2 --retry-all-errors --max-time 30 \ + -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \