diff --git a/.github/scripts/tests/verify-pull-request-policy.spec.mjs b/.github/scripts/tests/verify-pull-request-policy.spec.mjs index 10754c8..538a4ca 100644 --- a/.github/scripts/tests/verify-pull-request-policy.spec.mjs +++ b/.github/scripts/tests/verify-pull-request-policy.spec.mjs @@ -64,6 +64,12 @@ test("blocking steps have ids, continue on error and guarded applicability", () } assert.match(step("intent"), /inputs.release-intent && github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main'/); assert.match(step("notes"), /github.event_name == 'pull_request'/); + // #93: an unlabelled opened run waits in progress so the labeled run cancels it, and only then reads labels. + assert.match(step("label-grace"), /inputs.release-intent && github.event_name == 'pull_request' && github.event.action == 'opened'/); + assert.match(step("label-grace"), /toJSON\(github.event.pull_request.labels\) == '\[\]'/); + assert.match(step("label-grace"), /user.login != 'dependabot\[bot\]'/); + assert.match(step("label-grace"), /\n {8}run: sleep 20$/); + assert(steps.indexOf(step("label-grace")) < steps.indexOf(step("pull")), "waits before the live read"); assert.match(step("pull"), /github.event_name == 'pull_request'/); assert.match(step("checkout"), /persist-credentials: false/); assert.doesNotMatch(step("checkout"), /\n\s+ref:/); diff --git a/.github/scripts/tests/verify-release-intent.spec.mjs b/.github/scripts/tests/verify-release-intent.spec.mjs index 42265a4..c1c4d3a 100644 --- a/.github/scripts/tests/verify-release-intent.spec.mjs +++ b/.github/scripts/tests/verify-release-intent.spec.mjs @@ -121,6 +121,16 @@ test("a Dependabot pull request passes with a notice when the token cannot write assert.equal(run(INTENT, { live: pull(["major", "patch"], "someone"), readOnly: true }).status, 1); }); +test("an unlabelled opened pull request waits before the gate reads labels, so a labeled run supersedes it", () => { + const grace = INTENT.indexOf("Wait briefly for labels added right after opening"); + const gate = INTENT.indexOf("Require exactly one release-intent label"); + assert(grace > 0 && grace < gate); + const block = INTENT.slice(grace, gate); + assert.match(block, /github.event.action == 'opened' && toJSON\(github.event.pull_request.labels\) == '\[\]'/); + assert.match(block, /user.login != 'dependabot\[bot\]'/); + assert.match(block, /run: sleep 20\n/); +}); + test("the gate sets no permissions of its own, so the caller's pull-requests: read reaches it", () => { assert.equal(/^\s*permissions:/m.test(INTENT), false); assert(INTENT.includes("GH_TOKEN: ${{ github.token }}")); diff --git a/.github/workflows/verify-pull-request-policy.yml b/.github/workflows/verify-pull-request-policy.yml index 8baeebf..4e03f10 100644 --- a/.github/workflows/verify-pull-request-policy.yml +++ b/.github/workflows/verify-pull-request-policy.yml @@ -75,6 +75,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # A pull request opened without labels is often labelled seconds later (gh pr create --label, or a click + # right after opening). Waiting here keeps this run in progress, so the labeled run cancels it through the + # caller's concurrency group instead of leaving a stale failing release-intent check (Cratis/Workflows#93). + - name: Wait briefly for labels added right after opening + id: label-grace + if: ${{ !cancelled() && inputs.release-intent && github.event_name == 'pull_request' && github.event.action == 'opened' && github.event.pull_request.base.ref == 'main' && toJSON(github.event.pull_request.labels) == '[]' && github.event.pull_request.user.login != 'dependabot[bot]' }} + run: sleep 20 - name: Read the pull request as it is now id: pull if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.tools.outcome == 'success' }} diff --git a/.github/workflows/verify-release-intent.yml b/.github/workflows/verify-release-intent.yml index 6f330fb..c9a5932 100644 --- a/.github/workflows/verify-release-intent.yml +++ b/.github/workflows/verify-release-intent.yml @@ -42,6 +42,12 @@ jobs: timeout-minutes: 15 steps: + # A pull request opened without labels is often labelled seconds later (gh pr create --label, or a click + # right after opening). Waiting keeps this run in progress, so the labeled run cancels it through the + # caller's concurrency group instead of leaving a stale failing check (Cratis/Workflows#93). + - name: Wait briefly for labels added right after opening + if: ${{ github.event.action == 'opened' && toJSON(github.event.pull_request.labels) == '[]' && github.event.pull_request.user.login != 'dependabot[bot]' }} + run: sleep 20 - name: Require exactly one release-intent label env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 99ceb3f..eb20a92 100644 --- a/README.md +++ b/README.md @@ -770,6 +770,9 @@ The caller runs on pull-request events `opened`, `edited`, `reopened`, `synchron `labeled`, `unlabeled`, `ready_for_review`, and pushes to `main`, with one cancelling concurrency group per PR/ref. Push events run only the work-record policy. Intent applies only to PRs targeting `main` and can be disabled with `release-intent: false`. +When such a PR is opened without labels, the intent check waits 20 seconds before reading +them, so a label added right after opening cancels that run instead of leaving a stale +failure; `verify-release-intent.yml` does the same. Notes retain the default-branch, release-label and Dependabot rules below. The reusable declares no permissions; callers grant `contents: read` and `pull-requests: read`. Releasing repositories also have a separate