From 54c174dfe21c51f6d9671247a89b3554e6cb1624 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:00:01 -0500 Subject: [PATCH 1/2] Check that no caller can leave a release sitting as a draft Deleting a tag does not delete its GitHub release -- it reverts it to a draft. The release keeps its body and assets and stops being visible, and the state outlives the tag: laranail/validation still holds v1.0.0 and v1.0.1 as drafts from tags withdrawn on 2026-08-24, neither of which exists on the remote any more. `gh release edit` leaves that flag exactly as it finds it, so an edit after a tag is deleted and re-pushed republishes nothing on a run that still goes green: correct tag, invisible release. Nothing else reports it -- the tag resolves, composer installs, and the tag-currency job next door passes, because none of them reads the release. It was found by reading one, in laranail/validation-js, whose release had to be published by hand. The check is laranail/package-tools' scripts/verify-release-publish.py and it follows verify-action-pins.py exactly, including the path argument that makes it point at the caller rather than at package-tools. It needs no token, because it is a text scan. Most callers pass it by finding nothing: they use softprops/action-gh-release, which publishes an existing draft on its own. That is why the script carries its own matcher controls, and why the header comment now says so -- a check whose clean answer is an empty result can lie by simply stopping matching. --- .github/workflows/hygiene.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/hygiene.yml b/.github/workflows/hygiene.yml index ea0d306..f988a45 100644 --- a/.github/workflows/hygiene.yml +++ b/.github/workflows/hygiene.yml @@ -12,6 +12,14 @@ name: Hygiene (reusable) # package tests -- they need `gh` and a token, both of which every runner # already has. Requiring an install here would add a dependency, a bootstrap # problem for package-tools checking itself, and minutes to 37 repositories. +# +# `release-publish` is the odd one out: most repositories pass it by finding +# NOTHING, because they create and update their release with +# `softprops/action-gh-release`, which publishes an existing draft on its own. +# Only a hand-rolled `gh release edit` can leave one as a draft. A check whose +# clean answer is an empty result can lie by simply stopping matching, so that +# script drives its own matcher against built-in controls on every invocation +# and refuses to report anything when one of them misbehaves. on: workflow_call: @@ -32,6 +40,10 @@ on: description: Verify every path composer.json references survives the archive. type: boolean default: true + run-release-publish: + description: Verify no workflow can leave a GitHub release sitting as a draft. + type: boolean + default: true php-version: description: PHP version for the dist-integrity check. type: string @@ -70,6 +82,34 @@ jobs: env: GH_TOKEN: ${{ github.token }} + release-publish: + name: Release publishing + if: inputs.run-release-publish + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out the calling repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: repo + persist-credentials: false + + - name: Check out the shared checks + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: laranail/package-tools + ref: ${{ inputs.checks-ref }} + path: checks + persist-credentials: false + + # The path argument, for the same reason as action-pins above: without it the + # script resolves its target from its own __file__ and would check + # package-tools' own workflows rather than the caller's -- silently passing. + # No token either, unlike the other two jobs: this one is a text scan. + - name: No workflow can leave a release as a draft + run: python3 checks/scripts/verify-release-publish.py "${GITHUB_WORKSPACE}/repo" + tag-currency: name: Release currency # Never on a pull request: a PR that adds commits always makes a release due, From 446d4329bdf3e84aa3f2a980f82b297334f50b6c Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:14:15 -0500 Subject: [PATCH 2/2] Run the audit tests when a draft is marked ready `pull_request` with no `types` defaults to opened, synchronize and reopened, and the job skips a draft. So a pull request opened as a draft never gets this check: marking it ready re-triggers nothing, the skipped run stands, and the pull request can merge with its only job never having run. Measured on this very pull request, which is a draft deliberately -- it must not merge before laranail/package-tools#93. Marking it ready left `audit script tests` showing "skipping" from the draft run. Adding `ready_for_review` is additive: every event that triggered the workflow before still does. --- .github/workflows/audit-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/audit-tests.yml b/.github/workflows/audit-tests.yml index 4d0d8b7..1d72a0a 100644 --- a/.github/workflows/audit-tests.yml +++ b/.github/workflows/audit-tests.yml @@ -4,7 +4,13 @@ name: Audit tests # audit or its exemption list is checked before it lands. Needs no token. on: + # `ready_for_review` is not one of pull_request's default types (opened, + # synchronize, reopened), and the job below skips a draft. Without it a pull + # request opened as a draft never gets this check at all: marking it ready + # re-triggers nothing, so the skipped run stands and the PR merges unchecked. + # Measured on #23, which was opened as a draft deliberately. pull_request: + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: