Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/release-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 13 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/ReleaseWorkflowTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

/*
* The workflow that updates a release must not leave it as a draft.
*
* DELETING a tag reverts its GitHub 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 republishes nothing on a run that still goes
* GREEN: 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 on 2026-08-24. Measured as a live failure in
* laranail/validation-js on 2026-11-08, whose release had to be published by hand (laranail/validation-js#35).
*
* Nothing else reports that state: the tag resolves, composer installs, and verify-tag-currency.sh passes,
* because none of them reads the release.
*
* This reads EVERY workflow file rather than the two call sites, so a workflow added later inherits the rule.
* A release deliberately left as a draft would fail here, which is the right place to have to think about it.
*/

/** @return list<string> 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');
});
Loading