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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion scripts/verify-tag-currency.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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}"
51 changes: 51 additions & 0 deletions tests/Feature/VerifyTagCurrencyScriptTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

use Symfony\Component\Process\Process;

/*
* scripts/verify-tag-currency.sh against a stub `gh` (tests/fixtures/gh-stub), so the policy is
* pinned without the network. A published release must never be told to move, and a commit that
* touches only .github/ (a weekly Dependabot bump) must not turn a released package's check red.
*/
function runTagCurrency(array $env): Process
{
$root = dirname(__DIR__, 2);
$process = new Process(
['bash', $root . '/scripts/verify-tag-currency.sh', 'laranail/example'],
null,
$env + ['PATH' => $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();
17 changes: 17 additions & 0 deletions tests/fixtures/gh-stub/gh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Stub of `gh api <endpoint> [--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"
Loading