From a0560bb11bc197c7c317059ab7edcf262cf33460 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:30:57 +0800 Subject: [PATCH] fix(release-beat): read every tags page, and pin the checkout in a test (#1220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `newestTag` read `repos/{repo}/tags?per_page=100` once. The listing is 100 names to a page in string order, and `v0.2.100` sorts below `v0.2.99`, so once a repository has more than a page of tags the newest version can sit on page two: the watchdog then reads yesterday's tag and files a false "no v* tag since …" every day. It now pages to the end and keeps `latestOf`'s rule untouched (plain vX.Y.Z, pre-releases skipped, highest version rather than newest push). A page that repeats the previous one stops the walk, so a server that ignores `page` cannot spin. The fixture is that repository: page one is 100 older tags and only page two carries today's tag. On the old code the run alerts (`'alert' !== 'ok'`); with the fix it is `ok` and names `v0.2.101`. Also adds the one-line text assertion the workflow's own pin was missing: the job holds `issues: write`, so `actions/checkout` must stay on a full commit (`uses: actions/checkout@[0-9a-f]{40}`). With the pin reverted to `@v4`, that test reds. Docs-not-needed: no subsystem or architecture behaviour moves; the watchdog doc still describes the metric and the decision correctly. --- .github/scripts/release-beat.mjs | 21 +++++++++++++++- .github/scripts/release-beat.test.mjs | 36 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-beat.mjs b/.github/scripts/release-beat.mjs index 7f7ea0847..479f14829 100644 --- a/.github/scripts/release-beat.mjs +++ b/.github/scripts/release-beat.mjs @@ -71,8 +71,27 @@ function apiJson(gh, path, jq) { return out } +// Every page, not the first one (#1220). The listing is 100 names to a page in string order, and a +// string order is not a version order: `v0.2.100` sorts below `v0.2.99`, so once the repository has more +// than a page of tags the newest version can sit on page two and a single read reports yesterday's tag — +// a false "no v* tag since …" every day. Paging to the end is the cheapest way to keep `latestOf`'s +// semver rule and the pre-release boundary untouched; `/releases/latest` would answer a different +// question (GitHub's "latest" release, not the highest plain `vX.Y.Z` tag). +const TAGS_PER_PAGE = 100 + export function newestTag(gh, repo) { - const names = apiJson(gh, `repos/${repo}/tags?per_page=100`, '.[].name').split('\n').filter(Boolean) + const names = [] + let previous = null + for (let page = 1; ; page += 1) { + const batch = apiJson(gh, `repos/${repo}/tags?per_page=${TAGS_PER_PAGE}&page=${page}`, '.[].name').split('\n').filter(Boolean) + // A server that ignored `page` would hand back the same page forever; the repeated page also + // carries no new name, so stopping there loses nothing. + const pageText = batch.join('\n') + if (pageText === previous) break + names.push(...batch) + previous = pageText + if (batch.length < TAGS_PER_PAGE) break + } return latestOf(names) } diff --git a/.github/scripts/release-beat.test.mjs b/.github/scripts/release-beat.test.mjs index 5f8097281..7d58d7610 100644 --- a/.github/scripts/release-beat.test.mjs +++ b/.github/scripts/release-beat.test.mjs @@ -30,6 +30,13 @@ const isSearch = (args) => args.some((a) => a.startsWith('search/issues?')) const isCreate = (args) => args.includes('POST') && args.some((a) => a === `repos/${REPO}/issues`) const isRuns = (args) => args.some((a) => a.startsWith(`repos/${REPO}/actions/workflows/release.yml/runs`)) +// The tags listing is paginated; which page a call asks for is part of the route, because that is the +// whole point of the fixture below (#1220). No `page=` is page one, which is what the old code asked for. +const pageOf = (args) => { + const url = args.find((a) => a.startsWith(`repos/${REPO}/tags?`)) ?? '' + return Number(new URL(url, 'https://api.github.test/').searchParams.get('page') ?? '1') +} + function happyRoutes({ tagDateInstant = '2026-10-25T10:00:00Z', tags = 'v0.2.8', openTitles = '', searchTotal = '0', created = '{"number": 77}' } = {}) { return [ [isTagList, `${tags}\n`], @@ -87,6 +94,29 @@ test('the newest tag is semver order, not string order', () => { assert.equal(latestOf([]), null) }) +// #1220: `/tags?per_page=100` has no paging, and the names come back in string order while the versions +// are not (`v0.2.100` sorts below `v0.2.99`). Past 100 tags the newest version can sit on a later page, +// so one read of page one reports a false "no v* tag since …" every day. This fixture is that repository: +// page one is 100 older tags and only page two carries today's tag. On the old single read the newest +// seen is `v0.2.100`, dated yesterday, and the run alerts — the false alarm, red here. +test('a newest tag past page one is still read: >100 tags must not raise a false miss (#1220)', () => { + const pageOne = Array.from({ length: 100 }, (_, i) => `v0.2.${i + 1}`).join('\n') + const { gh, calls } = fakeGh([ + [(args) => isTagList(args) && pageOf(args) === 1, `${pageOne}\n`], + [(args) => isTagList(args) && pageOf(args) === 2, 'v0.2.101\n'], + [(args) => isTagRef(args) && args.some((a) => a.endsWith('/v0.2.101')), 'commit abc101\n'], + [(args) => isCommit(args) && args.some((a) => a.endsWith('/abc101')), '2026-10-26T03:00:00Z\n'], + [isTagRef, 'commit c0ffee\n'], + [isCommit, '2026-10-25T10:00:00Z\n'], + [isPulls, ''], + [isSearch, '0'], + ]) + const r = run({ gh, repo: REPO, date: '2026-10-26', open: false, summary: null, write: () => {} }) + assert.equal(r.action, 'ok', 'the newest tag is dated today, on page two') + assert.match(r.reason, /v0\.2\.101/) + assert.ok(calls.some((c) => isTagList(c.args) && pageOf(c.args) === 2), 'the second page was read') +}) + test('an annotated tag reads its tagger date; a lightweight tag reads its commit date', () => { const annotated = fakeGh([[isTagRef, 'tag 9f9f\n'], [isAnnotatedTag, '2026-10-25T10:00:00Z\n']]) assert.equal(tagDate(annotated.gh, REPO, 'v0.2.8'), '2026-10-25') @@ -172,6 +202,12 @@ test('the workflow runs at 02:30 Asia/Shanghai exactly, and its one comment says assert.deepEqual(workflow.match(/cron: "[^"]*"/g), ['cron: "30 18 * * *"'], 'one cron, and it is this one') }) +test('the checkout action stays pinned to a full commit (#1220)', () => { + // The job holds `issues: write`, so a floating major tag would hand that permission to whatever the + // tag points at tomorrow. The same one-line check audit-merges.test.mjs uses for its workflow. + assert.match(workflow, /uses: actions\/checkout@[0-9a-f]{40}/, 'actions stay pinned to a commit') +}) + test('permissions are exactly the two this job needs: read at the top, issues: write on the job', () => { const top = workflow.slice(workflow.indexOf('\npermissions:'), workflow.indexOf('\nconcurrency:')) assert.deepEqual(top.trim().split('\n').map(line => line.trim()), ['permissions:', 'contents: read'])