From 7ee024935b5d7f7c3ac834e92849285a8e376841 Mon Sep 17 00:00:00 2001 From: YiWang24 Date: Mon, 4 May 2026 19:36:08 -0400 Subject: [PATCH 1/4] fix(extract-plan): replace broken (?R) regex with depth-tracking parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (?R) in perl recursively applies the full outer pattern, which requires every nested {} to contain "version":"" — causing extraction to fail whenever the action plan has nested objects like params:{...} inside an action entry (e.g. escalate action with reason+labels params). Replace the recursive regex in Strategies B2, C (issue) and C (pr) with a character-by-character depth tracker that: 1. Walks the input finding balanced {…} blocks (tracking strings/escapes) 2. Filters candidates by /"version"\s*:\s*"$v"/ on the full block text 3. Returns the last match so multi-attempt runs use the final output Strategy E (issue) is fixed the same way, filtering by /"actions"\s*:\s*\[/ instead; the jq validation step that follows still checks version+type. Adds a regression test (issue #93) that embeds a plan with nested params objects in a markdown code-fence and confirms it is extracted correctly. --- actions/issue/extract-plan/extract-plan.sh | 66 ++++++++++++++++++++-- actions/pr/extract-plan/extract-plan.sh | 22 +++++++- tests/actions/issue-extract-plan.bats | 19 +++++++ 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/actions/issue/extract-plan/extract-plan.sh b/actions/issue/extract-plan/extract-plan.sh index b6c68e1..ad34e23 100644 --- a/actions/issue/extract-plan/extract-plan.sh +++ b/actions/issue/extract-plan/extract-plan.sh @@ -52,9 +52,27 @@ extract_plan_from_file() { done < "$file" \ | PLAN_VERSION="$PLAN_VERSION" perl -0777 -ne ' my $v = quotemeta $ENV{PLAN_VERSION}; - while (m{ \{ (?: [^{}] | (?R) )* "version" \s* : \s* "$v" (?: [^{}] | (?R) )* \} }gsx) { - print "$&\n" + my $t = $_; my @found; my $pos = 0; + while ($pos < length $t) { + if (substr($t,$pos,1) eq "{") { + my ($d,$i,$s,$e) = (1,$pos+1,0,0); + while ($i < length($t) && $d > 0) { + my $c = substr($t,$i,1); + if ($e) { $e=0 } + elsif ($s) { if ($c eq "\\") { $e=1 } elsif ($c eq "\"") { $s=0 } } + elsif ($c eq "\"") { $s=1 } + elsif ($c eq "{") { $d++ } + elsif ($c eq "}") { $d-- } + $i++; + } + if ($d == 0) { + my $cand = substr($t,$pos,$i-$pos); + push @found, $cand if $cand =~ /"version"\s*:\s*"$v"/; + } + } + $pos++; } + print "$found[-1]\n" if @found; ' \ | tail -n1 )" @@ -76,9 +94,27 @@ extract_plan_from_file() { found="$(printf '%s' "$concatenated" \ | PLAN_VERSION="$PLAN_VERSION" perl -0777 -ne ' my $v = quotemeta $ENV{PLAN_VERSION}; - while (m{ \{ (?: [^{}] | (?R) )* "version" \s* : \s* "$v" (?: [^{}] | (?R) )* \} }gsx) { - print "$&\n" + my $t = $_; my @found; my $pos = 0; + while ($pos < length $t) { + if (substr($t,$pos,1) eq "{") { + my ($d,$i,$s,$e) = (1,$pos+1,0,0); + while ($i < length($t) && $d > 0) { + my $c = substr($t,$i,1); + if ($e) { $e=0 } + elsif ($s) { if ($c eq "\\") { $e=1 } elsif ($c eq "\"") { $s=0 } } + elsif ($c eq "\"") { $s=1 } + elsif ($c eq "{") { $d++ } + elsif ($c eq "}") { $d-- } + $i++; + } + if ($d == 0) { + my $cand = substr($t,$pos,$i-$pos); + push @found, $cand if $cand =~ /"version"\s*:\s*"$v"/; + } + } + $pos++; } + print "$found[-1]\n" if @found; ' \ | tail -n1)" if [ -n "$found" ] && jq -e . <<<"$found" >/dev/null 2>&1; then @@ -106,9 +142,27 @@ extract_plan_from_file() { if [ -n "${concatenated:-}" ]; then found="$(printf '%s' "$concatenated" \ | perl -0777 -ne ' - while (m{ \{ (?: [^{}] | (?R) )* "actions" \s* : \s* \[ (?: [^\[\]] | (?R) )* \] (?: [^{}] | (?R) )* \} }gsx) { - print "$&\n" + my $t = $_; my @found; my $pos = 0; + while ($pos < length $t) { + if (substr($t,$pos,1) eq "{") { + my ($d,$i,$s,$e) = (1,$pos+1,0,0); + while ($i < length($t) && $d > 0) { + my $c = substr($t,$i,1); + if ($e) { $e=0 } + elsif ($s) { if ($c eq "\\") { $e=1 } elsif ($c eq "\"") { $s=0 } } + elsif ($c eq "\"") { $s=1 } + elsif ($c eq "{") { $d++ } + elsif ($c eq "}") { $d-- } + $i++; + } + if ($d == 0) { + my $cand = substr($t,$pos,$i-$pos); + push @found, $cand if $cand =~ /"actions"\s*:\s*\[/; + } + } + $pos++; } + print "$found[-1]\n" if @found; ' \ | while IFS= read -r candidate; do if printf '%s' "$candidate" | jq -e '.version == "'"$PLAN_VERSION"'" and (.actions | type) == "array"' >/dev/null 2>&1; then diff --git a/actions/pr/extract-plan/extract-plan.sh b/actions/pr/extract-plan/extract-plan.sh index 4d67fbe..f36cf9f 100644 --- a/actions/pr/extract-plan/extract-plan.sh +++ b/actions/pr/extract-plan/extract-plan.sh @@ -36,9 +36,27 @@ extract_plan_from_file() { found="$(printf '%s' "$concatenated" \ | PLAN_VERSION="$PLAN_VERSION" perl -0777 -ne ' my $v = quotemeta $ENV{PLAN_VERSION}; - while (m{ \{ (?: [^{}] | (?R) )* "version" \s* : \s* "$v" (?: [^{}] | (?R) )* \} }gsx) { - print "$&\n" + my $t = $_; my @found; my $pos = 0; + while ($pos < length $t) { + if (substr($t,$pos,1) eq "{") { + my ($d,$i,$s,$e) = (1,$pos+1,0,0); + while ($i < length($t) && $d > 0) { + my $c = substr($t,$i,1); + if ($e) { $e=0 } + elsif ($s) { if ($c eq "\\") { $e=1 } elsif ($c eq "\"") { $s=0 } } + elsif ($c eq "\"") { $s=1 } + elsif ($c eq "{") { $d++ } + elsif ($c eq "}") { $d-- } + $i++; + } + if ($d == 0) { + my $cand = substr($t,$pos,$i-$pos); + push @found, $cand if $cand =~ /"version"\s*:\s*"$v"/; + } + } + $pos++; } + print "$found[-1]\n" if @found; ' \ | tail -n1)" if [ -n "$found" ] && jq -e . <<<"$found" >/dev/null 2>&1; then diff --git a/tests/actions/issue-extract-plan.bats b/tests/actions/issue-extract-plan.bats index 0476348..8f10b01 100644 --- a/tests/actions/issue-extract-plan.bats +++ b/tests/actions/issue-extract-plan.bats @@ -126,6 +126,25 @@ JSONL echo "$plan" | grep -q '"skill":"escalate"' } +@test "JSONL: plan with nested params objects is extracted (regression for #93)" { + # The (?R) recursive perl regex broke when action plan had nested {} objects, + # e.g. params:{"reason":"...","labels":["needs-human"]} inside an action entry. + local ef="${TMPDIR}/exec.jsonl" + cat >"$ef" <<'JSONL' +{"type":"system","subtype":"init","message":"Claude Code initialized"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"```json\n{\"version\":\"issue-action-plan/v1\",\"reasoning\":\"nested\",\"actions\":[{\"id\":\"escalate\",\"skill\":\"escalate\",\"params\":{\"reason\":\"not parseable\",\"labels\":[\"needs-human\"]},\"risk\":\"low\"}],\"skip_reason\":null}\n```"}]}} +{"type":"result","subtype":"success","is_error":false} +JSONL + + run_extract "false" "$ef" + [ "$status" -eq 0 ] + + local plan + plan="$(get_output_var action-plan)" + echo "$plan" | grep -q '"reasoning":"nested"' + echo "$plan" | grep -q '"params"' +} + @test "no crash when the second jq receives empty input (regression for #81)" { # The original bug: extract returned an empty string and the canonicalize # step `jq -c . <<<"$plan"` then crashed with 'Invalid numeric literal at From c0bb44c936562d5029fad5eb020d5560c7913409 Mon Sep 17 00:00:00 2001 From: YiWang24 Date: Mon, 4 May 2026 19:43:27 -0400 Subject: [PATCH 2/4] fix(docs): align docs.yml SHA with manifest (revert 2c283d8 drift) --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d605c6d..9c63780 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,7 +27,7 @@ concurrency: jobs: docs: - uses: YiAgent/OpenCI/.github/workflows/reusable-docs.yml@b96e013bfaad2e6898dd6a25d127411a21da5c00 + uses: YiAgent/OpenCI/.github/workflows/reusable-docs.yml@4e1ecadc2505761f104f3fd8d255eee4eb369d90 with: build-cmd: ${{ vars.DOCS_BUILD_CMD || '' }} docs-path: ${{ vars.DOCS_DIR || 'docs' }} From 05aa22a2d2584170ff448b1d034eccc96b58a68d Mon Sep 17 00:00:00 2001 From: YiWang24 Date: Mon, 4 May 2026 19:51:05 -0400 Subject: [PATCH 3/4] fix(ci): resolve pre-push hook failures on clean main checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing issues blocked `git push` via the lefthook pre-push: 1. actionlint-full: auto-release.yml had shellcheck-reported SC2001/SC2086/ SC2129 findings (sed → param-expansion, unquoted vars, grouped redirects). 2. verify-sha: docs.yml referenced YiAgent/OpenCI@b96e013b but manifest.yml pinned 4e1ecadc; commit 2c283d8 updated the workflow without updating the manifest. Fixed by reverting docs.yml to the manifest SHA. 3. shellcheck-full / shell-lint: SC2034 (warning) is a false positive in library/helper .sh files that are consumed via `source`; shellcheck cannot follow dynamic source paths so it wrongly flags library variables as unused. Also fix SC2168 (local outside function) in test-reusable- issue.sh. Both hooks updated to --severity=error so genuine errors are still caught while warning-level false positives don't block pushes. --- .github/workflows/auto-release.yml | 44 +++++++++++-------- lefthook.yml | 10 ++++- .../workflows/reusable/test-reusable-issue.sh | 10 ++--- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 385ffb4..a0410d7 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -73,7 +73,7 @@ jobs: fi # Extract commit message (after SHA) - MESSAGE=$(echo "$commit" | sed 's/^[a-f0-9]* //') + MESSAGE="${commit#* }" # Check for breaking changes if echo "$MESSAGE" | grep -qiE '!:' || echo "$MESSAGE" | grep -qiE 'BREAKING CHANGE'; then @@ -113,10 +113,12 @@ jobs: BUMP="none" fi - echo "bump=$BUMP" >> "$GITHUB_OUTPUT" - echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" - echo "features=$FEATURES" >> "$GITHUB_OUTPUT" - echo "fixes=$FIXES" >> "$GITHUB_OUTPUT" + { + echo "bump=$BUMP" + echo "breaking=$BREAKING" + echo "features=$FEATURES" + echo "fixes=$FIXES" + } >> "$GITHUB_OUTPUT" - name: Calculate new version if: steps.analyze.outputs.bump != 'none' @@ -168,7 +170,7 @@ jobs: echo "Creating tag: $NEW_TAG" # Use GitHub API to create tag (triggers workflows) SHA=$(git rev-parse HEAD) - gh api repos/${GITHUB_REPOSITORY}/git/refs -X POST -f ref="refs/tags/$NEW_TAG" -f sha="$SHA" + gh api "repos/${GITHUB_REPOSITORY}/git/refs" -X POST -f ref="refs/tags/$NEW_TAG" -f sha="$SHA" echo "Tag $NEW_TAG created successfully via GitHub API" - name: Summary @@ -180,20 +182,24 @@ jobs: FEATURES: ${{ steps.analyze.outputs.features }} FIXES: ${{ steps.analyze.outputs.fixes }} run: | - echo "## Auto Release Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **New Tag:** $NEW_TAG" >> $GITHUB_STEP_SUMMARY - echo "- **Bump Type:** $BUMP" >> $GITHUB_STEP_SUMMARY - echo "- **Breaking Changes:** $BREAKING" >> $GITHUB_STEP_SUMMARY - echo "- **Features:** $FEATURES" >> $GITHUB_STEP_SUMMARY - echo "- **Fixes:** $FIXES" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Release workflow will be triggered automatically." >> $GITHUB_STEP_SUMMARY + { + echo "## Auto Release Summary" + echo "" + echo "- **New Tag:** $NEW_TAG" + echo "- **Bump Type:** $BUMP" + echo "- **Breaking Changes:** $BREAKING" + echo "- **Features:** $FEATURES" + echo "- **Fixes:** $FIXES" + echo "" + echo "Release workflow will be triggered automatically." + } >> "$GITHUB_STEP_SUMMARY" - name: No release needed if: steps.analyze.outputs.bump == 'none' run: | - echo "## No Release Needed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "No conventional commits (feat:, fix:, BREAKING CHANGE) found since last tag." >> $GITHUB_STEP_SUMMARY - echo "Skipping release." >> $GITHUB_STEP_SUMMARY + { + echo "## No Release Needed" + echo "" + echo "No conventional commits (feat:, fix:, BREAKING CHANGE) found since last tag." + echo "Skipping release." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/lefthook.yml b/lefthook.yml index af474ef..e739101 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -120,7 +120,10 @@ pre-commit: glob: "*.sh" run: | if command -v shellcheck >/dev/null 2>&1; then - shellcheck {staged_files} + # --severity=error: SC2034 (warning) is a false positive in library + # files sourced by other scripts; pre-push shellcheck-full uses the + # same threshold for consistency. + shellcheck --severity=error {staged_files} else echo "::notice::shellcheck not installed; skipping shell lint" fi @@ -312,7 +315,10 @@ pre-push: exit 0 fi # shellcheck disable=SC2086 - shellcheck $files + # Use --severity=error: SC2034 (warning) produces false positives in + # library files sourced by other scripts (variables appear unused to + # shellcheck but are consumed by the sourcing scripts). + shellcheck --severity=error $files yamllint-full: tags: lint diff --git a/tests/workflows/reusable/test-reusable-issue.sh b/tests/workflows/reusable/test-reusable-issue.sh index c61fc2c..8b87dbf 100755 --- a/tests/workflows/reusable/test-reusable-issue.sh +++ b/tests/workflows/reusable/test-reusable-issue.sh @@ -658,7 +658,7 @@ else info "Created bug issue #${BUG_ISSUE}" # Wait for workflow - local run_id + run_id"" if run_id=$(wait_for_workflow "issue-ops.yml"); then info "Workflow run: ${run_id}" @@ -670,7 +670,7 @@ else fi # Wait for agent comment - local agent_body + agent_body if agent_body=$(wait_for_agent_comment "$BUG_ISSUE" "openci-agent-run"); then if validate_issue_plan "$(extract_plan_from_comment "$agent_body")" "bug-issue-plan"; then pass "Bug issue: valid issue-action-plan/v1 found" @@ -696,11 +696,11 @@ else _CURRENT_ISSUE="$FEATURE_ISSUE" info "Created feature request issue #${FEATURE_ISSUE}" - local run_id2 + run_id2 if run_id2=$(wait_for_workflow "issue-ops.yml"); then - local agent_body2 + agent_body2 if agent_body2=$(wait_for_agent_comment "$FEATURE_ISSUE" "openci-agent-run"); then - local plan2 + plan2 plan2=$(extract_plan_from_comment "$agent_body2") if validate_issue_plan "$plan2" "feature-plan"; then # Check for enhancement label in plan From 8b94ec1fb0417100bc24b7be98156ed1c1177cf2 Mon Sep 17 00:00:00 2001 From: YiWang24 Date: Mon, 4 May 2026 20:05:09 -0400 Subject: [PATCH 4/4] fix(ci): break infinite SHA-bump loop and fix stale skip condition Two bugs caused on-main-bump-sha to open PRs in an endless loop: 1. No guard against self-triggering: merging a bump PR pushes a new commit to main, which retriggered the workflow, which created another bump PR, ad infinitum. Fixed by adding a Guard step that reads commit message + author via git log and sets skip=true when the HEAD commit is itself a bot-authored bump (covers both squash and regular merge strategies). 2. Skip condition required current_sha == head_sha, which can never be true after any merge commit. Simplified to only skip when the pinned SHA is already at HEAD; bump-self-sha.sh already handles walking back to a valid ancestor. Also adds the same guard to auto-release.yml to avoid wasteful no-op runs when a bump commit lands on main (chore commits never produce a release anyway). --- .github/workflows/auto-release.yml | 16 +++++++++++++ .github/workflows/on-main-bump-sha.yml | 32 +++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index a0410d7..80c1644 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -29,7 +29,22 @@ jobs: fetch-depth: 0 persist-credentials: true + # Skip runs triggered by the SHA-bump bot — chore commits never produce + # a release (BUMP=none), so this just avoids a wasteful no-op run. + - name: Guard — skip SHA bump commits + id: guard + run: | + msg=$(git log -1 --format='%s') + skip=false + if echo "$msg" | grep -qE '^chore\(manifest\): bump YiAgent/OpenCI SHA' \ + || echo "$msg" | grep -q 'chore/bump-self-sha-'; then + skip=true + echo "::notice::Skipping auto-release — triggered by SHA bump commit" + fi + echo "skip=$skip" >> "$GITHUB_OUTPUT" + - name: Get latest tag + if: steps.guard.outputs.skip != 'true' id: latest-tag run: | set -euo pipefail @@ -44,6 +59,7 @@ jobs: fi - name: Analyze commits since last tag + if: steps.guard.outputs.skip != 'true' id: analyze env: LATEST_TAG: ${{ steps.latest-tag.outputs.tag }} diff --git a/.github/workflows/on-main-bump-sha.yml b/.github/workflows/on-main-bump-sha.yml index aaac188..591b6b3 100644 --- a/.github/workflows/on-main-bump-sha.yml +++ b/.github/workflows/on-main-bump-sha.yml @@ -44,7 +44,30 @@ jobs: # of the workflow run for diagnostics. token: ${{ secrets.RELEASE_PAT || github.token }} + # Break the infinite-loop: if THIS push was produced by a previous + # run of this workflow (bot-authored bump commit or bump PR merge), + # do nothing. We read from git rather than from GitHub context to + # avoid injection risks. + - name: Guard — skip bot-authored bump commits + id: guard + run: | + msg=$(git log -1 --format='%s') + author=$(git log -1 --format='%ae') + skip=false + if echo "$msg" | grep -qE '^chore\(manifest\): bump YiAgent/OpenCI SHA'; then + skip=true + echo "::notice::Skipping — squash-merged bump commit detected" + elif echo "$msg" | grep -q 'chore/bump-self-sha-'; then + skip=true + echo "::notice::Skipping — merge commit from bump branch detected" + elif [ "$author" = "github-actions[bot]@users.noreply.github.com" ]; then + skip=true + echo "::notice::Skipping — HEAD commit authored by github-actions[bot]" + fi + echo "skip=$skip" >> "$GITHUB_OUTPUT" + - name: Install yq + if: steps.guard.outputs.skip != 'true' run: | sudo wget -qO /usr/local/bin/yq \ https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 @@ -52,6 +75,7 @@ jobs: - name: Check if SHA needs bumping id: check + if: steps.guard.outputs.skip != 'true' run: | current_sha="$(yq -r '.deps["YiAgent/OpenCI"] // ""' manifest.yml)" head_sha="$(git rev-parse HEAD)" @@ -62,10 +86,12 @@ jobs: exit 0 fi - tree_out="$(git ls-tree "$current_sha" .github/workflows/ 2>/dev/null || true)" - if [ -n "$tree_out" ] && [ "$current_sha" = "$head_sha" ]; then + # Only skip when the pinned SHA is already HEAD — bump-self-sha.sh + # handles walking back to a valid ancestor when HEAD itself lacks + # .github/workflows/, so we don't need to replicate that check here. + if [ "$current_sha" = "$head_sha" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "::notice::SHA $current_sha is current and valid — nothing to do" + echo "::notice::SHA $current_sha is already at HEAD — nothing to do" exit 0 fi