Compare commits

..
Author SHA1 Message Date
yonlu 35c0d83819 docs: a CI-only change is ci:, not fix(ci):
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Failing after 6m39s
The commit-analyzer reads the type and ignores the scope, so `fix` is a
patch whatever sits in the brackets. Two commits touching nothing but
.gitea/workflows/unclaim.yml were written `fix(ci):` and cut v0.2.1 and
v0.2.2 -- real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change.

CLAUDE.md already warned that a mistyped feat ships a minor version.
That was not enough, because this was not a mistyped type: `fix` was
chosen deliberately, in the belief that the (ci) scope qualified it.

The version bump is the small half, which is why this gets a paragraph
rather than a clause. A merge to main starts two workflows; if
release.yml then pushes a tag, that tag push starts four more --
arch-package, homebrew-formula, android-apk and desktop-assets -- on a
runner with capacity 1, where the APK build alone is tens of minutes
and publishes a signed artifact to a public registry. So a mistyped
type is six workflow runs, not an odd-looking changelog.

`make release-dry` answers this before the merge instead of after, and
is cheaper than any one of those runs.

The two releases are staying: they are already published, and a version
that vanishes is worse for whoever pulled it than one that turns out to
be empty.

Closes #111
2026-08-19 15:23:15 +00:00
2 changed files with 14 additions and 54 deletions

No files matched your search

+11 -27
View File
@@ -1,12 +1,6 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
/**
* How far the scroll test scrolls. One constant, because the guard and
* the assertion have to agree about it — they did not, which is #133.
*/
const SCROLL_TARGET = 80;
/** /**
* Plan 007 phase 5: expanding an album shows its tracks. * Plan 007 phase 5: expanding an album shows its tracks.
* *
@@ -110,27 +104,20 @@ test.describe('the album dropdown', () => {
await app.setViewportSize({ width: 900, height: 600 }); await app.setViewportSize({ width: 900, height: 600 });
try { try {
// Wait for the range the assertion below actually needs, not for await expect.poll(() => scrollRange(app)).toMatchObject({
// "scrollable at all" (#133). The guard used to be scrollable: true,
// `scrollHeight > clientHeight + 40` while the next line asks to overflowY: 'auto',
// reach 80, so any range in 41-79 satisfied it and could not });
// satisfy the assertion — and the grid passes through exactly
// that while it settles, because it recomputes its columns after
// the resize rather than during it. The settled range here is
// 330, so this waits rather than weakening anything.
await expect
.poll(() => scrollRange(app))
.toMatchObject({ room: true, overflowY: 'auto' });
await app.evaluate((target) => { await app.evaluate(() => {
const sc = document const sc = document
.querySelector('cover-grid') .querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container'); ?.shadowRoot?.querySelector('.grid-scroll-container');
if (sc) sc.scrollTop = target; if (sc) sc.scrollTop = 80;
}, SCROLL_TARGET); });
expect(await scrollTop(app)).toBe(SCROLL_TARGET); expect(await scrollTop(app)).toBe(80);
// And the dropdown it opens is on screen, wherever the manager // And the dropdown it opens is on screen, wherever the manager
// decides that leaves the scroll. It is *not* "the position is // decides that leaves the scroll. It is *not* "the position is
@@ -263,19 +250,16 @@ async function closeDropdown(app: Page): Promise<void> {
/** Whether the grid can scroll at all, which decides if a probe can move. */ /** Whether the grid can scroll at all, which decides if a probe can move. */
async function scrollRange(app: Page) { async function scrollRange(app: Page) {
return app.evaluate((target) => { return app.evaluate(() => {
const sc = document const sc = document
.querySelector('cover-grid') .querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container'); ?.shadowRoot?.querySelector('.grid-scroll-container');
return { return {
// `room` is the precondition of the assertion that follows it: scrollable: !!sc && sc.scrollHeight > sc.clientHeight + 40,
// enough range to actually reach the target. A threshold below
// what the caller depends on is not a guard.
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
overflowY: sc ? getComputedStyle(sc).overflowY : '', overflowY: sc ? getComputedStyle(sc).overflowY : '',
}; };
}, SCROLL_TARGET); });
} }
async function scrollTop(app: Page): Promise<number> { async function scrollTop(app: Page): Promise<number> {
+3 -27
View File
@@ -38,10 +38,8 @@
# Where a body is taken and no --body-file is given, it is read from stdin. # Where a body is taken and no --body-file is given, it is read from stdin.
# #
# Environment: # Environment:
# GITEA_TOKEN a PAT with write:issue. `claim` and `mine` additionally # GITEA_TOKEN a PAT with write:issue (plus write:repository and read:user,
# need to know your username: set GITEA_USER, or give the # which the rest of this repo's tooling reaches for)
# token read:user and it is looked up.
# GITEA_USER your Gitea login. Optional; see above.
# GITEA_URL defaults to https://git.ljones.me # GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket # GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail set -euo pipefail
@@ -83,29 +81,7 @@ read_body() {
if [ "$file" = "-" ]; then cat; else cat "$file"; fi if [ "$file" = "-" ]; then cat; else cat "$file"; fi
} }
# The one lookup in this script that needs a scope beyond write:issue. me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; }
# `GET /user` requires read:user, and it is reached for exactly two reasons:
# to name the assignee in `claim`, and to filter in `mine`. A token scoped to
# the work this script does — write:issue — therefore failed at `claim`, which
# is the one step the workflow requires before the first edit, so the whole
# documented process was blocked by its own tooling.
#
# GITEA_USER short-circuits it, which is what lets a least-privilege token do
# the job. The lookup stays as the fallback because it is right when the
# scope is there and needs no setup at all.
me() {
if [ -n "${GITEA_USER:-}" ]; then
printf '%s' "$GITEA_USER"
return
fi
curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" |
python3 "$py" login ||
{
echo "issue.sh: could not resolve your username. Set GITEA_USER, or" >&2
echo "issue.sh: re-issue GITEA_TOKEN with read:user." >&2
exit 1
}
}
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; } label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }