diff --git a/scripts/promote-vsix.cjs b/scripts/promote-vsix.cjs index d1b59a7..d5e70d8 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 ac67d92..7facab1 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/specs/command-palette.spec.ts b/test/specs/command-palette.spec.ts index 1ee46cd..8ffb42a 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'); diff --git a/test/unit/release-lookup.test.js b/test/unit/release-lookup.test.js new file mode 100644 index 0000000..03c9f7e --- /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']); +});