Skip to content
Open
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
26 changes: 19 additions & 7 deletions scripts/promote-vsix.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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`);
Expand All @@ -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 };
10 changes: 9 additions & 1 deletion scripts/release-github.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
6 changes: 4 additions & 2 deletions test/specs/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
67 changes: 67 additions & 0 deletions test/unit/release-lookup.test.js
Original file line number Diff line number Diff line change
@@ -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']);
});
Loading