From 5d3473d66c3d972e74461260240b0a4467391dc3 Mon Sep 17 00:00:00 2001 From: Archicratia Date: Mon, 16 Mar 2026 13:39:09 +0100 Subject: [PATCH] fix(actions): harden proposer queue against duplicate batch PRs --- .gitea/workflows/proposer-apply-pr.yml | 238 ++++++++++++++----------- 1 file changed, 136 insertions(+), 102 deletions(-) diff --git a/.gitea/workflows/proposer-apply-pr.yml b/.gitea/workflows/proposer-apply-pr.yml index 08f6928..597419c 100644 --- a/.gitea/workflows/proposer-apply-pr.yml +++ b/.gitea/workflows/proposer-apply-pr.yml @@ -130,8 +130,6 @@ jobs: echo "event=$EVENT_NAME label=${LABEL_NAME:-}" if [[ "$EVENT_NAME" == "issues" ]]; then - # Gitea peut fournir un payload "issues/labeled" sans label exploitable. - # On ne skip QUE si le label est explicitement présent ET différent de state/approved. if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip" echo 'SKIP=1' >> /tmp/proposer.env @@ -218,6 +216,43 @@ jobs: echo "Target batch:" grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env + - name: Derive deterministic batch identity + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } + + export TARGET_ISSUES TARGET_CHEMIN + + node --input-type=module - <<'NODE' + import fs from "node:fs"; + import crypto from "node:crypto"; + + const issues = String(process.env.TARGET_ISSUES || "") + .trim() + .split(/\s+/) + .filter(Boolean) + .sort((a, b) => Number(a) - Number(b)); + + const chemin = String(process.env.TARGET_CHEMIN || "").trim(); + const keySource = `${chemin}::${issues.join(",")}`; + const hash = crypto.createHash("sha1").update(keySource).digest("hex").slice(0, 12); + const primary = issues[0] || "0"; + const batchBranch = `bot/proposer-${primary}-${hash}`; + + fs.appendFileSync( + "/tmp/proposer.env", + [ + `BATCH_KEY=${JSON.stringify(keySource)}`, + `BATCH_HASH=${JSON.stringify(hash)}`, + `BATCH_BRANCH=${JSON.stringify(batchBranch)}` + ].join("\n") + "\n" + ); + NODE + + echo "Batch identity:" + grep -E '^(BATCH_KEY|BATCH_HASH|BATCH_BRANCH)=' /tmp/proposer.env + - name: Inspect open proposer PRs env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} @@ -233,6 +268,8 @@ jobs: -o /tmp/open_pulls.json export TARGET_ISSUES="${TARGET_ISSUES:-}" + export BATCH_BRANCH="${BATCH_BRANCH:-}" + export BATCH_KEY="${BATCH_KEY:-}" node --input-type=module - <<'NODE' >> /tmp/proposer.env import fs from "node:fs"; @@ -243,15 +280,21 @@ jobs: .split(/\s+/) .filter(Boolean); + const batchBranch = String(process.env.BATCH_BRANCH || ""); + const batchKey = String(process.env.BATCH_KEY || ""); + const proposerOpen = Array.isArray(pulls) ? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-")) : []; - const current = proposerOpen.find((pr) => { + const sameBatch = proposerOpen.find((pr) => { const ref = String(pr?.head?.ref || ""); const title = String(pr?.title || ""); const body = String(pr?.body || ""); + if (batchBranch && ref === batchBranch) return true; + if (batchKey && body.includes(`Batch-Key: ${batchKey}`)) return true; + return issues.some((n) => ref.startsWith(`bot/proposer-${n}-`) || title.includes(`#${n}`) || @@ -262,10 +305,11 @@ jobs: const out = []; - if (current) { + if (sameBatch) { out.push("SKIP=1"); out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`); - out.push(`OPEN_PR_URL=${JSON.stringify(String(current.html_url || current.url || ""))}`); + out.push(`OPEN_PR_URL=${JSON.stringify(String(sameBatch.html_url || sameBatch.url || ""))}`); + out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(sameBatch?.head?.ref || ""))}`); } else if (proposerOpen.length > 0) { const first = proposerOpen[0]; out.push("SKIP=1"); @@ -277,6 +321,22 @@ jobs: process.stdout.write(out.join("\n") + (out.length ? "\n" : "")); NODE + - name: Guard on remote batch branch before heavy work + run: | + set -euo pipefail + source /tmp/proposer.env + [[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; } + + if git ls-remote --exit-code --heads origin "$BATCH_BRANCH" >/dev/null 2>&1; then + echo 'SKIP=1' >> /tmp/proposer.env + echo 'SKIP_REASON="batch_branch_exists_without_pr"' >> /tmp/proposer.env + echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env + echo "Remote batch branch already exists -> skip duplicate materialization" + exit 0 + fi + + echo "Remote batch branch is free" + - name: Comment issue if queued / skipped if: ${{ always() }} env: @@ -306,7 +366,13 @@ jobs: MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main." ;; issue_already_has_open_pr) - MSG="This ticket already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}" + MSG="This batch already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}" + ;; + batch_branch_exists_without_pr) + MSG="This batch already has a remote batch branch (${OPEN_PR_BRANCH:-"(unknown branch)"}). Manual inspection is required before any new proposer PR is created." + ;; + batch_branch_already_materialized) + MSG="This batch was already materialized by another run on branch ${OPEN_PR_BRANCH:-"(unknown branch)"}. No duplicate PR was created." ;; explicit_issue_missing_chemin) MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable." @@ -328,6 +394,7 @@ jobs: ;; esac + export MSG node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json const msg = process.env.MSG || ""; process.stdout.write(JSON.stringify({ body: msg })); @@ -384,8 +451,7 @@ jobs: git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}" START_SHA="$(git rev-parse HEAD)" - TS="$(date -u +%Y%m%d-%H%M%S)" - BR="bot/proposer-${TARGET_PRIMARY_ISSUE}-${TS}" + BR="$BATCH_BRANCH" echo "BRANCH=$BR" >> /tmp/proposer.env git checkout -b "$BR" @@ -518,6 +584,27 @@ jobs: --data-binary @/tmp/proposer.failure.comment.json || true done + - name: Late guard against duplicate batch materialization + run: | + set -euo pipefail + source /tmp/proposer.env || true + [[ "${SKIP:-0}" != "1" ]] || exit 0 + [[ "${APPLY_RC:-0}" == "0" ]] || exit 0 + [[ "${REBASE_RC:-0}" == "0" ]] || exit 0 + [[ "${NOOP:-0}" == "0" ]] || exit 0 + + REMOTE_SHA="$(git ls-remote --heads origin "$BATCH_BRANCH" | awk 'NR==1 {print $1}')" + + if [[ -n "${REMOTE_SHA:-}" && "${REMOTE_SHA}" != "${END_SHA:-}" ]]; then + echo 'SKIP=1' >> /tmp/proposer.env + echo 'SKIP_REASON="batch_branch_already_materialized"' >> /tmp/proposer.env + echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env + echo "Remote batch branch already exists at $REMOTE_SHA -> skip duplicate push/PR" + exit 0 + fi + + echo "Late guard OK" + - name: Push bot branch if: ${{ always() }} env: @@ -557,13 +644,39 @@ jobs: test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; } + OPEN_PRS_JSON="$(curl -fsS \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100")" + + export OPEN_PRS_JSON BATCH_BRANCH BATCH_KEY + + EXISTING_PR_URL="$(node --input-type=module -e ' + const pulls = JSON.parse(process.env.OPEN_PRS_JSON || "[]"); + const branch = String(process.env.BATCH_BRANCH || ""); + const key = String(process.env.BATCH_KEY || ""); + const current = Array.isArray(pulls) + ? pulls.find((pr) => { + const ref = String(pr?.head?.ref || ""); + const body = String(pr?.body || ""); + return (branch && ref === branch) || (key && body.includes(`Batch-Key: ${key}`)); + }) + : null; + process.stdout.write(current ? String(current.html_url || current.url || "") : ""); + ')" + + if [[ -n "${EXISTING_PR_URL:-}" ]]; then + echo "PR already exists for this batch: $EXISTING_PR_URL" + exit 0 + fi + if [[ "${TARGET_COUNT:-0}" == "1" ]]; then PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}" else PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}" fi - export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER + export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER BATCH_KEY node --input-type=module -e ' import fs from "node:fs"; @@ -581,6 +694,7 @@ jobs: ...issues.map((n) => ` - #${n}`), `- Branche: ${process.env.BRANCH || ""}`, `- Commit: ${process.env.END_SHA || "unknown"}`, + `- Batch-Key: ${process.env.BATCH_KEY || ""}`, "", "Merge si CI OK." ].join("\n"); @@ -597,97 +711,6 @@ jobs: ); ' - echo "Creating proposer PR..." - 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 @/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 - } - - echo "PR created: $PR_URL" - - for ISSUE in $TARGET_ISSUES; do - export ISSUE PR_URL - - node --input-type=module -e ' - import fs from "node:fs"; - - const issue = process.env.ISSUE || ""; - const url = process.env.PR_URL || ""; - const msg = - `PR proposer créée pour le ticket #${issue} : ${url}\n\n` + - `Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`; - - fs.writeFileSync( - "/tmp/proposer.issue.close.comment.json", - JSON.stringify({ body: msg }) - ); - ' - - echo "Commenting issue #$ISSUE ..." - COMMENT_HTTP="$(curl -sS -o /tmp/proposer.comment.out.json -w '%{http_code}' -X POST \ - -H "Authorization: token $FORGE_TOKEN" \ - -H "Content-Type: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \ - --data-binary @/tmp/proposer.issue.close.comment.json || true)" - echo "Issue #$ISSUE comment HTTP=$COMMENT_HTTP" - - if [[ ! "$COMMENT_HTTP" =~ ^2 ]]; then - echo "Failed to comment issue #$ISSUE" - cat /tmp/proposer.comment.out.json || true - exit 1 - fi - - echo "Closing issue #$ISSUE ..." - CLOSE_HTTP="$(curl -sS -o /tmp/proposer.close.out.json -w '%{http_code}' -X PATCH \ - -H "Authorization: token $FORGE_TOKEN" \ - -H "Content-Type: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \ - --data-binary '{"state":"closed"}' || true)" - echo "Issue #$ISSUE close HTTP=$CLOSE_HTTP" - - if [[ ! "$CLOSE_HTTP" =~ ^2 ]]; then - echo "Failed to close issue #$ISSUE" - cat /tmp/proposer.close.out.json || true - exit 1 - fi - - echo "Verifying issue #$ISSUE state ..." - VERIFY_HTTP="$(curl -sS -o /tmp/proposer.verify.out.json -w '%{http_code}' \ - -H "Authorization: token $FORGE_TOKEN" \ - -H "Accept: application/json" \ - "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" || true)" - echo "Issue #$ISSUE verify HTTP=$VERIFY_HTTP" - - if [[ ! "$VERIFY_HTTP" =~ ^2 ]]; then - echo "Failed to re-read issue #$ISSUE after close" - cat /tmp/proposer.verify.out.json || true - exit 1 - fi - - ISSUE_STATE="$(node --input-type=module -e ' - import fs from "node:fs"; - const j = JSON.parse(fs.readFileSync("/tmp/proposer.verify.out.json", "utf8")); - console.log(String(j.state || "")); - ')" - - echo "Issue #$ISSUE state=$ISSUE_STATE" - [[ "$ISSUE_STATE" == "closed" ]] || { - echo "Issue #$ISSUE is not closed after PATCH" - cat /tmp/proposer.verify.out.json || true - exit 1 - } - done - - echo "PR: $PR_URL" - PR_JSON="$(curl -fsS -X POST \ -H "Authorization: token $FORGE_TOKEN" \ -H "Content-Type: application/json" \ @@ -710,8 +733,8 @@ jobs: const issue = process.env.ISSUE || ""; const url = process.env.PR_URL || ""; const msg = - `PR proposer créée pour le ticket #${issue} : ${url}\n\n` + - `Le ticket est clôturé automatiquement ; la discussion peut se poursuivre dans la PR.`; + `PR proposer creee pour le ticket #${issue} : ${url}\n\n` + + `Le ticket est cloture automatiquement ; la discussion peut se poursuivre dans la PR.`; fs.writeFileSync( "/tmp/proposer.issue.close.comment.json", @@ -730,6 +753,17 @@ jobs: -H "Content-Type: application/json" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \ --data-binary '{"state":"closed"}' + + ISSUE_STATE="$(curl -fsS \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" | \ + node --input-type=module -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { const j = JSON.parse(s || "{}"); process.stdout.write(String(j.state || "")); });')" + + [[ "$ISSUE_STATE" == "closed" ]] || { + echo "Issue #$ISSUE is still not closed after PATCH" + exit 1 + } done echo "PR: $PR_URL"