From f2e4ae5ac292a216fe5b3ca0773f6fd8c31b45d1 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 16 Mar 2026 00:58:10 +0100 Subject: [PATCH] fix(actions): make proposer queue runtime-safe --- .gitea/workflows/proposer-apply-pr.yml | 301 +++++++++++++++---------- 1 file changed, 183 insertions(+), 118 deletions(-) diff --git a/.gitea/workflows/proposer-apply-pr.yml b/.gitea/workflows/proposer-apply-pr.yml index 60968da..7db74c7 100644 --- a/.gitea/workflows/proposer-apply-pr.yml +++ b/.gitea/workflows/proposer-apply-pr.yml @@ -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(/[:/](?[^/]+)\/(?[^/]+?)(?:\.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,7 +119,7 @@ jobs: ].join("\n") + "\n"); NODE - echo "✅ context:" + echo "Context:" sed -n '1,200p' /tmp/proposer.env - name: Early gate @@ -121,14 +129,51 @@ jobs: if [[ "$EVENT_NAME" == "issues" ]]; then if [[ "$LABEL_NAME" != "state/approved" ]]; then - echo "ℹ️ issues/labeled but label=$LABEL_NAME -> skip" + echo "issues/labeled but label=$LABEL_NAME -> skip" echo 'SKIP=1' >> /tmp/proposer.env echo 'SKIP_REASON="label_not_state_approved"' >> /tmp/proposer.env exit 0 fi fi - echo "✅ proceed" + echo "Proceed" + + - name: Checkout default branch + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } + + rm -rf .git + git init -q + git remote add origin "$CLONE_URL" + git fetch --depth 1 origin "$DEFAULT_BRANCH" + git -c advice.detachedHead=false checkout -q FETCH_HEAD + git log -1 --oneline + + - name: Detect app dir (repo-root vs ./site) + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } + + APP_DIR="." + if [[ -d "site" && -f "site/package.json" ]]; then + APP_DIR="site" + fi + + echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env + echo "APP_DIR=$APP_DIR" + + 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: @@ -136,14 +181,25 @@ jobs: run: | set -euo pipefail source /tmp/proposer.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 + } 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 @@ -151,11 +207,11 @@ jobs: 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" + echo "No target batch" exit 0 fi - echo "✅ target batch:" + echo "Target batch:" grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env - name: Inspect open proposer PRs @@ -164,7 +220,7 @@ jobs: run: | set -euo pipefail source /tmp/proposer.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } + [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } curl -fsS \ -H "Authorization: token $FORGE_TOKEN" \ @@ -177,21 +233,22 @@ jobs: 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 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-")) + ? 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 => + + return issues.some((n) => ref.startsWith(`bot/proposer-${n}-`) || title.includes(`#${n}`) || body.includes(`#${n}`) || @@ -200,13 +257,14 @@ jobs: }); const out = []; + if (current) { - out.push(`SKIP=1`); + 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=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 || ""))}`); @@ -236,72 +294,48 @@ jobs: case "${SKIP_REASON:-}" in queue_busy_open_proposer_pr) - MSG="ℹ️ Ticket mis en file d’attente 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." + 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="ℹ️ Ce ticket a déjà une PR Proposer ouverte : ${OPEN_PR_URL:-"(URL indisponible)"}" + MSG="This ticket already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}" ;; explicit_issue_missing_chemin) - MSG="ℹ️ Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Chemin** est manquant ou illisible." + MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable." ;; explicit_issue_missing_type) - MSG="ℹ️ Proposer Apply: impossible de traiter ce ticket automatiquement car le champ **Type** est manquant ou illisible." + MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable." ;; explicit_issue_not_approved) - MSG="ℹ️ Proposer Apply: ce ticket n’est pas actuellement marqué **state/approved**." + MSG="Proposer Apply: this ticket is not currently labeled state/approved." ;; explicit_issue_rejected) - MSG="ℹ️ Proposer Apply: ce ticket porte **state/rejected** et n’entre donc pas dans la file Proposer." + MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue." ;; no_open_approved_proposer_issue) - MSG="ℹ️ Aucun ticket Proposer approuvé n’est actuellement en attente." + MSG="No approved proposer ticket is currently waiting." ;; *) - MSG="ℹ️ Proposer Apply: skip — ${SKIP_REASON:-raison non précisée}." + MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}." ;; esac - PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$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 "$PAYLOAD" || true - - - name: Checkout default branch - run: | - set -euo pipefail - source /tmp/proposer.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - - rm -rf .git - git init -q - git remote add origin "$CLONE_URL" - git fetch --depth 1 origin "$DEFAULT_BRANCH" - git -c advice.detachedHead=false checkout -q FETCH_HEAD - git log -1 --oneline - - - name: Detect app dir (repo-root vs ./site) - run: | - set -euo pipefail - source /tmp/proposer.env - [[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; } - - APP_DIR="." - if [[ -d "site" && -f "site/package.json" ]]; then - APP_DIR="site" - fi - - echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env - echo "✅ APP_DIR=$APP_DIR" - 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; } + --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 +347,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 +356,7 @@ jobs: set -euo pipefail source /tmp/proposer.env [[ "${SKIP:-0}" != "1" ]] || exit 0 + cd "$APP_DIR" npm run build @@ -333,9 +369,9 @@ 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)" @@ -353,13 +389,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 +409,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 +440,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 +450,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 +466,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,20 +478,35 @@ 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: Push bot branch @@ -462,10 +517,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 +544,7 @@ 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; } if [[ "${TARGET_COUNT:-0}" == "1" ]]; then PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}" @@ -497,64 +552,74 @@ 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 || "") - .trim() - .split(/\s+/) - .filter(Boolean); + export TITLE="$PR_TITLE" + export CHEMIN="$TARGET_CHEMIN" + export ISSUES="$TARGET_ISSUES" + export BRANCH="$BRANCH" + export END_SHA="${END_SHA:-unknown}" + export DEFAULT_BRANCH="$DEFAULT_BRANCH" + export OWNER="$OWNER" - const body = [ - `PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map(n => `#${n}`).join(", ")} (state/approved).`, - "", - `- Chemin: ${process.env.CHEMIN || "(inconnu)"}`, - "- Tickets:", - ...issues.map(n => ` - #${n}`), - `- Branche: ${process.env.BRANCH}`, - `- Commit: ${process.env.END_SHA || "unknown"}`, - "", - "Merge si CI OK." - ].join("\n"); + node --input-type=module - <<'NODE' > /tmp/proposer.pr.json + const issues = String(process.env.ISSUES || "") + .trim() + .split(/\s+/) + .filter(Boolean); - 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 - })); + const body = [ + `PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`, + "", + `- Chemin: ${process.env.CHEMIN || "(inconnu)"}`, + "- Tickets:", + ...issues.map((n) => ` - #${n}`), + `- Branche: ${process.env.BRANCH || ""}`, + `- Commit: ${process.env.END_SHA || "unknown"}`, + "", + "Merge si CI OK." + ].join("\n"); + + process.stdout.write(JSON.stringify({ + title: process.env.TITLE || "proposer: apply tickets", + body, + base: process.env.DEFAULT_BRANCH || "main", + head: `${process.env.OWNER}:${process.env.BRANCH}`, + allow_maintainer_edit: true + })); NODE - )" 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")" - 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 - <<'NODE' > /tmp/proposer.issue.close.comment.json + const issue = process.env.ISSUE || ""; + const url = process.env.PR_URL || ""; + const msg = + `PR proposer created for ticket #${issue}: ${url}\n\n` + + `The ticket is closed automatically. Discussion can continue in the PR.`; + + 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/comments" \ - --data-binary "$C_PAYLOAD" + --data-binary @/tmp/proposer.issue.close.comment.json curl -fsS -X PATCH \ -H "Authorization: token $FORGE_TOKEN" \ @@ -563,7 +628,7 @@ jobs: --data-binary '{"state":"closed"}' done - echo "✅ PR: $PR_URL" + echo "PR: $PR_URL" - name: Finalize if: ${{ always() }} @@ -573,13 +638,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" \ No newline at end of file + echo "Proposer queue OK" \ No newline at end of file -- 2.49.1