From 9b7f32d416b3e541940d6608a589af910060401d Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 5 Oct 2026 11:39:01 -0400 Subject: [PATCH] Let tag currency tell a release from a moving tag A package with more than one v* tag cuts real releases, and a published release must not be moved. The check told those packages to force-move it, and went red on every Dependabot bump, since a .github-only commit put the release behind main. console's scheduled hygiene was red for exactly that. For released packages, commits that touch only .github/ now pass, and unreleased shipped code asks for the next patch release instead of a moved tag. Moving-tag packages are unchanged. Pinned by a test against a stub gh. --- CHANGELOG.md | 4 ++ scripts/verify-tag-currency.sh | 29 ++++++++++- tests/Feature/VerifyTagCurrencyScriptTest.php | 51 +++++++++++++++++++ tests/fixtures/gh-stub/gh | 17 +++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/VerifyTagCurrencyScriptTest.php create mode 100755 tests/fixtures/gh-stub/gh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d71756..c5190fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `laravel/framework ^13.0` is now declared in `require`. `src/` uses `AliasLoader`, `Application`, `AuthorizesRequests`, `DispatchesJobs` and others from `Illuminate\Foundation`, which no `illuminate/*` component ships, so the dependency only arrived through the host application. +### Fixed + +- `scripts/verify-tag-currency.sh` told a package on real releases to force-move its published tag, and went red whenever main gained a commit, Dependabot's weekly `.github/` bumps included. A package with more than one `v*` tag is now treated as released: commits since the release that touch only `.github/` pass, and unreleased shipped code asks for the next patch release instead of a moved tag. Single-moving-tag packages behave as before. Pinned by `VerifyTagCurrencyScriptTest` against a stub `gh`. + ## [0.1.5] - 2026-10-05 ### Fixed diff --git a/scripts/verify-tag-currency.sh b/scripts/verify-tag-currency.sh index a4a825d..82e93db 100755 --- a/scripts/verify-tag-currency.sh +++ b/scripts/verify-tag-currency.sh @@ -86,6 +86,21 @@ tag_commit() { fi } +# A package on the moving-tag model holds exactly one v* tag and moves it. A package that has cut +# a second tag is on real releases, and a published release is immutable: moving it hands consumers +# who already resolved it different code under the same name. Such a tag falls behind main whenever +# main gains a commit, which Dependabot does weekly. A commit that touches only .github/ ships +# nothing (the directory is export-ignored), so it is not unreleased code and must not turn the +# check red. Called after ${tags} is read. +released() { [ "$(printf '%s\n' "${tags}" | grep -c .)" -gt 1 ]; } + +ci_only() { + local files + files=$(gh api "repos/${REPO}/compare/$1...${head}" --jq '.files[].filename' 2>/dev/null) || return 1 + [ -n "${files}" ] || return 1 + ! printf '%s\n' "${files}" | grep -qvE '^\.github/' +} + # Not mapfile: macOS ships bash 3.2, which does not have it, and this has to run # on a maintainer's laptop as well as in CI. tags=$(gh api "repos/${REPO}/git/matching-refs/tags/v" --jq '.[].ref | sub("refs/tags/"; "")' 2>/dev/null | sort -V) @@ -167,7 +182,11 @@ if [ "${commit}" = "${head}" ]; then ok "${highest} is also on ${BRANCH}." else hbehind=$(gh api "repos/${REPO}/compare/${hcommit}...${head}" --jq '.ahead_by' 2>/dev/null || echo '?') + if released && ci_only "${hcommit}"; then + ok "${highest} is a release; the ${hbehind} commit(s) since it touch only .github/." + else fail "${highest} is the highest tag and is ${hbehind} commit(s) behind ${BRANCH}. An unconstrained \`composer require ${REPO}\` resolves it, so it must be current or it must not exist." + fi fi fi else @@ -178,7 +197,15 @@ else --jq '.commits[] | " " + .sha[0:8] + " " + (.commit.message | split("\n")[0])' 2>/dev/null || true echo - fail "${current} is behind ${BRANCH}. Move it (git tag -f ${current} ${BRANCH} && git push --force origin ${current}) or cut a new one." + if released; then + if ci_only "${commit}"; then + ok "${current} is a release; the commits since it touch only .github/, so nothing a consumer installs is unreleased." + else + fail "${current} is behind ${BRANCH}. It is a published release, so do not move it: cut ${current%.*}.$(( ${current##*.} + 1 ))." + fi + else + fail "${current} is behind ${BRANCH}. Move it (git tag -f ${current} ${BRANCH} && git push --force origin ${current}) or cut a new one." + fi fi exit "${FAILED}" diff --git a/tests/Feature/VerifyTagCurrencyScriptTest.php b/tests/Feature/VerifyTagCurrencyScriptTest.php new file mode 100644 index 0000000..c274923 --- /dev/null +++ b/tests/Feature/VerifyTagCurrencyScriptTest.php @@ -0,0 +1,51 @@ + $root . '/tests/fixtures/gh-stub' . PATH_SEPARATOR . getenv('PATH'), 'STUB_ALIAS' => '0.1.x-dev'], + ); + $process->run(); + + return $process; +} + +it('passes a release whose later commits touch only .github/', function (): void { + $process = runTagCurrency(['STUB_TAGS' => 'v0.1.0 v0.1.5', 'STUB_TAG_SHA' => 'aaa', 'STUB_HEAD' => 'bbb', 'STUB_FILES' => '.github/workflows/ci.yml']); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toContain('touch only .github/'); +})->skipOnWindows(); + +it('asks for a new release, never a moved tag, when shipped code is unreleased', function (): void { + $process = runTagCurrency(['STUB_TAGS' => 'v0.1.0 v0.1.5', 'STUB_TAG_SHA' => 'aaa', 'STUB_HEAD' => 'bbb', 'STUB_FILES' => '.github/workflows/ci.yml src/Foo.php']); + + expect($process->getExitCode())->toBe(1) + ->and($process->getOutput())->toContain('cut v0.1.6') + ->and($process->getOutput())->not->toContain('git tag -f'); +})->skipOnWindows(); + +it('still asks a moving-tag package to move its tag', function (): void { + $process = runTagCurrency(['STUB_TAGS' => 'v0.1.0', 'STUB_TAG_SHA' => 'aaa', 'STUB_HEAD' => 'bbb', 'STUB_FILES' => '.github/workflows/ci.yml']); + + expect($process->getExitCode())->toBe(1) + ->and($process->getOutput())->toContain('Move it'); +})->skipOnWindows(); + +it('passes a tag that is on main', function (): void { + $process = runTagCurrency(['STUB_TAGS' => 'v0.1.0 v0.1.5', 'STUB_TAG_SHA' => 'bbb', 'STUB_HEAD' => 'bbb', 'STUB_FILES' => '']); + + expect($process->getExitCode())->toBe(0); +})->skipOnWindows(); diff --git a/tests/fixtures/gh-stub/gh b/tests/fixtures/gh-stub/gh new file mode 100755 index 0000000..3de8410 --- /dev/null +++ b/tests/fixtures/gh-stub/gh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Stub of `gh api [--jq expr]` for verify-tag-currency.sh, driven by env: +# STUB_TAGS (space-separated), STUB_TAG_SHA (all tags -> this sha), STUB_HEAD, STUB_FILES (space-separated), STUB_ALIAS +ep=$2; jqx=.; [ "${3:-}" = --jq ] && jqx=$4 +case "$ep" in + */git/refs/heads) out='[{"ref":"refs/heads/main"}]' ;; + */git/ref/heads/*) out="{\"object\":{\"sha\":\"$STUB_HEAD\",\"type\":\"commit\"}}" ;; + */git/matching-refs/tags/v) out=$(for t in $STUB_TAGS; do printf '{"ref":"refs/tags/%s"}\n' $t; done | jq -s .) ;; + */git/ref/tags/*) out="{\"object\":{\"sha\":\"$STUB_TAG_SHA\",\"type\":\"commit\"}}" ;; + */compare/*) a=${ep##*/compare/}; a=${a%%...*} + if [ "$a" = "$STUB_HEAD" ]; then st=identical; n=0; else st=ahead; n=1; fi + files=$(for f in $STUB_FILES; do printf '{"filename":"%s"}\n' $f; done | jq -s .) + out="{\"status\":\"$st\",\"ahead_by\":$n,\"commits\":[{\"sha\":\"cccccccc\",\"commit\":{\"message\":\"bump\"}}],\"files\":$files}" ;; + */contents/composer.json) out="{\"content\":\"$(printf '{"extra":{"branch-alias":{"dev-main":"%s"}}}' "$STUB_ALIAS" | base64)\"}" ;; + *) echo "stub: unhandled $ep" >&2; exit 1 ;; +esac +printf '%s' "$out" | jq -r "$jqx"