From 3b48a54069a7d7a3e7a5dd9a4d1f15f0ad862ed3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sat, 7 Nov 2026 23:07:31 -0500 Subject: [PATCH] Publish the release when a re-pushed tag leaves it as a draft Deleting a tag reverts its GitHub release to a draft -- it keeps its body and assets and stops being visible -- and `gh release edit` leaves that flag exactly as it finds it. So an edit after a tag is deleted and re-pushed goes green having republished nothing: correct tag, invisible release. This repository shows the residue of that mechanism already. The v1.0.0 and v1.0.1 releases have been drafts since those tags were withdrawn, and they outlived the tags themselves. It was found as a live failure in laranail/validation-js, whose release had to be published by hand. Both editing paths now pass --draft=false: release.yml when it updates an existing release, and release-benchmark.yml when it injects the table, which runs by workflow_dispatch too and so can reach a draft. Nothing else reports that state. The tag resolves, composer installs and the tag-currency check passes, because none of them reads the release -- so the guard reads every workflow file rather than these two call sites, counts the files, and requires at least two commands so a call site that disappears fails too. The first comment in release.yml said v0.1.0 is force-pushed on every change. v0.1.1 was cut on 2026-11-02, so that premise no longer holds; the idempotence it argued for still does, for a re-pushed tag. --- .github/workflows/release-benchmark.yml | 7 ++- .github/workflows/release.yml | 19 ++++--- CHANGELOG.md | 14 +++++ docs/release.md | 19 +++++++ tests/ReleaseWorkflowTest.php | 68 +++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/ReleaseWorkflowTest.php diff --git a/.github/workflows/release-benchmark.yml b/.github/workflows/release-benchmark.yml index b90eeb2..17d4fd7 100644 --- a/.github/workflows/release-benchmark.yml +++ b/.github/workflows/release-benchmark.yml @@ -70,4 +70,9 @@ jobs: cat benchmark-result.md >> current-body.md printf '%s\n' "$END" >> current-body.md - gh release edit "$TAG" --notes-file current-body.md + # --draft=false: this job also runs by workflow_dispatch, which can reach a release + # that is a DRAFT -- deleting a tag reverts its release to one, and this repository + # holds two such orphans from the withdrawn v1.0.x tags. `gh release edit` leaves the + # flag exactly as it finds it, so injecting the table without this would leave the + # release invisible on a run that still goes green. + gh release edit "$TAG" --draft=false --notes-file current-body.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fd784f..b29d58a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,18 +61,25 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # v0.1.0 is a moving tag -- it is force-pushed on every change, so this - # workflow re-fires against a release that already exists. `gh release - # create` refuses outright in that case ("a release with the same tag - # name already exists: v0.1.0") and exits 1, which turns every routine - # tag move into a red run and buries any real release failure. + # A re-pushed tag re-fires this workflow against a release that already + # exists. `gh release create` refuses outright in that case ("a release + # with the same tag name already exists: v0.1.0") and exits 1, which would + # turn that into a red run and bury any real release failure. # # Editing when it exists is what laranail/password-tools already does. # The SBOM has to be re-uploaded explicitly on that path: `gh release # edit` does not take assets, and without --clobber the upload fails on # the asset name already being present. + # + # --draft=false is load-bearing, not a restatement of the default. DELETING a tag + # reverts its release to a DRAFT, and `gh release edit` leaves that flag exactly as it + # finds it -- so an edit after a tag is deleted and re-pushed goes GREEN while + # republishing nothing: correct tag, invisible release. This repository already holds + # two such orphans: the v1.0.0 and v1.0.1 releases, still drafts since those tags were + # withdrawn. Measured as a live failure in laranail/validation-js on 2026-11-08, whose + # release had to be published by hand. if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then - gh release edit "$GITHUB_REF_NAME" --notes-file release-body.md + gh release edit "$GITHUB_REF_NAME" --draft=false --notes-file release-body.md gh release upload "$GITHUB_REF_NAME" sbom.cdx.json --clobber else gh release create "$GITHUB_REF_NAME" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5bfb1..b501a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se ## [Unreleased] +### Fixed + +- **A re-pushed tag no longer leaves its GitHub release unpublished.** Deleting a tag reverts its + release to a *draft* — it keeps its body and assets and stops being visible — and `gh release edit` + leaves that flag exactly as it finds it. So an edit after a tag is deleted and re-pushed goes + **green** having republished nothing: correct tag, invisible release. Nothing else reports that + state either, because the tag resolves, `composer` installs and `composer tag-currency` passes + without any of them reading the release. Both editing workflows now pass `--draft=false` — + `release.yml` on its update path and `release-benchmark.yml` when it injects the benchmark table, + which also runs by `workflow_dispatch` and so can reach a draft. `tests/ReleaseWorkflowTest.php` + fails if any workflow's `gh release edit` drops it. Measured as a live failure in + `laranail/validation-js` on 2026-11-08; this repository shows the residue of the same mechanism in + its `v1.0.0` and `v1.0.1` releases, still drafts since those tags were withdrawn. + ## v0.1.1 - 2026-11-02 ### Changed diff --git a/docs/release.md b/docs/release.md index 12ae803..68695d0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -104,6 +104,25 @@ Publishing the release (`release: released`) fires three workflows: table to land, and benchmark numbers are never pasted in by hand. - **Benchmark** publishes the same measurements for the branch. +### A deleted tag leaves its release behind as a draft + +Deleting a tag on the remote 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 survives the tag being gone +for good: this repository still holds the `v1.0.0` and `v1.0.1` releases as drafts, from the tags +withdrawn in the floor-to-`v0.1.0` reset. + +`gh release edit` leaves that flag exactly as it finds it, so both workflows that edit a release pass +`--draft=false` — `release.yml` on its update path and `release-benchmark.yml` when it injects the +table. Without it a run goes green having republished nothing, and nothing else reports it: the tag +resolves, `composer` installs, and `composer tag-currency` passes, because none of them reads the +release. `tests/ReleaseWorkflowTest.php` fails if any workflow's `gh release edit` drops the flag. + +So after re-pushing a tag, read the release: + +```bash +gh release view vX.Y.Z --repo laranail/validation --json isDraft,publishedAt +``` + ## Distribution laranail packages resolve through git VCS repositories rather than Packagist, so a consumer adds diff --git a/tests/ReleaseWorkflowTest.php b/tests/ReleaseWorkflowTest.php new file mode 100644 index 0000000..7a35163 --- /dev/null +++ b/tests/ReleaseWorkflowTest.php @@ -0,0 +1,68 @@ + every `gh release edit` command in the repository's workflows, labelled by file */ +function releaseEditCommands(): array +{ + $dir = __DIR__ . '/../.github/workflows'; + $files = []; + + // glob() answers false on failure, so each result is checked rather than coalesced: validation's + // PHPStan config forbids the short ternary, and the copies in the family keep one shape. + foreach (['*.yml', '*.yaml'] as $pattern) { + $matched = glob($dir . '/' . $pattern); + + if (is_array($matched)) { + $files = [...$files, ...$matched]; + } + } + + // A glob that stops matching must fail here rather than report a clean sweep. + expect(count($files))->toBeGreaterThanOrEqual(13); + + $commands = []; + + foreach ($files as $file) { + $raw = file_get_contents($file); + $lines = is_string($raw) ? explode("\n", $raw) : []; + // Drop comment lines first -- the prose beside the command names it too -- then join shell line + // continuations, so a flag written on the next line still counts as part of the same command. + $code = implode("\n", array_filter($lines, static fn (string $line): bool => preg_match('/^\s*#/', $line) !== 1)); + preg_match_all('/gh release edit[^\n]*/', (string) preg_replace('/\\\\\n\s*/', ' ', $code), $found); + + foreach ($found[0] as $command) { + $commands[] = basename($file) . ': ' . $command; + } + } + + return $commands; +} + +it('clears the draft flag on every release edit, because deleting a tag sets it', function (): void { + $commands = releaseEditCommands(); + + // Non-vacuity: no match at all means the matcher broke, not that the workflows are clean. + expect($commands)->not->toBeEmpty('no gh release edit was found -- check the matcher, not the workflows'); + + // Both call sites must carry it: release.yml's update path and release-benchmark.yml's table injection. + expect(count($commands))->toBeGreaterThanOrEqual(2); + + expect($commands)->each->toContain('--draft=false'); +});