diff --git a/.github/workflows/force-workspace-backport.yml b/.github/workflows/force-workspace-backport.yml index ff88488..e92e696 100644 --- a/.github/workflows/force-workspace-backport.yml +++ b/.github/workflows/force-workspace-backport.yml @@ -28,11 +28,10 @@ jobs: parse-and-validate: runs-on: ubuntu-latest outputs: - sha: ${{ steps.parse.outputs.sha }} - target_branch: ${{ steps.parse.outputs.target_branch }} consumer_force_workspace_enabled: ${{ steps.validate.outputs.enabled }} - consumer_backport_branches: ${{ steps.validate.outputs.consumer_backport_branches }} - skip: ${{ steps.idempotency.outputs.skip }} + # pairs: JSON array of {"sha","branch"} after allowlist + per-pair idempotency filtering. + # Consumed by the cherry-pick-and-force-stage matrix. "[]" = nothing to do (matrix skips). + pairs: ${{ steps.filter.outputs.pairs }} steps: # NOTE: no third-party action (e.g. Bullfrog) before the token-minting step — keeping # untrusted actions out of jobs that handle secrets.APP_PRIVATE_KEY avoids a token-theft @@ -48,7 +47,7 @@ jobs: # F3 (provenance): re-verify the trigger inside the reusable (do NOT trust the wrapper's # `if:` alone). Re-FETCH the comment by id with the App token (authoritative — don't trust # the passed body either), assert author == mergify[bot] AND body contains the failure marker. - - name: Re-verify Mergify provenance + parse (anchored) + - name: Re-verify Mergify provenance + parse (sha, branch) pairs id: parse env: GH_TOKEN: ${{ steps.token.outputs.token }} @@ -68,34 +67,64 @@ jobs: echo "::error::provenance check failed — body lacks the 'No backport have been created' marker; aborting" exit 1 fi - # F5 (anchoring + ambiguity): extract the SHA + target branch, requiring EXACTLY ONE - # distinct candidate of each across the comment. The whitelist restricts branches to the - # active LTS vocabulary (equuleus/1.3 dropped 2026-06-01 — EOL; add a token when an LTS opens). + # Extract every (sha, branch) PAIR from the abort comment. BODY is passed via env and read with + # os.environ — NOT interpolated into the python3 source (F6 hardening; no injection surface). + # Emits raw_pairs (compact JSON array) to $GITHUB_OUTPUT; the filter step applies the allowlist + + # per-pair idempotency. raw_pairs == [] is handled (→ RED) by the filter step. BODY="$(cat /tmp/mergify_comment_body.txt)" python3 - <<'PYEOF' >> "$GITHUB_OUTPUT" - import os, re, sys + import os, re, sys, json body = os.environ["BODY"] - # Anchored to Mergify's REAL "No backport have been created" failure wording, captured verbatim - # from in-prod abort VyOS-Networks/vyos-1x#2116 (2026-05-15): - # #### ❌ No backport have been created - # * Backport to branch `circinus` failed due to conflicts - # Cherry-pick of 6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937 has failed: - # Branch name is wrapped in backticks; SHA is introduced by "Cherry-pick of <40hex>". - # (The earlier "(?:commit|Backport of)" / bare-"to branch" anchors never matched real output — - # caught at the Rollout 4 canary pre-flight; the smoke fixtures had mirrored the wrong wording.) - BRANCH_RE = r"to branch\s+`?(sagitta|circinus)`?\b" - # SHA anchored to the failed-cherry-pick sentence so an unrelated bare 40-hex elsewhere - # (URL, prior-PR reference) is NOT picked up (F5/anchoring). "Backport of " kept as a - # robustness alternate; the short 9-hex "commit 6df7ca2f6" line is correctly ignored ({40}). + + # ===== LOCKSTEP PARSER BLOCK — byte-identical across force-workspace-backport.yml + this file ===== + # Anchored to Mergify's REAL "No backport have been created" failure wording (verbatim from in-prod + # aborts captured 2026-06-02). The failed bullet is "* Backport to branch `X` failed ..."; the SHA + # is introduced by "Cherry-pick of <40hex> has failed:". + # + # Multi-branch abort comments are Scenario A: Mergify posts ONE combined comment for a + # "@Mergifyio backport b1 b2" command (confirmed live - vyos/vyos-documentation#2024, where BOTH + # branches fail with DIFFERENT per-branch SHAs). extract_pairs returns a LIST of (sha, branch) + # pairs: it segments the body by each failed bullet and takes the Cherry-pick SHA in each segment. + # - BRANCH_RE is LINE-ANCHORED to the literal failed bullet ("^ * Backport to branch `X` failed"), + # so a conflicting filename / prose containing "to branch `sagitta`" inside the embedded + # git-status output cannot forge a segment boundary and mis-pair a SHA (adversarial finding). + # - success bullets render "has been created for branch `X`" (no "Backport to branch ... failed") + # so they are excluded; merge-commit / branch-not-found failures carry no "Cherry-pick of " + # anchor, so their segment yields 0 SHAs and that branch is skipped (logged) - fail-closed. + # - SHAs are aggregated PER BRANCH; a branch with != 1 distinct SHA (0 = no anchor; >=2 = duplicate + # bullets or an injected fake "Cherry-pick of ") is skipped - fail-closed, never mis-staged. + # - each segment must ALSO contain the cherry-pick context "On branch mergify/bp//" matching + # ITS bullet branch, coupling the SHA to its own context so a forged bullet cannot capture another + # branch's SHA — correctness no longer depends on comment-body ordering (adversarial round-2). + BRANCH_RE = r"(?m)^[ \t>]*[-*]\s+Backport to branch\s+`?(sagitta|circinus)`?\s+failed\b" SHA_RE = r"\b(?:[Cc]herry-pick of|Backport of)\s+(?:commit\s+)?([0-9a-f]{40})\b" - shas = sorted(set(re.findall(SHA_RE, body, re.IGNORECASE))) - branches = sorted({m.lower() for m in re.findall(BRANCH_RE, body, re.IGNORECASE)}) - if len(shas) != 1 or len(branches) != 1: - print(f"Parse failed/ambiguous — distinct SHAs={shas}, branches={branches} (require exactly 1 each)", file=sys.stderr) - print("\n".join(body.splitlines()[:20]), file=sys.stderr) - sys.exit(1) - print(f"sha={shas[0]}") - print(f"target_branch={branches[0]}") - print(f"Parsed: SHA={shas[0]}, target_branch={branches[0]}", file=sys.stderr) + + def extract_pairs(body): + matches = list(re.finditer(BRANCH_RE, body, re.IGNORECASE)) + by_branch, order = {}, [] + for i, m in enumerate(matches): + branch = m.group(1).lower() + seg = body[m.start() : (matches[i + 1].start() if i + 1 < len(matches) else len(body))] + if not re.search(r"On branch mergify/bp/" + re.escape(branch) + r"/", seg): + print(f"::warning::skip branch {branch}: segment lacks 'On branch mergify/bp/{branch}/' cherry-pick context", file=sys.stderr) + continue + shas = set(re.findall(SHA_RE, seg, re.IGNORECASE)) + if branch not in by_branch: + by_branch[branch] = set() + order.append(branch) + by_branch[branch] |= shas + pairs = [] + for branch in order: + shas = sorted(by_branch[branch]) + if len(shas) != 1: + print(f"::warning::skip branch {branch}: {len(shas)} distinct cherry-pick SHAs across its failed bullet(s) (need exactly 1)", file=sys.stderr) + continue + pairs.append({"sha": shas[0], "branch": branch}) + return pairs + # ===== END LOCKSTEP PARSER BLOCK ===== + + pairs = extract_pairs(body) + print("raw_pairs=" + json.dumps(pairs, separators=(",", ":"))) + print(f"extract_pairs found {len(pairs)} stageable pair(s): {pairs}", file=sys.stderr) PYEOF - name: Read consumers.yaml + validate @@ -130,60 +159,99 @@ jobs: fi enabled=$(echo "$consumer" | jq -r '.force_workspace_enabled // false') backport_branches=$(echo "$consumer" | jq -c '.backport_branches // ["sagitta","circinus"]') - echo "enabled=$enabled" >> $GITHUB_OUTPUT - # Output name matches the job-level `outputs.consumer_backport_branches` mapping. - echo "consumer_backport_branches=$backport_branches" >> $GITHUB_OUTPUT + echo "enabled=$enabled" >> "$GITHUB_OUTPUT" + # Step output consumed by the filter step below (same job); no longer a job-level output. + echo "consumer_backport_branches=$backport_branches" >> "$GITHUB_OUTPUT" - - name: Validate target branch is in allowlist - env: - TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} - ALLOWLIST: ${{ steps.validate.outputs.consumer_backport_branches }} - run: | - if ! echo "$ALLOWLIST" | jq -e --arg b "$TARGET_BRANCH" 'index($b) != null' >/dev/null; then - echo "target_branch $TARGET_BRANCH not in allowlist $ALLOWLIST; aborting" >&2 - exit 1 - fi - echo "target_branch validated against allowlist" - - - name: Idempotency check — does force-backport branch already exist? - id: idempotency + - name: Filter pairs (allowlist + per-pair idempotency) + id: filter env: GH_TOKEN: ${{ steps.token.outputs.token }} REPO: ${{ github.repository }} - SHA: ${{ steps.parse.outputs.sha }} - TARGET: ${{ steps.parse.outputs.target_branch }} + RAW_PAIRS: ${{ steps.parse.outputs.raw_pairs }} + ALLOWLIST: ${{ steps.validate.outputs.consumer_backport_branches }} run: | set -euo pipefail - ref_name="force-backport/${SHA}/${TARGET}" - skip=false - # Idempotency: SKIP only when a PR for this deterministic head already exists (any state) — - # the work is done or in progress. A bare BRANCH with NO PR is NOT a skip: it is a failed - # prior run (pushed the branch, then errored before opening the PR). Treating branch-existence - # as a terminal skip would strand it forever (adversarial-review finding) — instead RECOVER - # (the push step force-overwrites the orphaned branch in our deterministic namespace, then the - # PR is opened). PR existence is checked across ALL states (a branch + its PR can diverge). - # Count only SAME-REPO PRs with that head — a fork/cross-repo PR could reuse the head - # branch name to poison this check and block recovery (adversarial round-2 finding). - existing_pr=$(gh pr list --repo "${REPO}" --head "${ref_name}" --state all \ - --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository == false)] | length') - if [ "${existing_pr:-0}" -gt 0 ]; then - skip=true - echo "Idempotent skip: a PR with head ${ref_name} already exists (state all)" >&2 - # Singular `git/ref/heads/` → HTTP 404 when ABSENT (the plural `git/refs/...` would - # return [] with 200 for an absent ref — the prior always-skip bug). - elif gh api "repos/${REPO}/git/ref/heads/${ref_name}" >/dev/null 2>&1; then - echo "::warning::orphaned branch refs/heads/${ref_name} exists with NO PR (failed prior run) — recovering: force-overwrite + open PR" >&2 - fi - echo "skip=${skip}" >> "$GITHUB_OUTPUT" - [ "$skip" = "true" ] || echo "proceeding for ${ref_name}" + # RAW_PAIRS / ALLOWLIST / REPO passed via env and read with os.environ — NOT interpolated into the + # python3 source (F6 hardening; no injection surface). Emits the final `pairs` JSON to + # $GITHUB_OUTPUT, or exits non-zero (RED) for the "nothing force-stageable" / "all out of allowlist" + # cases. gh's own stdout is captured by subprocess so only `pairs=` reaches $GITHUB_OUTPUT. + python3 - <<'PYEOF' >> "$GITHUB_OUTPUT" + import os, sys, json, subprocess + + repo = os.environ["REPO"] + raw = json.loads(os.environ["RAW_PAIRS"]) + allowlist = set(json.loads(os.environ["ALLOWLIST"])) + + # Stage 1 — raw empty → RED. No force-stageable branch (no "Cherry-pick of " anchor under any + # failed "to branch" bullet, e.g. merge-commit error / branch-not-found). Preserves the prior + # single-branch behaviour (nothing to do is a failure, not a silent green). + if not raw: + print("::error::no force-stageable branch in abort comment (no 'Cherry-pick of ' anchor under any failed 'to branch' bullet)", file=sys.stderr) + sys.exit(1) + + # Stage 2 — allowlist filter, per branch. Mixed (some in / some out) → process the in-list subset, + # log the dropped. ALL dropped → RED (preserves the prior single-branch allowlist-step failure). + after_allowlist = [] + for p in raw: + if p["branch"] in allowlist: + after_allowlist.append(p) + else: + print(f"::warning::branch {p['branch']} not in consumer backport_branches {sorted(allowlist)}; skipping", file=sys.stderr) + if not after_allowlist: + dropped = sorted({p["branch"] for p in raw}) + print(f"::error::aborted branch(es) {dropped} not in this consumer's backport_branches allowlist; aborting", file=sys.stderr) + sys.exit(1) + + # Stage 3 — per-pair idempotency. Drop a pair iff a SAME-REPO (non-cross) PR with head + # force-backport// already exists (state all) — work done/in-progress. A fork PR could + # reuse the head name to poison the check, so only same-repo PRs count (adversarial round-2). An + # orphaned BRANCH with NO PR is KEPT (the push step force-overwrites it; treating branch-existence + # as a terminal skip would strand a failed prior run). All remaining already have PRs → green + # no-op (pairs=[] → the matrix is guarded by `pairs != '[]'` and skips). + def pr_exists(ref): + out = subprocess.run( + ["gh", "pr", "list", "--repo", repo, "--head", ref, "--state", "all", + "--json", "number,isCrossRepository", + "--jq", "[.[] | select(.isCrossRepository == false)] | length"], + capture_output=True, text=True, check=True).stdout.strip() + return int(out or "0") > 0 + + def branch_exists(ref): + # Singular git/ref/heads/ → 404 when ABSENT (plural git/refs/... returns [] with 200). + return subprocess.run( + ["gh", "api", f"repos/{repo}/git/ref/heads/{ref}"], + capture_output=True, text=True).returncode == 0 + + after_idem = [] + for p in after_allowlist: + ref = f"force-backport/{p['sha']}/{p['branch']}" + if pr_exists(ref): + print(f"idempotent skip: PR with head {ref} already exists (state all)", file=sys.stderr) + continue + if branch_exists(ref): + print(f"::warning::orphaned branch refs/heads/{ref} exists with NO PR (failed prior run) — recovering: force-overwrite + open PR", file=sys.stderr) + after_idem.append(p) + + print("pairs=" + json.dumps(after_idem, separators=(",", ":"))) + print(f"final pairs ({len(after_idem)}): {after_idem}", file=sys.stderr) + PYEOF cherry-pick-and-force-stage: needs: [parse-and-validate] - # Gate on BOTH per-consumer opt-in AND idempotency check (skip if PR/branch already exists). + # Gate on per-consumer opt-in AND a non-empty filtered pair set. One matrix instance per (sha, branch). + # `pairs != '[]'` guards against an empty fromJSON() matrix (the green idempotent no-op / nothing-to-do). if: | needs.parse-and-validate.outputs.consumer_force_workspace_enabled == 'true' - && needs.parse-and-validate.outputs.skip != 'true' + && needs.parse-and-validate.outputs.pairs != '[]' runs-on: ubuntu-latest + strategy: + # fail-fast: false — one branch's fail-closed (RED) or clean-apply (no-PR) must NOT abort the others. + # Each instance pushes a DISTINCT deterministic branch force-backport//, so the parallel + # matrix jobs never collide; DECISIONS_JSON via $GITHUB_ENV is per-instance (no cross-branch leakage). + fail-fast: false + matrix: + pair: ${{ fromJSON(needs.parse-and-validate.outputs.pairs) }} steps: # NOTE: no third-party action before the token-minting step (token-theft-surface avoidance — # adversarial-review finding). This job clones + force-pushes + opens a PR with the App token. @@ -208,13 +276,16 @@ jobs: - name: Cherry-pick + force-stage unmerged paths (resolve toward source / "theirs") id: forcestage env: - SHA: ${{ needs.parse-and-validate.outputs.sha }} - TARGET: ${{ needs.parse-and-validate.outputs.target_branch }} + SHA: ${{ matrix.pair.sha }} + TARGET: ${{ matrix.pair.branch }} run: | set -euo pipefail cd repo # Reachability (spec §4.2.8 step 3): refuse to force-stage a SHA that is not actually on # the mirrored source branch — guards against a crafted/parsed SHA that isn't a real merge. + # src_branch = the repo's default branch (origin/HEAD), which is the branch Mergify backports + # FROM; every cherry-pick SHA in the abort comment must be an ancestor of it (checked per matrix + # instance, so each branch's own SHA is independently verified). src_branch=$(git rev-parse --abbrev-ref origin/HEAD | sed 's@^origin/@@') if ! git merge-base --is-ancestor "$SHA" "origin/${src_branch}"; then echo "::error::SHA $SHA is not reachable from origin/${src_branch} (the mirrored source branch); refusing to force-stage" @@ -300,8 +371,8 @@ jobs: - name: Push to deterministic branch if: steps.forcestage.outputs.proceed == 'true' env: - SHA: ${{ needs.parse-and-validate.outputs.sha }} - TARGET: ${{ needs.parse-and-validate.outputs.target_branch }} + SHA: ${{ matrix.pair.sha }} + TARGET: ${{ matrix.pair.branch }} run: | cd repo branch_name="force-backport/${SHA}/${TARGET}" @@ -336,11 +407,14 @@ jobs: env: GH_TOKEN: ${{ steps.token.outputs.token }} REPO: ${{ github.repository }} - SHA: ${{ needs.parse-and-validate.outputs.sha }} - TARGET: ${{ needs.parse-and-validate.outputs.target_branch }} + SHA: ${{ matrix.pair.sha }} + TARGET: ${{ matrix.pair.branch }} DECISIONS_JSON: ${{ env.DECISIONS_JSON }} MERGIFY_COMMENT_ID: ${{ inputs.mergify_comment_id }} TARGET_PR_NUMBER: ${{ inputs.target_pr_number }} + # Pass the trigger login via env (NOT interpolated into run:) — avoids a workflow-injection + # surface even though a GitHub login is a constrained charset. + TRIGGER_LOGIN: ${{ github.event.issue.user.login }} run: | set -euo pipefail cd repo @@ -378,8 +452,8 @@ jobs: # operator inspects. Removed the previous `2>/dev/null || echo ""` pattern. # --assignee is optional: only set when the triggering event provides a login (issue_comment). assignee_flag=() - if [ -n "${{ github.event.issue.user.login }}" ]; then - assignee_flag=(--assignee "${{ github.event.issue.user.login }}") + if [ -n "${TRIGGER_LOGIN:-}" ]; then + assignee_flag=(--assignee "$TRIGGER_LOGIN") fi pr_number=$(gh pr create \ --repo "$REPO" \ diff --git a/.github/workflows/force-workspace-parser-smoke.yml b/.github/workflows/force-workspace-parser-smoke.yml index 4a1b9d9..6a98841 100644 --- a/.github/workflows/force-workspace-parser-smoke.yml +++ b/.github/workflows/force-workspace-parser-smoke.yml @@ -18,97 +18,322 @@ jobs: uses: bullfrogsec/bullfrog@v0.8.4 with: egress-policy: audit - - name: Assert parser produces EXACT expected SHA + branch on fixtures + - name: Assert extract_pairs produces EXACT expected (sha, branch) pairs on real fixtures run: | set -euo pipefail # Use the EXACT same parser as force-workspace-backport.yml. - # This block must stay in lockstep with the parser there. + # The LOCKSTEP PARSER BLOCK below (BRANCH_RE + SHA_RE + extract_pairs) must stay + # BYTE-IDENTICAL with the parse step in force-workspace-backport.yml. python3 - <<'PYEOF' - import re, sys + import re, sys, json - # Fixtures: each is (body, expected_sha, expected_branch). expected_sha=None means the - # parser MUST REJECT (ambiguous, no anchored SHA, or multi-branch) — negative cases. + # ===== LOCKSTEP PARSER BLOCK — byte-identical across force-workspace-backport.yml + this file ===== + # Anchored to Mergify's REAL "No backport have been created" failure wording (verbatim from in-prod + # aborts captured 2026-06-02). The failed bullet is "* Backport to branch `X` failed ..."; the SHA + # is introduced by "Cherry-pick of <40hex> has failed:". # - # POSITIVE fixtures use Mergify's REAL "No backport have been created" wording, captured - # verbatim from in-prod abort VyOS-Networks/vyos-1x#2116 (2026-05-15). The branch name is - # wrapped in backticks; the SHA is introduced by "Cherry-pick of <40hex> has failed:". - # If Mergify's failure-comment format ever drifts from this, THIS test goes red — which is - # the guard the prior (fictional-wording) fixtures failed to provide. + # Multi-branch abort comments are Scenario A: Mergify posts ONE combined comment for a + # "@Mergifyio backport b1 b2" command (confirmed live - vyos/vyos-documentation#2024, where BOTH + # branches fail with DIFFERENT per-branch SHAs). extract_pairs returns a LIST of (sha, branch) + # pairs: it segments the body by each failed bullet and takes the Cherry-pick SHA in each segment. + # - BRANCH_RE is LINE-ANCHORED to the literal failed bullet ("^ * Backport to branch `X` failed"), + # so a conflicting filename / prose containing "to branch `sagitta`" inside the embedded + # git-status output cannot forge a segment boundary and mis-pair a SHA (adversarial finding). + # - success bullets render "has been created for branch `X`" (no "Backport to branch ... failed") + # so they are excluded; merge-commit / branch-not-found failures carry no "Cherry-pick of " + # anchor, so their segment yields 0 SHAs and that branch is skipped (logged) - fail-closed. + # - SHAs are aggregated PER BRANCH; a branch with != 1 distinct SHA (0 = no anchor; >=2 = duplicate + # bullets or an injected fake "Cherry-pick of ") is skipped - fail-closed, never mis-staged. + # - each segment must ALSO contain the cherry-pick context "On branch mergify/bp//" matching + # ITS bullet branch, coupling the SHA to its own context so a forged bullet cannot capture another + # branch's SHA — correctness no longer depends on comment-body ordering (adversarial round-2). + BRANCH_RE = r"(?m)^[ \t>]*[-*]\s+Backport to branch\s+`?(sagitta|circinus)`?\s+failed\b" + SHA_RE = r"\b(?:[Cc]herry-pick of|Backport of)\s+(?:commit\s+)?([0-9a-f]{40})\b" + + def extract_pairs(body): + matches = list(re.finditer(BRANCH_RE, body, re.IGNORECASE)) + by_branch, order = {}, [] + for i, m in enumerate(matches): + branch = m.group(1).lower() + seg = body[m.start() : (matches[i + 1].start() if i + 1 < len(matches) else len(body))] + if not re.search(r"On branch mergify/bp/" + re.escape(branch) + r"/", seg): + print(f"::warning::skip branch {branch}: segment lacks 'On branch mergify/bp/{branch}/' cherry-pick context", file=sys.stderr) + continue + shas = set(re.findall(SHA_RE, seg, re.IGNORECASE)) + if branch not in by_branch: + by_branch[branch] = set() + order.append(branch) + by_branch[branch] |= shas + pairs = [] + for branch in order: + shas = sorted(by_branch[branch]) + if len(shas) != 1: + print(f"::warning::skip branch {branch}: {len(shas)} distinct cherry-pick SHAs across its failed bullet(s) (need exactly 1)", file=sys.stderr) + continue + pairs.append({"sha": shas[0], "branch": branch}) + return pairs + # ===== END LOCKSTEP PARSER BLOCK ===== + + # FIXTURES: real Mergify abort-comment bodies captured verbatim from the fleet on 2026-06-02. + # Each is (name, body, expected_pairs); expected_pairs is a list of (sha, branch) tuples. + # If Mergify's failure-comment format ever drifts from these, THIS test goes red - the guard the + # prior (fictional-wording) fixtures failed to provide (see PR #143). FIXTURES = [ - # Positive (real circinus abort, verbatim from #2116): - ("""#### ❌ No backport have been created - - * Backport to branch `circinus` failed due to conflicts - - Cherry-pick of 6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937 has failed: - You are currently cherry-picking commit 6df7ca2f6.""", - "6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937", "circinus"), - # Positive (real-wording sagitta variant): - ("""#### ❌ No backport have been created - - * Backport to branch `sagitta` failed due to conflicts - - Cherry-pick of abcdef1234567890abcdef1234567890abcdef12 has failed:""", - "abcdef1234567890abcdef1234567890abcdef12", "sagitta"), - # NEGATIVE: failure branch present but the only 40-hex is an UNRELATED bare SHA in a URL - # (no Cherry-pick-of/Backport-of anchor) → anchored SHA_RE finds 0 → must REJECT. - ("""No backport have been created. - * Backport to branch `sagitta` failed due to conflicts - See https://github.com/vyos/vyos-1x/commit/0000000000000000000000000000000000000000 for context.""", - None, None), - # NEGATIVE: two distinct anchored SHAs → ambiguous → must REJECT. - ("""Cherry-pick of 1111111111111111111111111111111111111111 has failed. - Cherry-pick of 2222222222222222222222222222222222222222 has failed. - * Backport to branch `circinus` failed due to conflicts""", - None, None), - # NEGATIVE: one comment naming TWO branches (e.g. `@Mergifyio backport sagitta circinus` - # where both fail) → two distinct branches → fail-closed REJECT (force_workspace acts on - # one branch per invocation; multi-branch is left to manual fallback — documented gap). - ("""#### ❌ No backport have been created - * Backport to branch `sagitta` failed due to conflicts - Cherry-pick of 6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937 has failed: - * Backport to branch `circinus` failed due to conflicts""", - None, None), - ] + # #2024 - both branches fail with conflicts in ONE comment, DIFFERENT per-branch SHAs. + ("vyos/vyos-documentation#2024 both-fail-conflict", + "\n".join([ + "> backport circinus sagitta", + "", + "#### ❌ No backport have been created", + "", + "
", + "", + "* Backport to branch `circinus` failed due to conflicts", + "", + "Cherry-pick of dec7dcb1eae0e547bca403f82b39bbb0a40a5d00 has failed:", + "```", + "On branch mergify/bp/circinus/pr-2024", + "Your branch is ahead of 'origin/circinus' by 1 commit.", + " (use \"git push\" to publish your local commits)", + "", + "You are currently cherry-picking commit dec7dcb1.", + "", + "Unmerged paths:", + " (use \"git add/rm ...\" as appropriate to mark resolution)", + "\tdeleted by us: docs/automation/terraform/terraformvyos.md", + "", + "no changes added to commit (use \"git add\" and/or \"git commit -a\")", + "```", + "", + "* Backport to branch `sagitta` failed due to conflicts", + "", + "Cherry-pick of 3c4b0d49a77e864bc20dc7b3609745912f59d585 has failed:", + "```", + "On branch mergify/bp/sagitta/pr-2024", + "Your branch is up to date with 'origin/sagitta'.", + "", + "You are currently cherry-picking commit 3c4b0d49.", + "", + "Unmerged paths:", + " (use \"git add/rm ...\" as appropriate to mark resolution)", + "\tdeleted by us: docs/cli.md", + "\tdeleted by us: docs/installation/cloud/aws.md", + "", + "no changes added to commit (use \"git add\" and/or \"git commit -a\")", + "```", + "", + "
", + ]), + [("dec7dcb1eae0e547bca403f82b39bbb0a40a5d00", "circinus"), + ("3c4b0d49a77e864bc20dc7b3609745912f59d585", "sagitta")]), - # Lockstep with force-workspace-backport.yml's parse step (keep byte-identical): - BRANCH_RE = r"to branch\s+`?(sagitta|circinus)`?\b" - SHA_RE = r"\b(?:[Cc]herry-pick of|Backport of)\s+(?:commit\s+)?([0-9a-f]{40})\b" + # #1890 - both branches fail with a merge-commit (-m) error: NO "Cherry-pick of " anchor + # (the 40-hex appears only as "error: commit is a merge"). Not force-stageable -> []. + ("vyos/vyos-documentation#1890 both-fail-merge-error", + "\n".join([ + "> backport circinus sagitta", + "", + "#### ❌ No backport have been created", + "", + "
", + "", + "* Backport to branch `circinus` failed", + "", + "Git reported the following error:", + "```", + "error: commit d1e5b5126af907215708fde817dcd309e579b725 is a merge but no -m option was given.", + "fatal: cherry-pick failed", + "```", + "", + "* Backport to branch `sagitta` failed", + "", + "Git reported the following error:", + "```", + "error: commit d1e5b5126af907215708fde817dcd309e579b725 is a merge but no -m option was given.", + "fatal: cherry-pick failed", + "```", + "", + "
", + ]), + []), + + # #2016 - partial: circinus SUCCEEDS ("has been created for branch `circinus`" -> "for branch", + # not matched), sagitta fails. Exactly 1 stageable pair (sagitta). + ("vyos/vyos-documentation#2016 partial-one-ok-one-fail", + "\n".join([ + "> backport circinus sagitta", + "", + "#### ❌ No backport have been created", + "", + "
", + "", + "* [#2025 fix(includes): rewrite need_improvement.txt as MyST (backport #2016)](https://github.com/vyos/vyos-documentation/pull/2025) has been created for branch `circinus`", + "* Backport to branch `sagitta` failed due to conflicts", + "", + "Cherry-pick of 82a06e1dc812ceba239aaabf4acd651a038bfee7 has failed:", + "```", + "On branch mergify/bp/sagitta/pr-2016", + "Your branch is up to date with 'origin/sagitta'.", + "", + "You are currently cherry-picking commit 82a06e1d.", + "", + "Unmerged paths:", + "\tboth modified: docs/_include/need_improvement.txt", + "```", + "", + "
", + ]), + [("82a06e1dc812ceba239aaabf4acd651a038bfee7", "sagitta")]), + + # #1878 - partial with a TYPO branch ("saggita" not in vocabulary; circinus success ignored) -> []. + ("vyos/vyos-documentation#1878 partial-typo-branch", + "\n".join([ + "> backport circinus saggita", + "", + "#### ❌ No backport have been created", + "", + "
", + "", + "* [#1935 troubleshooting: T8608: document TCP ping (backport #1878)](https://github.com/vyos/vyos-documentation/pull/1935) has been created for branch `circinus`", + "* Backport to branch `saggita` failed", + "", + "GitHub error: ```Branch not found```", + "", + "
", + ]), + []), + + # #2005 - both succeed -> header "Backports have been created"; only "for branch" bullets -> []. + # (In production the caller wrapper's contains(..., 'No backport have been created') never fires; + # kept here to assert the parser never produces a pair for a success comment.) + ("vyos/vyos-documentation#2005 both-success", + "\n".join([ + "> backport sagitta circinus", + "", + "#### ✅ Backports have been created", + "", + "
", + "", + "* [#2006 T8782 (backport #2005)](https://github.com/vyos/vyos-documentation/pull/2006) has been created for branch `sagitta`", + "* [#2007 T8782 (backport #2005)](https://github.com/vyos/vyos-documentation/pull/2007) has been created for branch `circinus`", + "", + "
", + ]), + []), + + # #2116 - single-branch fail (the example cited in the reusable's comments). 1 pair. + ("VyOS-Networks/vyos-1x#2116 single-fail", + "\n".join([ + "> backport circinus", + "", + "#### ❌ No backport have been created", + "", + "
", + "", + "* Backport to branch `circinus` failed due to conflicts", + "", + "Cherry-pick of 6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937 has failed:", + "```", + "On branch mergify/bp/circinus/pr-2116", + "Your branch is up to date with 'origin/circinus'.", + "", + "You are currently cherry-picking commit 6df7ca2f6.", + "```", + "", + "
", + ]), + [("6df7ca2f61ce1aa8352e8dbb1fc6e9c5d6d42937", "circinus")]), + + # FW7 (synthetic) - hardening (adversarial finding): a conflicting FILENAME containing + # "to branch `sagitta`" inside the embedded git-status output must NOT forge a segment boundary; + # only the real line-anchored circinus bullet (confirmed by its own "On branch mergify/bp/circinus/" + # context) pairs. (Unanchored matching would have treated the filename text as a branch boundary.) + ("synthetic injection-resistance (filename contains 'to branch `sagitta`')", + "\n".join([ + "> backport circinus", + "#### ❌ No backport have been created", + "
", + "", + "* Backport to branch `circinus` failed due to conflicts", + "", + "Cherry-pick of aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has failed:", + "```", + "On branch mergify/bp/circinus/pr-9999", + "Unmerged paths:", + "\tboth modified: docs/to branch `sagitta`/evil.md", + "```", + "", + "
", + ]), + [("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "circinus")]), + + # FW8 (synthetic) - defense: two failed bullets for the SAME branch with DIFFERENT SHAs is + # ambiguous (>=2 distinct SHAs aggregated for that branch) -> skip the branch -> [] (fail-closed, + # never two PRs for one branch). Mergify does not emit this, but the per-branch dedup guards it. + ("synthetic duplicate-branch conflicting SHAs", + "\n".join([ + "> backport circinus", + "#### ❌ No backport have been created", + "
", + "", + "* Backport to branch `circinus` failed due to conflicts", + "Cherry-pick of 1111111111111111111111111111111111111111 has failed:", + "```", + "On branch mergify/bp/circinus/pr-9", + "```", + "* Backport to branch `circinus` failed due to conflicts", + "Cherry-pick of 2222222222222222222222222222222222222222 has failed:", + "```", + "On branch mergify/bp/circinus/pr-9", + "```", + "", + "
", + ]), + []), + + # FW9 (synthetic, adversarial round-2) - a FORGED failed bullet for `sagitta` (no real sagitta + # cherry-pick) placed adjacent to the circinus bullet. The "On branch mergify/bp//" context + # cross-check fails-closed BOTH: circinus's segment (ending at the forged bullet) has no On-branch + # line, and the forged sagitta segment carries circinus's context, not sagitta's. -> [] (no mis-pair). + ("synthetic forged-bullet steal attempt", + "\n".join([ + "> backport circinus", + "#### ❌ No backport have been created", + "
", + "", + "* Backport to branch `circinus` failed due to conflicts", + "* Backport to branch `sagitta` failed due to conflicts", + "Cherry-pick of cccccccccccccccccccccccccccccccccccccccc has failed:", + "```", + "On branch mergify/bp/circinus/pr-9", + "```", + "", + "
", + ]), + []), + ] - # Lockstep with the reusable's parser: set-based with exactly-one-distinct assertion - # (anchored/ambiguity-safe). Keep identical to force-workspace-backport.yml's parse step. failed = False - for body, expected_sha, expected_branch in FIXTURES: - shas = sorted(set(re.findall(SHA_RE, body, re.IGNORECASE))) - branches = sorted({m.lower() for m in re.findall(BRANCH_RE, body, re.IGNORECASE)}) - rejected = (len(shas) != 1 or len(branches) != 1) - if expected_sha is None: - # Negative fixture: the parser MUST reject (ambiguous / no anchored commit SHA). - if rejected: - print(f"OK (negative): correctly rejected — shas={shas} branches={branches}") - else: - print(f"FAIL (negative): expected rejection but extracted sha={shas} branch={branches}", file=sys.stderr) - failed = True - continue - if rejected: - print(f"FAIL: ambiguous/none — shas={shas} branches={branches}", file=sys.stderr) - failed = True - continue - if shas[0] != expected_sha: - print(f"FAIL: sha mismatch — expected {expected_sha}, got {shas[0]}", file=sys.stderr) - failed = True - continue - if branches[0] != expected_branch: - print(f"FAIL: branch mismatch — expected {expected_branch}, got {branches[0]}", file=sys.stderr) + for name, body, expected in FIXTURES: + got = extract_pairs(body) + got_norm = sorted((p["sha"], p["branch"]) for p in got) + exp_norm = sorted(expected) + # Shape contract: the producer/consumer contract that fromJSON(pairs) + matrix.pair.sha / + # matrix.pair.branch consume - compact JSON round-trips, every element has exactly {sha,branch}. + serialized = json.dumps(got, separators=(",", ":")) + roundtrip = json.loads(serialized) + shape_ok = all(set(p.keys()) == {"sha", "branch"} for p in roundtrip) + if got_norm == exp_norm and shape_ok: + print(f"OK: {name} -> {got_norm}") + else: + print(f"FAIL: {name} -> got={got_norm} expected={exp_norm} shape_ok={shape_ok}", file=sys.stderr) failed = True - continue - print(f"OK: sha={shas[0]} branch={branches[0]}") if failed: sys.exit(1) + print("All fixtures passed.") PYEOF - # v7: Slack notify step REMOVED — SLACK_BOT_TOKEN / SLACK_VYOS_INFRA_CHANNEL_ID are not - # provisioned in either org (verified 2026-06-01). The smoke job's own red run status on - # `schedule` is the drift alert (visible in the Actions tab + any repo-level failure - # notifications). When the GHA-failure-notifications initiative provisions Slack secrets, - # wire a notify step here at that point (it would gate `if: failure() && secrets present`). + # Slack notify step REMOVED - SLACK_BOT_TOKEN / SLACK_VYOS_INFRA_CHANNEL_ID are not provisioned in + # either org (verified 2026-06-01). The smoke job's own red run status on `schedule` is the drift + # alert (visible in the Actions tab + repo-level failure notifications). Wire a notify step here when + # the GHA-failure-notifications initiative provisions Slack secrets (gated on if: failure() + secrets).