From 486bf54b6606563dba0b9ff40c66207481e315ab Mon Sep 17 00:00:00 2001 From: BinaryOutlook <31880587+BinaryOutlook@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:19:48 +0800 Subject: [PATCH 1/2] fix(ci): find release drafts when preparing VSIX releases GitHub's releases/tags endpoint does not return draft releases, because a draft has no Git tag until it is published. Prepare VSIX release draft created a correct draft and then failed its final check with HTTP 404, so every manual release run (v0.3.0 through v0.5.2) ended red. Look releases up in the authenticated release list instead, which includes drafts for workflows with contents write permission. The same lookup now backs the duplicate guard, so a leftover draft from an interrupted upload blocks a second draft for that version. Two releases sharing a tag stop the run instead of picking one. Co-Authored-By: Claude Opus 5.5 --- scripts/promote-vsix.cjs | 26 +++++++++---- scripts/release-github.cjs | 10 ++++- test/unit/release-lookup.test.js | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 test/unit/release-lookup.test.js diff --git a/scripts/promote-vsix.cjs b/scripts/promote-vsix.cjs index d1b59a77..d5e70d81 100644 --- a/scripts/promote-vsix.cjs +++ b/scripts/promote-vsix.cjs @@ -6,7 +6,22 @@ const path = require('node:path'); const { createHash } = require('node:crypto'); const { verifyRun, prepareReleaseAssets, renderReleaseNotes, verifyValidationJobs } = require('./release-candidate'); const { evidenceNames } = require('./ci-validation.cjs'); -const { command, api, optionalApi, pages, git, currentMain, assertMain, assertRepository, REPOSITORY } = require('./release-github.cjs'); +const { command, api, optionalApi, pages, findRelease, git, currentMain, assertMain, assertRepository, REPOSITORY } = require('./release-github.cjs'); + +// An interrupted upload can leave a draft without a Git tag, so check the +// release list as well as the tag before creating anything. +function assertUnreleased(tag, client = { pages, optionalApi }) { + assert.equal(findRelease(tag, client), null, 'A release or draft already exists; do not replace it'); + assert.equal(client.optionalApi(`git/ref/tags/${tag}`), null, 'A tag already exists; do not move it'); +} + +function confirmRelease(tag, publish, client = { pages }) { + const release = findRelease(tag, client); + assert.ok(release, `No release or draft for ${tag} was found after upload`); + assert.equal(release.draft, !publish, publish ? `${tag} is still a draft` : `${tag} is public, not a draft`); + assert.equal(release.prerelease, false, `${tag} is marked as a prerelease`); + return release; +} async function promote({ runId, publish = false, beforePublish = async () => {} }) { assertRepository(); @@ -21,8 +36,7 @@ async function promote({ runId, publish = false, beforePublish = async () => {} const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; const tag = 'v' + version; const notes = fs.readFileSync(`release-notes/${version}.md`, 'utf8'); - assert.equal(optionalApi(`releases/tags/${tag}`), null, 'A release or draft already exists; do not replace it'); - assert.equal(optionalApi(`git/ref/tags/${tag}`), null, 'A tag already exists; do not move it'); + assertUnreleased(tag); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vsix-release-')); try { const dist = path.join(directory, 'dist'); @@ -67,9 +81,7 @@ async function promote({ runId, publish = false, beforePublish = async () => {} await beforePublish(); command('gh', ['release', 'edit', tag, '--repo', REPOSITORY, '--draft=false', '--latest']); } - const released = api(`releases/tags/${tag}`); - assert.equal(released.draft, !publish); - assert.equal(released.prerelease, false); + const released = confirmRelease(tag, publish); console.log(`${publish ? 'Published' : 'Prepared draft'} ${released.html_url} from ${source}`); if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${publish ? 'Published' : 'Prepared draft'} [${tag}](${released.html_url}) from \`${source}\`.\n`); @@ -83,4 +95,4 @@ if (require.main === module) promote({ runId: process.env.VALIDATION_RUN }).catc if (error.stderr) console.error(String(error.stderr)); process.exitCode = 1; }); -module.exports = { promote }; +module.exports = { promote, assertUnreleased, confirmRelease }; diff --git a/scripts/release-github.cjs b/scripts/release-github.cjs index ac67d926..7facab15 100644 --- a/scripts/release-github.cjs +++ b/scripts/release-github.cjs @@ -24,6 +24,14 @@ function pages(endpoint, key) { const result = JSON.parse(command('gh', ['api', '--paginate', '--slurp', `repos/${REPOSITORY}/${endpoint}`])); return result.flatMap(page => key ? page[key] : page); } +// GET releases/tags/{tag} omits drafts, which have no Git tag until they are +// published. The authenticated release list includes drafts for workflows +// with contents write permission, so release identity checks search it. +function findRelease(tag, client = { pages }) { + const matches = client.pages('releases?per_page=100').filter(release => release.tag_name === tag); + assert.ok(matches.length <= 1, `${matches.length} releases or drafts use ${tag}; inspect them before continuing`); + return matches[0] || null; +} function git(...args) { return command('git', args); } function currentMain() { const sha = api('git/ref/heads/main').object.sha; @@ -36,4 +44,4 @@ function assertMain(source) { function assertRepository() { assert.equal(process.env.GITHUB_REPOSITORY, REPOSITORY); } -module.exports = { REPOSITORY, command, api, optionalApi, pages, git, currentMain, assertMain, assertRepository }; +module.exports = { REPOSITORY, command, api, optionalApi, pages, findRelease, git, currentMain, assertMain, assertRepository }; diff --git a/test/unit/release-lookup.test.js b/test/unit/release-lookup.test.js new file mode 100644 index 00000000..03c9f7e5 --- /dev/null +++ b/test/unit/release-lookup.test.js @@ -0,0 +1,67 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { findRelease } = require('../../scripts/release-github.cjs'); +const { assertUnreleased, confirmRelease } = require('../../scripts/promote-vsix.cjs'); + +const release = (overrides = {}) => ({ id: 1, tag_name: 'v0.9.1', draft: false, prerelease: false, + html_url: 'https://github.com/BinaryOutlook/binary-markdown/releases/tag/v0.9.1', ...overrides }); +const draft = overrides => release({ draft: true, + html_url: 'https://github.com/BinaryOutlook/binary-markdown/releases/tag/untagged-0123456789abcdef', ...overrides }); + +// GitHub's releases/tags endpoint omits drafts. The fixture exposes only the +// release list and tag refs, so any use of that endpoint fails the test. +function client(releases, tags = []) { + const calls = []; + return { + calls, + pages(endpoint) { + calls.push(endpoint); + assert.equal(endpoint, 'releases?per_page=100'); + return releases; + }, + optionalApi(endpoint) { + calls.push(endpoint); + const tag = endpoint.match(/^git\/ref\/tags\/(.+)$/)?.[1]; + assert.ok(tag, 'Unexpected request: ' + endpoint); + return tags.includes(tag) ? { ref: `refs/tags/${tag}` } : null; + }, + }; +} + +test('finds a draft by tag from the release list', () => { + const github = client([release({ id: 2, tag_name: 'v0.9.0' }), draft({ id: 3 })]); + assert.equal(findRelease('v0.9.1', github).id, 3); + assert.equal(findRelease('v0.9.2', github), null); +}); +test('refuses to choose between releases or drafts that share a tag', () => { + const github = client([draft({ id: 3 }), draft({ id: 4 })]); + assert.throws(() => findRelease('v0.9.1', github), /2 releases or drafts use v0\.9\.1/); +}); +test('a prepared draft confirms the manual draft workflow', () => { + const prepared = draft({ id: 3 }); + assert.equal(confirmRelease('v0.9.1', false, client([prepared])), prepared); +}); +test('automatic publication requires the release to be public', () => { + const published = release({ id: 3 }); + assert.equal(confirmRelease('v0.9.1', true, client([published])), published); + assert.throws(() => confirmRelease('v0.9.1', true, client([draft({ id: 3 })])), /v0\.9\.1 is still a draft/); +}); +test('a missing, unexpectedly public or prerelease result fails confirmation', () => { + assert.throws(() => confirmRelease('v0.9.1', false, client([])), /No release or draft for v0\.9\.1/); + assert.throws(() => confirmRelease('v0.9.1', false, client([release({ id: 3 })])), /v0\.9\.1 is public, not a draft/); + assert.throws(() => confirmRelease('v0.9.1', false, client([draft({ prerelease: true })])), + /v0\.9\.1 is marked as a prerelease/); +}); +test('a leftover untagged draft blocks a new upload', () => { + const github = client([draft({ id: 3 })]); + assert.throws(() => assertUnreleased('v0.9.1', github), /A release or draft already exists/); +}); +test('an existing tag blocks a new upload', () => { + assert.throws(() => assertUnreleased('v0.9.1', client([], ['v0.9.1'])), /A tag already exists/); +}); +test('an unused version passes both upload guards', () => { + const github = client([release({ id: 2, tag_name: 'v0.9.0' })], ['v0.9.0']); + assertUnreleased('v0.9.1', github); + assert.deepEqual(github.calls, ['releases?per_page=100', 'git/ref/tags/v0.9.1']); +}); From 06f055b45bb4eda8e8c0da43709107f04d4e72ee Mon Sep 17 00:00:00 2001 From: BinaryOutlook <31880587+BinaryOutlook@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:00:51 +0800 Subject: [PATCH 2/2] test: wait for command palette focus before keyboard input openCommandPalette() focuses the filter input on the next animation frame, and the input's own keydown handler implements ArrowUp, ArrowDown, Enter and Escape. Two tests pressed those keys after a fixed 100 ms sleep, so on a slow runner the key could reach the editor instead. The arrow navigation test failed this way on macOS in PR #112's validation. Wait for the input to be focused, as the wraparound test already does since 4b0a0da. With animation frames delayed by 300 ms, the old pattern reproduces the CI failure and the new one passes. --- test/specs/command-palette.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/specs/command-palette.spec.ts b/test/specs/command-palette.spec.ts index 1ee46cd6..8ffb42a3 100644 --- a/test/specs/command-palette.spec.ts +++ b/test/specs/command-palette.spec.ts @@ -91,7 +91,8 @@ test.describe('コマンドパレット', () => { await editor.focus(); const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.keyboard.press(`${modifier}+/`); - await page.waitForTimeout(100); + // Opening the palette moves focus on the next animation frame. + await expect(page.locator('.command-palette-input')).toBeFocused(); // 最初のアイテムが選択されている const firstItem = page.locator('.command-palette-item').first(); @@ -186,7 +187,8 @@ test.describe('コマンドパレット', () => { // カーソルは「行2」にある const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.keyboard.press(`${modifier}+/`); - await page.waitForTimeout(100); + // Opening the palette moves focus on the next animation frame. + await expect(page.locator('.command-palette-input')).toBeFocused(); // Esc で閉じた後、カーソル位置は「行2」のまま await page.keyboard.press('Escape');