diff --git a/.github/actions/dispatch-live-proofs/action.yml b/.github/actions/dispatch-live-proofs/action.yml new file mode 100644 index 0000000000..01e234867d --- /dev/null +++ b/.github/actions/dispatch-live-proofs/action.yml @@ -0,0 +1,92 @@ +name: Dispatch recommended live proofs +description: Dispatch enabled live-proof plans from durably published review records. +inputs: + target-repo: + description: Repository whose review records should be inspected. + required: true + item-numbers: + description: Comma-separated item numbers whose records were published. + required: true + records-root: + description: Root directory containing canonical review records. + required: true + client-id: + description: ClawSweeper GitHub App client ID. + required: true + private-key: + description: ClawSweeper GitHub App private key. + required: true +runs: + using: composite + steps: + - name: Select recommended live proofs + id: candidates + shell: bash + env: + ITEM_NUMBERS: ${{ inputs.item-numbers }} + RECORDS_ROOT: ${{ inputs.records-root }} + TARGET_REPO: ${{ inputs.target-repo }} + run: | + set -euo pipefail + candidates="$RUNNER_TEMP/clawsweeper-live-proof-candidates.jsonl" + : > "$candidates" + echo "path=$candidates" >> "$GITHUB_OUTPUT" + if [ ! -d "$RECORDS_ROOT" ]; then + echo "No records root exists at $RECORDS_ROOT; live-proof dispatch is a no-op." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -z "${ITEM_NUMBERS//[[:space:],]/}" ]; then + echo "No published item numbers were provided; live-proof dispatch is a no-op." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ ! -f dist/repair/live-proof-dispatch-candidates.js ]; then + echo "dispatch-live-proofs requires dist/repair/live-proof-dispatch-candidates.js; run the build:repair script before this action." >&2 + exit 1 + fi + pnpm run --silent repair:live-proof-candidates > "$candidates" + if [ ! -s "$candidates" ]; then + echo "No published review requested an enabled live proof." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "available=true" >> "$GITHUB_OUTPUT" + + - name: Create live-proof dispatch token + id: dispatch-token + if: ${{ steps.candidates.outputs.available == 'true' }} + continue-on-error: true + uses: ./.github/actions/create-target-write-token + with: + client-id: ${{ inputs.client-id }} + private-key: ${{ inputs.private-key }} + owner: openclaw + repository: clawsweeper + + - name: Dispatch recommended live proofs + if: ${{ steps.candidates.outputs.available == 'true' && steps.dispatch-token.outcome == 'success' && steps.dispatch-token.outputs.token != '' }} + shell: bash + env: + CANDIDATES_PATH: ${{ steps.candidates.outputs.path }} + GH_TOKEN: ${{ steps.dispatch-token.outputs.token }} + TARGET_REPO: ${{ inputs.target-repo }} + run: | + set -euo pipefail + while IFS= read -r candidate; do + item="$(jq -r '.item' <<<"$candidate")" + plan="$(jq -c '.plan' <<<"$candidate")" + jq -n \ + --arg repo "$TARGET_REPO" \ + --arg item "$item" \ + --argjson plan "$plan" \ + '{ + event_type: "clawsweeper_live_proof", + client_payload: { + repo: $repo, + item: $item, + live_proof_plan: $plan + } + }' | gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches" --input - + echo "Dispatched live proof for $TARGET_REPO#$item." + done < "$CANDIDATES_PATH" diff --git a/.github/workflows/exact-review-batch-publish.yml b/.github/workflows/exact-review-batch-publish.yml index 365a55fd54..baa7f43e48 100644 --- a/.github/workflows/exact-review-batch-publish.yml +++ b/.github/workflows/exact-review-batch-publish.yml @@ -29,6 +29,8 @@ jobs: }} runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + live_proof_matrix: ${{ steps.live-proof-dispatch-matrix.outputs.matrix }} env: EXACT_REVIEW_QUEUE_URL: ${{ vars.CLAWSWEEPER_EXACT_REVIEW_QUEUE_URL || 'https://clawsweeper.openclaw.ai' }} CLAWSWEEPER_AUTO_CLOSE_REASONS: ${{ vars.CLAWSWEEPER_AUTO_CLOSE_REASONS || 'all' }} @@ -404,6 +406,32 @@ jobs: pnpm run --silent repair:exact-review-batch observe || true pnpm run --silent repair:exact-review-batch complete + - name: Compose live-proof dispatch matrix from durable batch receipt + id: live-proof-dispatch-matrix + if: ${{ steps.batch.outputs.claimed == 'true' && steps.batch.outputs.item_count != '0' }} + run: | + set -euo pipefail + receipt_path="$(dirname "$EXACT_REVIEW_BATCH_MANIFEST")/state-receipt.json" + matrix="$(jq -c ' + [ + .outcomes[]? + | select(.outcome == "accepted" or .outcome == "deduped") + | .canonicalTargetKey + | capture("^(?[^#]+)#(?[1-9][0-9]*)$") + ] + | unique_by(.target_repo, .item_number) + | sort_by(.target_repo, (.item_number | tonumber)) + | group_by(.target_repo) + | { + include: map({ + target_repo: .[0].target_repo, + target_slug: (.[0].target_repo | gsub("[^A-Za-z0-9_.-]+"; "-")), + item_numbers: (map(.item_number) | join(",")) + }) + } + ' "$receipt_path")" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + - name: Submit batch GitHub egress telemetry if: ${{ always() && steps.github-egress-observer.outcome == 'success' }} continue-on-error: true @@ -417,3 +445,44 @@ jobs: env: CLAWSWEEPER_WEBHOOK_SECRET: ${{ secrets.CLAWSWEEPER_WEBHOOK_SECRET }} run: pnpm run --silent repair:exact-review-batch release + + dispatch-live-proofs: + name: Dispatch live proofs for ${{ matrix.target_repo }} + needs: publish + if: ${{ needs.publish.outputs.live_proof_matrix != '' && needs.publish.outputs.live_proof_matrix != '{"include":[]}' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.publish.outputs.live_proof_matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093 + steps: + - uses: actions/checkout@v7 + with: + ref: main + filter: blob:none + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup-pnpm + with: + build-script: build:repair + + - uses: ./.github/actions/setup-state + with: + coordinator-url: ${{ vars.CLAWSWEEPER_EXACT_REVIEW_QUEUE_URL || 'https://clawsweeper.openclaw.ai' }} + records-url: ${{ vars.CLAWSWEEPER_EXACT_REVIEW_QUEUE_URL || 'https://clawsweeper.openclaw.ai' }} + records-secret: ${{ secrets.CLAWSWEEPER_WEBHOOK_SECRET }} + records-repo-slugs: ${{ matrix.target_slug }} + hydrate-git-state: "false" + hydrate-state-blobs: "false" + + - name: Dispatch recommended live proofs + uses: ./.github/actions/dispatch-live-proofs + with: + target-repo: ${{ matrix.target_repo }} + item-numbers: ${{ matrix.item_numbers }} + records-root: records + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index b704f41253..77cf3a4bc0 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -1433,48 +1433,15 @@ jobs: EXACT_REVIEW_BATCH_MUTATION_OUTPUT: .artifacts/direct-publication-outcome.json run: pnpm run --silent repair:publish-event-result - - name: Create live-proof dispatch token - id: live-proof-dispatch-token + - name: Dispatch recommended live proofs if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.prepare-direct-exact-review-publication.outcome == 'success' }} - continue-on-error: true - uses: ./.github/actions/create-target-write-token + uses: ./.github/actions/dispatch-live-proofs with: + target-repo: ${{ steps.target.outputs.target_repo }} + item-numbers: ${{ steps.target.outputs.item_number }} + records-root: records client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} - owner: openclaw - repository: clawsweeper - - - name: Dispatch recommended live proofs - if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.prepare-direct-exact-review-publication.outcome == 'success' && steps.live-proof-dispatch-token.outcome == 'success' && steps.live-proof-dispatch-token.outputs.token != '' }} - env: - GH_TOKEN: ${{ steps.live-proof-dispatch-token.outputs.token }} - TARGET_REPO: ${{ steps.target.outputs.target_repo }} - ITEM_NUMBER: ${{ steps.target.outputs.item_number }} - run: | - set -euo pipefail - item_numbers="$ITEM_NUMBER" - ITEM_NUMBERS="$item_numbers" RECORDS_ROOT=records pnpm run --silent repair:live-proof-candidates > /tmp/live-proof-candidates.jsonl - if [ ! -s /tmp/live-proof-candidates.jsonl ]; then - echo "No published review requested an enabled live proof." - exit 0 - fi - while IFS= read -r candidate; do - item="$(jq -r '.item' <<<"$candidate")" - plan="$(jq -c '.plan' <<<"$candidate")" - jq -n \ - --arg repo "$TARGET_REPO" \ - --arg item "$item" \ - --argjson plan "$plan" \ - '{ - event_type: "clawsweeper_live_proof", - client_payload: { - repo: $repo, - item: $item, - live_proof_plan: $plan - } - }' | gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches" --input - - echo "Dispatched live proof for $TARGET_REPO#$item." - done < /tmp/live-proof-candidates.jsonl - name: Post direct exact review publication result if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.prepare-direct-exact-review-publication.outcome == 'success' }} @@ -2629,6 +2596,16 @@ jobs: fi rm -f "$publication_error" + - name: Dispatch recommended live proofs + if: ${{ steps.publication-context.outputs.claimed == 'true' && steps.publication-context.outputs.direct_lifecycle_recovery != 'true' && steps.validate-exact-review-bundle.outcome == 'success' && steps.legacy-exact-artifact.outputs.legacy_tupleless != 'true' && steps.publish-event-result.outcome == 'success' && (steps.publish-event-result.outputs.remote_tuple_verified == 'true' || (steps.publish-event-result.outputs.completion_kind == 'deferred' && steps.publish-event-result.outputs.reason_code == 'close_coverage_deferred')) }} + uses: ./.github/actions/dispatch-live-proofs + with: + target-repo: ${{ steps.publication-context.outputs.target_repo }} + item-numbers: ${{ steps.publication-context.outputs.item_number }} + records-root: records + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + - name: Record fallback canonical exact review lifecycle receipt id: record-fallback-canonical-lifecycle-receipt if: ${{ steps.publication-context.outputs.claimed == 'true' && steps.publication-context.outputs.direct_lifecycle_recovery != 'true' && steps.publish-event-result.outcome == 'success' && (steps.publish-event-result.outputs.remote_tuple_verified == 'true' || (steps.publish-event-result.outputs.completion_kind == 'deferred' && steps.publish-event-result.outputs.reason_code == 'close_coverage_deferred')) }} @@ -4788,36 +4765,23 @@ jobs: --artifact-dir artifacts \ --report-dir "records/$target_slug/items" - - name: Dispatch recommended live proofs + - name: Collect published review item numbers + id: published-review-items if: ${{ always() && !cancelled() && steps.commit-review-records.outputs.records_published == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - TARGET_REPO: ${{ needs.plan.outputs.target_repo }} run: | set -euo pipefail item_numbers="$(pnpm run --silent workflow -- artifact-item-numbers --artifact-dir artifacts)" - ITEM_NUMBERS="$item_numbers" RECORDS_ROOT=records pnpm run --silent repair:live-proof-candidates > /tmp/live-proof-candidates.jsonl - if [ ! -s /tmp/live-proof-candidates.jsonl ]; then - echo "No published review requested an enabled live proof." - exit 0 - fi - while IFS= read -r candidate; do - item="$(jq -r '.item' <<<"$candidate")" - plan="$(jq -c '.plan' <<<"$candidate")" - jq -n \ - --arg repo "$TARGET_REPO" \ - --arg item "$item" \ - --argjson plan "$plan" \ - '{ - event_type: "clawsweeper_live_proof", - client_payload: { - repo: $repo, - item: $item, - live_proof_plan: $plan - } - }' | gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches" --input - - echo "Dispatched live proof for $TARGET_REPO#$item." - done < /tmp/live-proof-candidates.jsonl + echo "item_numbers=$item_numbers" >> "$GITHUB_OUTPUT" + + - name: Dispatch recommended live proofs + if: ${{ always() && !cancelled() && steps.commit-review-records.outputs.records_published == 'true' && steps.published-review-items.outcome == 'success' }} + uses: ./.github/actions/dispatch-live-proofs + with: + target-repo: ${{ needs.plan.outputs.target_repo }} + item-numbers: ${{ steps.published-review-items.outputs.item_numbers }} + records-root: records + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} - name: Dispatch vision-fit implementation candidates if: ${{ always() && !cancelled() && steps.commit-review-records.outputs.records_published == 'true' && vars.CLAWSWEEPER_AUTO_IMPLEMENT_ISSUES == '1' && needs.plan.outputs.target_repo == 'openclaw/openclaw' && vars.CLAWSWEEPER_AUTO_IMPLEMENT_VISION_FIT == '1' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index db31b0477d..23ae148598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ checkpoint, and status-only commits are intentionally omitted. ### Fixed +- Routed every durable review-record publication lane through one shared, host-authenticated live-proof dispatcher, including queued exact-review batches grouped per target repository. - Exact-event reviews now dispatch recommended live proofs with host-repository credentials, while generic and configured OpenClaw and steipete profiles opt into browser or terminal proof as appropriate. - Hosted webhook 🦞👀 receipts now dedupe per pull request across `opened` and `ready_for_review`, so back-to-back webhook actions keep one receipt instead of posting near-identical duplicates. (#1084) - The target dispatcher no longer double-posts pull request receipt acknowledgements when `opened` and `ready_for_review` fire seconds apart: the ack step now waits and rechecks for any existing marker immediately before posting. (#1083) diff --git a/test/live-proof.test.ts b/test/live-proof.test.ts index 93eddbc9ff..71aad63f52 100644 --- a/test/live-proof.test.ts +++ b/test/live-proof.test.ts @@ -336,6 +336,68 @@ test("live-proof workflow keeps execute secretless and attach trusted", () => { assert.match(source, /setsid timeout --kill-after=30s 1500s/); assert.match(source, /--record \.\.\/live-proof-report\.md/); assert.doesNotMatch(source, /--plan \.\.\/live-proof/); + + const dispatchActionSource = readFileSync( + ".github/actions/dispatch-live-proofs/action.yml", + "utf8", + ); + const dispatchAction = YAML.parse(dispatchActionSource) as { + inputs: Record; + runs: { + steps: Array<{ + id?: string; + if?: string; + uses?: string; + with?: Record; + run?: string; + }>; + }; + }; + assert.deepEqual(Object.keys(dispatchAction.inputs), [ + "target-repo", + "item-numbers", + "records-root", + "client-id", + "private-key", + ]); + assert.ok(Object.values(dispatchAction.inputs).every((input) => input.required === true)); + const candidateStep = dispatchAction.runs.steps.find((step) => step.id === "candidates"); + const tokenStep = dispatchAction.runs.steps.find((step) => step.id === "dispatch-token"); + const dispatchStep = dispatchAction.runs.steps.at(-1); + assert.match(candidateStep?.run ?? "", /\[ ! -d "\$RECORDS_ROOT" \]/); + assert.match(candidateStep?.run ?? "", /\[ -z "\$\{ITEM_NUMBERS\/\//); + assert.match( + candidateStep?.run ?? "", + /\[ ! -f dist\/repair\/live-proof-dispatch-candidates\.js \]/, + ); + assert.match(candidateStep?.run ?? "", /pnpm run --silent repair:live-proof-candidates/); + assert.equal(tokenStep?.uses, "./.github/actions/create-target-write-token"); + assert.equal(tokenStep?.with?.owner, "openclaw"); + assert.equal(tokenStep?.with?.repository, "clawsweeper"); + assert.match(dispatchStep?.run ?? "", /event_type: "clawsweeper_live_proof"/); + assert.match(dispatchStep?.run ?? "", /repos\/\$GITHUB_REPOSITORY\/dispatches/); + const noOpFixture = mkdtempSync(join(tmpdir(), "clawsweeper-live-proof-dispatch-noop-")); + const runCandidateStep = (recordsRoot: string, itemNumbers: string, outputName: string) => { + const outputPath = join(noOpFixture, outputName); + const result = spawnSync("bash", ["-c", candidateStep?.run ?? ""], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + ITEM_NUMBERS: itemNumbers, + RECORDS_ROOT: recordsRoot, + RUNNER_TEMP: noOpFixture, + TARGET_REPO: "openclaw/clawsweeper", + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(outputPath, "utf8"), /available=false/); + }; + runCandidateStep(join(noOpFixture, "missing-records"), "42", "missing-root-output.txt"); + const emptyRecords = join(noOpFixture, "records"); + mkdirSync(emptyRecords); + runCandidateStep(emptyRecords, " , ", "missing-items-output.txt"); + const sweep = readFileSync(".github/workflows/sweep.yml", "utf8"); const sweepWorkflow = YAML.parse(sweep) as { jobs: Record< @@ -361,36 +423,112 @@ test("live-proof workflow keeps execute secretless and attach trusted", () => { (step) => step.name === "Dispatch recommended live proofs", ); const directDispatch = directSteps[directDispatchIndex]; - const dispatchToken = directSteps.find((step) => step.id === "live-proof-dispatch-token"); assert.ok(directDeliveryIndex >= 0); assert.ok(directDispatchIndex > directDeliveryIndex); - assert.equal(dispatchToken?.uses, "./.github/actions/create-target-write-token"); - assert.equal(dispatchToken?.with?.owner, "openclaw"); - assert.equal(dispatchToken?.with?.repository, "clawsweeper"); + assert.equal(directDispatch?.uses, "./.github/actions/dispatch-live-proofs"); assert.match( directDispatch?.if ?? "", /prepare-direct-exact-review-publication\.outcome == 'success'/, ); - assert.equal( - directDispatch?.env?.GH_TOKEN, - "${{ steps.live-proof-dispatch-token.outputs.token }}", + assert.equal(directDispatch?.with?.["target-repo"], "${{ steps.target.outputs.target_repo }}"); + assert.equal(directDispatch?.with?.["item-numbers"], "${{ steps.target.outputs.item_number }}"); + + const fallbackSteps = sweepWorkflow.jobs["event-review-publish"]?.steps ?? []; + const fallbackPublicationIndex = fallbackSteps.findIndex( + (step) => step.name === "Publish event result and apply safe close", ); - assert.equal(directDispatch?.env?.ITEM_NUMBER, "${{ steps.target.outputs.item_number }}"); - assert.match( - directDispatch?.run ?? "", - /ITEM_NUMBERS="\$item_numbers" RECORDS_ROOT=records pnpm run --silent repair:live-proof-candidates/, + const fallbackDispatchIndex = fallbackSteps.findIndex( + (step) => step.uses === "./.github/actions/dispatch-live-proofs", + ); + assert.ok(fallbackPublicationIndex >= 0); + assert.ok(fallbackDispatchIndex > fallbackPublicationIndex); + assert.match(fallbackSteps[fallbackDispatchIndex]?.if ?? "", /remote_tuple_verified/); + + const scheduledSteps = sweepWorkflow.jobs.publish?.steps ?? []; + const scheduledPublicationIndex = scheduledSteps.findIndex( + (step) => step.id === "commit-review-records", + ); + const scheduledDispatchIndex = scheduledSteps.findIndex( + (step) => step.uses === "./.github/actions/dispatch-live-proofs", + ); + assert.ok(scheduledPublicationIndex >= 0); + assert.ok(scheduledDispatchIndex > scheduledPublicationIndex); + assert.equal( + scheduledSteps[scheduledDispatchIndex]?.with?.["item-numbers"], + "${{ steps.published-review-items.outputs.item_numbers }}", ); - assert.doesNotMatch(directDispatch?.run ?? "", /dist\/clawsweeper\.js/); - assert.match(directDispatch?.run ?? "", /event_type: "clawsweeper_live_proof"/); - assert.match(directDispatch?.run ?? "", /repos\/\$GITHUB_REPOSITORY\/dispatches/); assert.equal( Object.values(sweepWorkflow.jobs) .flatMap((job) => job.steps) - .filter((step) => step.name === "Dispatch recommended live proofs").length, - 2, + .filter((step) => step.uses === "./.github/actions/dispatch-live-proofs").length, + 3, + ); + + const batchWorkflow = YAML.parse( + readFileSync(".github/workflows/exact-review-batch-publish.yml", "utf8"), + ) as { + jobs: Record< + string, + { + needs?: string; + strategy?: { matrix?: string }; + steps: Array<{ id?: string; uses?: string; run?: string; with?: Record }>; + } + >; + }; + const matrixStep = batchWorkflow.jobs.publish?.steps.find( + (step) => step.id === "live-proof-dispatch-matrix", ); - assert.equal(sweep.match(/pnpm run --silent repair:live-proof-candidates/g)?.length, 2); - assert.doesNotMatch(sweep, /node --input-type=module <<'NODE' > \/tmp\/live-proof-candidates/); + assert.match(matrixStep?.run ?? "", /\.outcome == "accepted" or \.outcome == "deduped"/); + assert.match(matrixStep?.run ?? "", /group_by\(\.target_repo\)/); + const matrixFixture = mkdtempSync(join(tmpdir(), "clawsweeper-live-proof-matrix-")); + const matrixOutput = join(matrixFixture, "github-output.txt"); + writeFileSync( + join(matrixFixture, "state-receipt.json"), + JSON.stringify({ + outcomes: [ + { outcome: "accepted", canonicalTargetKey: "openclaw/second#10" }, + { outcome: "retryable", canonicalTargetKey: "openclaw/ignored#3" }, + { outcome: "deduped", canonicalTargetKey: "openclaw/second#2" }, + { outcome: "accepted", canonicalTargetKey: "openclaw/first#7" }, + ], + }), + ); + const matrixResult = spawnSync("bash", ["-c", matrixStep?.run ?? ""], { + encoding: "utf8", + env: { + ...process.env, + EXACT_REVIEW_BATCH_MANIFEST: join(matrixFixture, "manifest.json"), + GITHUB_OUTPUT: matrixOutput, + }, + }); + assert.equal(matrixResult.status, 0, matrixResult.stderr); + assert.deepEqual(JSON.parse(readFileSync(matrixOutput, "utf8").trim().slice("matrix=".length)), { + include: [ + { + target_repo: "openclaw/first", + target_slug: "openclaw-first", + item_numbers: "7", + }, + { + target_repo: "openclaw/second", + target_slug: "openclaw-second", + item_numbers: "2,10", + }, + ], + }); + const batchDispatchJob = batchWorkflow.jobs["dispatch-live-proofs"]; + assert.equal(batchDispatchJob?.needs, "publish"); + assert.equal( + batchDispatchJob?.strategy?.matrix, + "${{ fromJSON(needs.publish.outputs.live_proof_matrix) }}", + ); + const batchDispatch = batchDispatchJob?.steps.find( + (step) => step.uses === "./.github/actions/dispatch-live-proofs", + ); + assert.equal(batchDispatch?.with?.["target-repo"], "${{ matrix.target_repo }}"); + assert.equal(batchDispatch?.with?.["item-numbers"], "${{ matrix.item_numbers }}"); + const candidateSource = readFileSync("src/repair/live-proof-dispatch-candidates.ts", "utf8"); assert.match(candidateSource, /profile\.liveTest\?\.enabled/); assert.match(candidateSource, /plan\.status === "recommended"/); diff --git a/test/repair/workflow-sparse-checkout-helpers.ts b/test/repair/workflow-sparse-checkout-helpers.ts index a8dd8fd4f7..b3cd1eb04a 100644 --- a/test/repair/workflow-sparse-checkout-helpers.ts +++ b/test/repair/workflow-sparse-checkout-helpers.ts @@ -57,9 +57,10 @@ function packageScripts(): Record { function resolveScriptClosure(script: string): Set { const scripts = packageScripts(); const value = script.trim().replace(/^["']|["']$/g, ""); + const expressionScripts = [...script.matchAll(/["']([\w:.-]+)["']/g)].map((match) => match[1]!); const pending = /^\/(.+)\/$/.exec(value) ? Object.keys(scripts).filter((name) => new RegExp(/^\/(.+)\/$/.exec(value)![1]!).test(name)) - : [value]; + : [value, ...expressionScripts]; const closure = new Set(); while (pending.length > 0) { const name = pending.pop(); diff --git a/test/repair/workflow-sparse-checkout.test.ts b/test/repair/workflow-sparse-checkout.test.ts index 1d09bb5425..db437d0052 100644 --- a/test/repair/workflow-sparse-checkout.test.ts +++ b/test/repair/workflow-sparse-checkout.test.ts @@ -27,6 +27,7 @@ const REPAIR_RUNTIME_PATHS = [ const MAIN_BUNDLE = "dist/clawsweeper.js"; const RUNTIME_DIST_ARTIFACT = "clawsweeper-runtime-dist"; +const DISPATCH_LIVE_PROOFS_ACTION = "./.github/actions/dispatch-live-proofs"; test("repair planning and execution use a Node runtime accepted by current OpenClaw", () => { const workflow = parse( @@ -127,6 +128,77 @@ test("every workflow job that runs the main bundle directly obtains it", () => { assert.ok(audited.length > 0, `no job invoking ${MAIN_BUNDLE} was audited`); }); +test("every workflow job that runs the repair bundle through dispatch-live-proofs obtains it", () => { + const audited: string[] = []; + for (const workflowPath of fs.globSync(".github/workflows/*.yml").sort()) { + const workflow = parse(fs.readFileSync(workflowPath, "utf8")) as { + jobs?: Record }[] }>; + }; + for (const [jobName, job] of Object.entries(workflow.jobs ?? {})) { + const steps = job.steps ?? []; + if (!steps.some((step) => step.uses === DISPATCH_LIVE_PROOFS_ACTION)) continue; + const site = `${workflowPath}:${jobName}`; + audited.push(site); + const buildScripts = steps + .filter((step) => String(step.uses ?? "").includes("actions/setup-pnpm")) + .map((step) => String(step.with?.["build-script"] ?? "")); + assert.ok( + buildScripts.some(buildScriptEmitsRepairBundle), + `${site} invokes ${DISPATCH_LIVE_PROOFS_ACTION} but no build-script emits the repair bundle: ${JSON.stringify(buildScripts)}`, + ); + } + } + assert.equal(audited.length, 4, JSON.stringify(audited)); +}); + +test("every durable review-record publication lane invokes dispatch-live-proofs", () => { + const publicationSites: string[] = []; + for (const workflowPath of fs.globSync(".github/workflows/*.yml").sort()) { + const workflow = parse(fs.readFileSync(workflowPath, "utf8")) as { + jobs?: Record< + string, + { + needs?: unknown; + steps?: { id?: unknown; uses?: unknown; run?: unknown }[]; + } + >; + }; + const jobs = workflow.jobs ?? {}; + for (const [jobName, job] of Object.entries(jobs)) { + const steps = job.steps ?? []; + const runs = steps.map((step) => String(step.run ?? "")); + const directPublication = runs.some( + (run) => + run.includes("repair:publish-event-result") || + run.includes("repair:exact-review-batch commit"), + ); + const artifactPublication = + runs.some((run) => run.includes("pnpm run apply-artifacts")) && + runs.some((run) => /repair:publish-main[\s\S]*--path ["']records\//.test(run)); + if (!directPublication && !artifactPublication) continue; + const site = `${workflowPath}:${jobName}`; + publicationSites.push(site); + const dispatchesInJob = steps.some( + (candidate) => candidate.uses === DISPATCH_LIVE_PROOFS_ACTION, + ); + const dispatchesInDependentJob = Object.values(jobs).some((candidate) => { + const needs = Array.isArray(candidate.needs) ? candidate.needs : [candidate.needs]; + return ( + needs.includes(jobName) && + (candidate.steps ?? []).some( + (candidateStep) => candidateStep.uses === DISPATCH_LIVE_PROOFS_ACTION, + ) + ); + }); + assert.ok( + dispatchesInJob || dispatchesInDependentJob, + `${site} durably publishes review records without ${DISPATCH_LIVE_PROOFS_ACTION}`, + ); + } + } + assert.equal(publicationSites.length, 4, JSON.stringify(publicationSites)); +}); + test("state-hydrating sparse repair workflows keep hydration dependencies", () => { for (const workflowPath of [ ".github/workflows/repair-comment-router.yml",