diff --git a/.github/scripts/intake-triage-legs.test.cjs b/.github/scripts/intake-triage-legs.test.cjs index 5c75287c..0dfdabf1 100644 --- a/.github/scripts/intake-triage-legs.test.cjs +++ b/.github/scripts/intake-triage-legs.test.cjs @@ -118,7 +118,7 @@ test("every model job tells its launcher whether the fallback token is set, neve } }); -test("the fallback job runs on a fixed GitHub-hosted label only after the subscription job hands off", () => { +test("the fallback job runs on a fixed GitHub-hosted label only after the subscription job hands off or finds its token empty with the fallback token set", () => { assert.equal(fallback.name, "claude-intake-triage-fallback"); assert.equal(fallback["runs-on"], "ubuntu-24.04"); assert.deepEqual(fallback.needs, ["claude", "subscription"]); @@ -132,7 +132,24 @@ test("the fallback job runs on a fixed GitHub-hosted label only after the subscr JSON.stringify(handoff), ); } - // An empty subscription token is not a handoff: no fallback. + // An empty subscription token hands on as an empty key does, but only to + // a fallback token that is set (claude-code-plugins#13586). + assert.equal( + starts({ + handoff: "", + "credential-present": "false", + "fallback-ready": "true", + }), + true, + ); + assert.equal( + starts({ + handoff: "", + "credential-present": "false", + "fallback-ready": "false", + }), + false, + ); assert.equal(starts({ handoff: "", "credential-present": "false" }), false); assert.doesNotMatch(fallback.if, /always\(\)|failure\(\)|cancelled\(\)/u); for (const step of fallback.steps) { @@ -149,7 +166,8 @@ test("the fallback job runs on a fixed GitHub-hosted label only after the subscr const FALLBACK_LEG_REASON = `\${{ needs.subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || ` + "needs.subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || " + - "needs.subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }}"; + "needs.subscription.outputs.handoff-reason == 'auth' && 'auth' || " + + "needs.subscription.outputs.credential-present == 'false' && 'no-key' || '' }}"; test("the fallback job repeats the subscription job's steps but for the credential and the fallback reason", () => { const strip = (steps, secret, input) => @@ -189,6 +207,16 @@ test("the fallback job repeats the subscription job's steps but for the credenti }); for (const known of ["limit-stop", "credit-stop", "auth"]) assert.equal(reason(known), known); + assert.equal( + evaluate(value, { + needs: { + subscription: { + outputs: { "handoff-reason": "", "credential-present": "false" }, + }, + }, + }), + "no-key", + ); for (const forged of ["", "no-key", "Limit-Stop", "limit-stop\n::error::x"]) { assert.equal(reason(forged), "", JSON.stringify(forged)); } diff --git a/.github/scripts/pr-review-cli.test.cjs b/.github/scripts/pr-review-cli.test.cjs index 76d210a6..2002abbf 100644 --- a/.github/scripts/pr-review-cli.test.cjs +++ b/.github/scripts/pr-review-cli.test.cjs @@ -174,6 +174,28 @@ test("the fallback job starts only after the subscription job succeeded and hand for (const result of ["failure", "cancelled", "skipped"]) { assert.equal(starts({ handoff: "true" }, result), false, result); } + // An empty subscription token hands on as an empty key does, but only to a + // fallback token that is set (claude-code-plugins#13586). + assert.equal( + starts({ + handoff: "", + "credential-present": "false", + "fallback-ready": "true", + }), + true, + ); + assert.equal( + starts({ + handoff: "", + "credential-present": "false", + "fallback-ready": "false", + }), + false, + ); + assert.equal( + starts({ handoff: "", "credential-present": "", "fallback-ready": "true" }), + false, + ); // A fork, a draft or Dependabot never reaches it either. assert.equal( Boolean( @@ -198,7 +220,8 @@ test("the fallback job starts only after the subscription job succeeded and hand const FALLBACK_LEG_REASON = `\${{ needs.review-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || ` + "needs.review-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || " + - "needs.review-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }}"; + "needs.review-subscription.outputs.handoff-reason == 'auth' && 'auth' || " + + "needs.review-subscription.outputs.credential-present == 'false' && 'no-key' || '' }}"; test("the fallback job repeats the subscription job's steps but for the credential and the fallback reason", () => { const strip = (steps, secret, input) => diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index d30eba77..c8da218d 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -379,6 +379,7 @@ jobs: handoff-reason: ${{ steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} triage-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class }} result-subtype: ${{ steps.extract.outputs.result-subtype }} @@ -1132,6 +1133,7 @@ jobs: handoff-reason: ${{ steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} triage-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class }} result-subtype: ${{ steps.extract.outputs.result-subtype }} @@ -1870,7 +1872,9 @@ jobs: fallback: name: claude-intake-triage-fallback needs: [claude, subscription] - if: needs.subscription.outputs.handoff == 'true' + if: >- + needs.subscription.outputs.handoff == 'true' + || needs.subscription.outputs.credential-present == 'false' && needs.subscription.outputs.fallback-ready == 'true' runs-on: ubuntu-24.04 timeout-minutes: 19 # The model's job reads and never writes: the token can read the issue @@ -1885,6 +1889,7 @@ jobs: handoff-reason: ${{ steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} triage-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class }} result-subtype: ${{ steps.extract.outputs.result-subtype }} @@ -2495,7 +2500,7 @@ jobs: known-failure-class: ${{ steps.claude.outputs.lane-class }} handoff: ${{ steps.claude.outputs.handoff }} # Why this job ran, for this leg's lane record. - fallback-reason: ${{ needs.subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.subscription.outputs.credential-present == 'false' && 'no-key' || '' }} # After the outcome step: its timeout check reads the clock when it # runs, so time spent here must not count against a failed leg. diff --git a/.github/workflows/pr-review-cli.yml b/.github/workflows/pr-review-cli.yml index bfbe53db..86131b9d 100644 --- a/.github/workflows/pr-review-cli.yml +++ b/.github/workflows/pr-review-cli.yml @@ -502,6 +502,7 @@ jobs: handoff-reason: ${{ steps.claude-4.outputs.handoff-reason || steps.claude-3.outputs.handoff-reason || steps.claude-2.outputs.handoff-reason || steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} review-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude-4.outputs.lane-class || steps.claude-3.outputs.lane-class || steps.claude-2.outputs.lane-class || steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class || ((steps.isolate.outcome == 'failure' || steps.isolate.outcome == 'cancelled') && 'install-failed') || '' }} verdict: ${{ steps.extract.outputs.verdict }} @@ -2620,6 +2621,7 @@ jobs: handoff-reason: ${{ steps.claude-4.outputs.handoff-reason || steps.claude-3.outputs.handoff-reason || steps.claude-2.outputs.handoff-reason || steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} review-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude-4.outputs.lane-class || steps.claude-3.outputs.lane-class || steps.claude-2.outputs.lane-class || steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class || ((steps.isolate.outcome == 'failure' || steps.isolate.outcome == 'cancelled') && 'install-failed') || '' }} verdict: ${{ steps.extract.outputs.verdict }} @@ -4683,7 +4685,9 @@ jobs: timeout-minutes: ${{ inputs.lane == 'security' && 117 || 109 }} if: >- needs.review-subscription.result == 'success' - && needs.review-subscription.outputs.handoff == 'true' + && (needs.review-subscription.outputs.handoff == 'true' + || needs.review-subscription.outputs.credential-present == 'false' + && needs.review-subscription.outputs.fallback-ready == 'true') && !cancelled() && github.event_name == 'pull_request' @@ -4710,6 +4714,7 @@ jobs: handoff-reason: ${{ steps.claude-4.outputs.handoff-reason || steps.claude-3.outputs.handoff-reason || steps.claude-2.outputs.handoff-reason || steps.claude.outputs.handoff-reason }} credential: ${{ steps.claude.outputs.credential }} credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} review-failed: ${{ steps.review-outcome.outputs.review-failed }} failure-class: ${{ steps.claude-4.outputs.lane-class || steps.claude-3.outputs.lane-class || steps.claude-2.outputs.lane-class || steps.claude.outputs.lane-class || steps.review-outcome.outputs.failure-class || ((steps.isolate.outcome == 'failure' || steps.isolate.outcome == 'cancelled') && 'install-failed') || '' }} verdict: ${{ steps.extract.outputs.verdict }} @@ -6699,7 +6704,7 @@ jobs: known-failure-class: ${{ steps.claude-4.outputs.lane-class || steps.claude-3.outputs.lane-class || steps.claude-2.outputs.lane-class || steps.claude.outputs.lane-class }} handoff: ${{ steps.claude-4.outputs.handoff || steps.claude-3.outputs.handoff || steps.claude-2.outputs.handoff || steps.claude.outputs.handoff }} # Why this job ran, for this leg's lane record. - fallback-reason: ${{ needs.review-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.review-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.review-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.review-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.review-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.review-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.review-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} # After the outcome step: its timeout check reads the clock when it # runs, so time spent here must not count against a failed leg.