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'); +});