diff --git a/.github/scripts/prepare-version-reminder.cjs b/.github/scripts/prepare-version-reminder.cjs new file mode 100644 index 000000000..0623de3ed --- /dev/null +++ b/.github/scripts/prepare-version-reminder.cjs @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); + +const versionFiles = ['package.json', 'package-lock.json']; + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +function github(method, endpoint, body) { + const args = ['api', endpoint]; + if (method === 'GET') { + args.push('--paginate', '--slurp'); + } else { + args.push('--method', method, '--input', '-'); + } + return JSON.parse( + execFileSync('gh', args, { + encoding: 'utf8', + input: body === undefined ? undefined : JSON.stringify(body), + }), + ); +} + +function required(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required to prepare the version-bump reminder.`); + } + return value; +} + +function summary(markdown) { + console.log(markdown); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`); + } +} + +function prepareBranch(branch, newVersion, title) { + const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); + if (manifest.version !== newVersion || lock.version !== newVersion || lock.packages?.['']?.version !== newVersion) { + throw new Error('The prepared manifest and both root lockfile versions must match NEW_VERSION.'); + } + const staged = git('diff', '--cached', '--name-only'); + if (staged && staged.split('\n').some((file) => !versionFiles.includes(file))) { + throw new Error('Unrelated files are staged; refusing to include them in the version bump.'); + } + + const remote = spawnSync('git', ['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${branch}`], { + encoding: 'utf8', + }); + if (remote.error) { + throw remote.error; + } + if (remote.status === 0) { + git('fetch', '--no-tags', 'origin', `refs/heads/${branch}`); + const base = git('merge-base', 'HEAD', 'FETCH_HEAD'); + const changedFiles = git('diff', '--name-only', base, 'FETCH_HEAD').split('\n'); + if ( + changedFiles.some((file) => !versionFiles.includes(file)) || + versionFiles.some((file) => git('show', `FETCH_HEAD:${file}`) !== fs.readFileSync(file, 'utf8').trim()) + ) { + throw new Error(`Existing branch ${branch} has conflicting changes; refusing to overwrite it.`); + } + console.log(`Reusing existing branch ${branch}; no commits were overwritten.`); + return; + } + if (remote.status !== 2) { + throw new Error(`Could not inspect remote branch ${branch}: ${remote.stderr}`); + } + + git('switch', '-c', branch); + git('add', '--', ...versionFiles); + // GitHub.com no-reply emails use "{account ID}+{login}@users.noreply.github.com". + // These shared bot account IDs are not our user, organization, or repository IDs: + // 41898282: GitHub Actions committer (https://api.github.com/users/github-actions%5Bbot%5D). + // 223556219: Copilot co-author (https://api.github.com/user/223556219). + git( + '-c', + 'user.name=github-actions[bot]', + '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'commit', + '-m', + title, + '-m', + 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>', + ); + git('push', 'origin', `HEAD:refs/heads/${branch}`); +} + +function main() { + const oldVersion = required('OLD_VERSION'); + const newVersion = required('NEW_VERSION'); + const repository = required('GITHUB_REPOSITORY'); + const server = required('GITHUB_SERVER_URL'); + const runId = required('GITHUB_RUN_ID'); + const assignee = required('REMINDER_ASSIGNEE'); + if ( + !/^\d+\.\d+\.\d+$/.test(oldVersion) || + !/^\d+\.\d+\.\d+-alpha\.0$/.test(newVersion) || + !/^[\w.-]+\/[\w.-]+$/.test(repository) || + !/^\d+$/.test(runId) || + !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(assignee) + ) { + throw new Error('Invalid version, repository, run ID, or reminder assignee.'); + } + + const branch = `bot/bump-${newVersion}`; + const title = `chore: prepare ${newVersion} development`; + const runUrl = `${server}/${repository}/actions/runs/${runId}`; + const source = process.env.RELEASE_TAG + ? `[Release ${process.env.RELEASE_TAG}](${server}/${repository}/releases/tag/${encodeURIComponent(process.env.RELEASE_TAG)})` + : 'Manual workflow run'; + const description = [ + '## Why this change is needed', + '', + 'Move `main` to the next development version so ongoing development no longer uses an already released version.', + 'The published release and its tag are unchanged.', + '', + '## Version change', + '', + '| File | Old version | New version |', + '| --- | --- | --- |', + `| \`package.json\` | \`${oldVersion}\` | **\`${newVersion}\`** |`, + `| \`package-lock.json\` (both root version entries) | \`${oldVersion}\` | **\`${newVersion}\`** |`, + '', + '`alpha.0` means the **first alpha of the next patch**; `.0` is the prerelease counter.', + 'Reruns do not advance it to `alpha.1`. Workspace package and dependency versions are unchanged.', + 'Nothing is published or merged automatically. Replace the alpha with the intended numeric version before the next release.', + '', + '## Source', + '', + `- Trigger: ${source}`, + `- [Workflow run](${runUrl})`, + ].join('\n'); + const compareUrl = new URL(`${server}/${repository}/compare/main...${branch}`); + compareUrl.search = new URLSearchParams({ expand: '1', title, body: description }).toString(); + const prUrl = compareUrl.toString(); + + prepareBranch(branch, newVersion, title); + summary( + `## Development version is ready\n\n${description}\n\nBranch: [\`${branch}\`](${server}/${repository}/tree/${branch})\n\n[Create version-bump PR](${prUrl})\n`, + ); + + const marker = ``; + const body = [ + marker, + description, + '', + '## Next steps', + '', + `@${assignee}: the branch \`${branch}\` is prepared.`, + '', + `1. [Create version-bump PR](${prUrl}) using the prefilled title and description.`, + '2. Submit the PR yourself; normal pull-request CI will run under your user identity.', + '3. Review and merge after checks pass, then close this reminder.', + '', + 'The workflow cannot create PRs under the repository policy. This issue is the reminder, not a failure to prepare the change.', + ].join('\n'); + // Enumerate issues instead of search: newly created issues may not be search-indexed on a retry. + const issues = github('GET', `repos/${repository}/issues?state=all&creator=github-actions%5Bbot%5D&per_page=100`).flat(); + const matches = issues.filter((issue) => !issue.pull_request && issue.body?.includes(marker)); + if (matches.length > 1) { + throw new Error(`Multiple reminders exist for ${newVersion}; resolve the duplicates before retrying.`); + } + const existing = matches[0]; + const issue = github( + existing ? 'PATCH' : 'POST', + `repos/${repository}/issues${existing ? `/${existing.number}` : ''}`, + { + title: `Prepare development version ${newVersion}`, + body, + assignees: [assignee], + ...(existing ? { state: 'open' } : {}), + }, + ); + if (!issue.assignees?.some((user) => user.login === assignee)) { + throw new Error(`Reminder ${issue.html_url} was not assigned to @${assignee}; check issue assignment permissions.`); + } + summary(`Reminder assigned to @${assignee}: ${issue.html_url}`); +} + +main(); diff --git a/.github/scripts/prepare-version-reminder.test.cjs b/.github/scripts/prepare-version-reminder.test.cjs new file mode 100644 index 000000000..76d1c3c2c --- /dev/null +++ b/.github/scripts/prepare-version-reminder.test.cjs @@ -0,0 +1,333 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const script = path.join(__dirname, 'prepare-version-reminder.cjs'); +const branch = 'bot/bump-1.0.2-alpha.0'; +const marker = ''; +const issueUrl = 'https://github.com/example/project/issues/17'; + +function git(directory, ...args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function writeVersions(directory, version) { + fs.writeFileSync(path.join(directory, 'package.json'), `${JSON.stringify({ name: 'fixture', version }, null, 2)}\n`); + fs.writeFileSync( + path.join(directory, 'package-lock.json'), + `${JSON.stringify({ version, lockfileVersion: 3, packages: { '': { version } } }, null, 2)}\n`, + ); +} + +function fixture(t, state = { issues: [] }) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'documentdb-version-reminder-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const remote = path.join(directory, 'remote.git'); + const work = path.join(directory, 'work'); + const bin = path.join(directory, 'bin'); + fs.mkdirSync(work); + fs.mkdirSync(bin); + git(directory, 'init', '--bare', remote); + git(work, 'init'); + git(work, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + git(work, 'config', 'user.name', 'Fixture'); + git(work, 'config', 'user.email', 'fixture@example.test'); + writeVersions(work, '1.0.1'); + fs.writeFileSync(path.join(work, 'unrelated.txt'), 'Keep this file unchanged.\n'); + git(work, 'add', '.'); + git(work, 'commit', '-m', 'Released version'); + git(work, 'remote', 'add', 'origin', remote); + git(work, 'push', 'origin', 'main'); + const mainSha = git(work, 'rev-parse', 'HEAD'); + writeVersions(work, '1.0.2-alpha.0'); + const stateFile = path.join(directory, 'github-state.json'); + fs.writeFileSync(stateFile, JSON.stringify({ calls: [], ...state })); + fs.writeFileSync( + path.join(bin, 'gh'), + String.raw`#!/usr/bin/env node +const fs = require('node:fs'); +const file = process.env.GH_TEST_STATE; +const state = JSON.parse(fs.readFileSync(file, 'utf8')); +const args = process.argv.slice(2); +if (args[0] !== 'api' || !args[1].startsWith('repos/example/project/issues')) { + throw new Error('Unexpected GitHub command: ' + JSON.stringify(args)); +} +const method = args.includes('--method') ? args[args.indexOf('--method') + 1] : 'GET'; +const body = method === 'GET' ? undefined : JSON.parse(fs.readFileSync(0, 'utf8')); +state.calls.push({ method, endpoint: args[1], args, body }); +fs.writeFileSync(file, JSON.stringify(state)); +if (state.fail === method) { + console.error('GitHub API denied this operation (HTTP 403)'); + process.exit(1); +} +if (method === 'GET') { + if (!args.includes('--paginate') || !args.includes('--slurp')) throw new Error('Pagination required'); + console.log(JSON.stringify(state.pages || [state.issues])); +} else { + body.assignees = state.ignoreAssignee ? [] : body.assignees.map((login) => ({ login })); + let issue; + if (method === 'POST') { + issue = { number: 17, html_url: 'https://github.com/example/project/issues/17', ...body }; + state.issues.push(issue); + } else if (method === 'PATCH') { + issue = state.issues.find((item) => item.number === Number(args[1].split('/').at(-1))); + if (!issue) throw new Error('Unknown issue'); + Object.assign(issue, body); + } else { + throw new Error('Unexpected API method'); + } + fs.writeFileSync(file, JSON.stringify(state)); + console.log(JSON.stringify(issue)); +} +`, + { mode: 0o755 }, + ); + return { + directory, + work, + remote, + mainSha, + stateFile, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + GH_TEST_STATE: stateFile, + OLD_VERSION: '1.0.1', + NEW_VERSION: '1.0.2-alpha.0', + GITHUB_REPOSITORY: 'example/project', + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_RUN_ID: '12345', + GITHUB_STEP_SUMMARY: path.join(directory, 'summary'), + REMINDER_ASSIGNEE: 'release-maintainer', + RELEASE_TAG: '', + }, + }; +} + +function run(context, env = {}) { + return spawnSync(process.execPath, [script], { + cwd: context.work, + encoding: 'utf8', + env: { ...context.env, ...env }, + }); +} + +function state(context) { + return JSON.parse(fs.readFileSync(context.stateFile, 'utf8')); +} + +function assertPrepared(context) { + assert.equal(git(context.work, 'ls-remote', 'origin', 'refs/heads/main').split('\t')[0], context.mainSha); + assert.deepEqual( + git(context.work, 'diff', '--name-only', 'main', branch).split('\n'), + ['package-lock.json', 'package.json'], + ); + assert.equal(JSON.parse(git(context.work, 'show', `${branch}:package.json`)).version, '1.0.2-alpha.0'); + const lock = JSON.parse(git(context.work, 'show', `${branch}:package-lock.json`)); + assert.equal(lock.version, '1.0.2-alpha.0'); + assert.equal(lock.packages[''].version, '1.0.2-alpha.0'); + assert.match(git(context.work, 'log', '-1', '--format=%B', branch), /Co-authored-by: Copilot/); +} + +test('Pushes only the version files and creates an assigned issue with a prefilled PR link', (t) => { + const context = fixture(t); + + const result = run(context); + + assert.equal(result.status, 0, result.stderr); + assertPrepared(context); + const { issues, calls } = state(context); + assert.equal(issues.length, 1); + assert.deepEqual(issues[0].assignees, [{ login: 'release-maintainer' }]); + assert.match(issues[0].body, //); + assert.match(issues[0].body, /Old version \| New version/); + assert.match(issues[0].body, /`package\.json` \| `1\.0\.1` \| \*\*`1\.0\.2-alpha\.0`\*\*/); + assert.match(issues[0].body, /`package-lock\.json` \(both root version entries\)/); + assert.match(issues[0].body, /first alpha of the next patch/); + assert.match(issues[0].body, /Manual workflow run/); + assert.match(issues[0].body, /normal pull-request CI/); + const link = issues[0].body.match(/\[Create version-bump PR\]\(([^)]+)\)/)[1]; + const url = new URL(link); + assert.equal(url.pathname, '/example/project/compare/main...bot/bump-1.0.2-alpha.0'); + assert.equal(url.searchParams.get('expand'), '1'); + assert.equal(url.searchParams.get('title'), 'chore: prepare 1.0.2-alpha.0 development'); + assert.match(url.searchParams.get('body'), /Old version \| New version/); + assert.match(url.searchParams.get('body'), /already released version/); + const summary = fs.readFileSync(context.env.GITHUB_STEP_SUMMARY, 'utf8'); + assert.equal(summary.match(/\[Create version-bump PR\]\(([^)]+)\)/)[1], link); + assert.equal(summary.trimEnd().split('\n').at(-1), `Reminder assigned to @release-maintainer: ${issueUrl}`); + assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']); +}); + +test('A rerun reuses the remote branch and updates the same issue without another commit', (t) => { + const context = fixture(t); + assert.equal(run(context).status, 0); + const sha = git(context.work, 'rev-parse', branch); + git(context.work, 'switch', 'main'); + writeVersions(context.work, '1.0.2-alpha.0'); + + const result = run(context); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Reusing existing branch/); + assert.equal(git(context.work, 'rev-parse', branch), sha); + assert.equal(git(context.work, 'ls-remote', 'origin', `refs/heads/${branch}`).split('\t')[0], sha); + assert.equal(state(context).issues.length, 1); + assert.deepEqual(state(context).calls.map((call) => call.method), ['GET', 'POST', 'GET', 'PATCH']); +}); + +test('Reuses a matching branch from the old failed workflow even when main has advanced', (t) => { + const context = fixture(t); + git(context.work, 'switch', '-c', branch); + git(context.work, 'add', 'package.json', 'package-lock.json'); + git(context.work, 'commit', '-m', 'Prepared by the old workflow'); + git(context.work, 'push', 'origin', branch); + const sha = git(context.work, 'rev-parse', 'HEAD'); + git(context.work, 'switch', 'main'); + fs.appendFileSync(path.join(context.work, 'unrelated.txt'), 'New main commit.\n'); + git(context.work, 'add', 'unrelated.txt'); + git(context.work, 'commit', '-m', 'Main advanced'); + git(context.work, 'push', 'origin', 'main'); + writeVersions(context.work, '1.0.2-alpha.0'); + + const result = run(context); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Reusing existing branch/); + assert.equal(git(context.work, 'rev-parse', branch), sha); + assert.equal(state(context).issues.length, 1); +}); + +test('Finds a reminder on later API pages and reopens a closed issue instead of duplicating it', (t) => { + const existing = { number: 17, html_url: issueUrl, body: marker, state: 'closed' }; + const context = fixture(t, { + issues: [existing], + pages: [[{ number: 1, body: 'Unrelated issue' }], [existing]], + }); + + const result = run(context); + + assert.equal(result.status, 0, result.stderr); + assert.equal(state(context).issues.length, 1); + assert.equal(state(context).issues[0].state, 'open'); + assert.deepEqual(state(context).calls.map((call) => call.method), ['GET', 'PATCH']); +}); + +for (const conflict of ['version', 'unrelated-file']) { + test(`Never overwrites conflicting existing branch content (${conflict})`, (t) => { + const context = fixture(t); + git(context.work, 'switch', '-c', branch); + if (conflict === 'version') { + writeVersions(context.work, '1.0.2-alpha.1'); + } else { + fs.appendFileSync(path.join(context.work, 'unrelated.txt'), 'Human change.\n'); + } + git(context.work, 'add', '.'); + git(context.work, 'commit', '-m', 'Human changes'); + git(context.work, 'push', 'origin', branch); + const sha = git(context.work, 'rev-parse', 'HEAD'); + git(context.work, 'switch', 'main'); + writeVersions(context.work, '1.0.2-alpha.0'); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /refusing to overwrite/); + assert.equal(git(context.work, 'ls-remote', 'origin', `refs/heads/${branch}`).split('\t')[0], sha); + assert.deepEqual(state(context).calls, []); + }); +} + +test('Issue permission failures remain failures, but the prepared branch and PR link remain available', (t) => { + const context = fixture(t, { issues: [], fail: 'POST' }); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /HTTP 403/); + assertPrepared(context); + assert.match(fs.readFileSync(context.env.GITHUB_STEP_SUMMARY, 'utf8'), /Create version-bump PR/); + assert.equal(state(context).issues.length, 0); +}); + +test('Reports a silently ignored issue assignment as an error', (t) => { + const context = fixture(t, { issues: [], ignoreAssignee: true }); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /was not assigned to @release-maintainer/); + assert.equal(state(context).issues.length, 1); + assert.match(fs.readFileSync(context.env.GITHUB_STEP_SUMMARY, 'utf8'), /Create version-bump PR/); +}); + +test('A failed remote lookup is not treated as a missing branch', (t) => { + const context = fixture(t); + git(context.work, 'remote', 'set-url', 'origin', path.join(context.directory, 'nonexistent.git')); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Could not inspect remote branch/); + assert.equal(git(context.work, 'branch', '--show-current'), 'main'); + assert.deepEqual(state(context).calls, []); +}); + +test('Refuses to commit unrelated staged files', (t) => { + const context = fixture(t); + fs.appendFileSync(path.join(context.work, 'unrelated.txt'), 'Unrelated staged change.\n'); + git(context.work, 'add', 'unrelated.txt'); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unrelated files are staged/); + assert.equal(git(context.work, 'rev-parse', 'HEAD'), context.mainSha); +}); + +test('Rejects a mismatched lockfile before creating a branch or issue', (t) => { + const context = fixture(t); + fs.writeFileSync(path.join(context.work, 'package-lock.json'), JSON.stringify({ version: '1.0.1' })); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /both root lockfile versions must match/); + assert.deepEqual(state(context).calls, []); +}); + +test('Includes the triggering release link', (t) => { + const context = fixture(t); + + const result = run(context, { RELEASE_TAG: 'v1.0.1' }); + + assert.equal(result.status, 0, result.stderr); + assert.match(state(context).issues[0].body, /\[Release v1\.0\.1\]\(https:\/\/github\.com\/example\/project\/releases\/tag\/v1\.0\.1\)/); +}); + +test('Duplicate reminder markers fail explicitly instead of updating an arbitrary issue', (t) => { + const context = fixture(t, { issues: [{ number: 17, body: marker }, { number: 18, body: marker }] }); + + const result = run(context); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Multiple reminders exist/); + assert.deepEqual(state(context).calls.map((call) => call.method), ['GET']); +}); + +test('A pull request carrying the marker is not mistaken for a reminder issue', (t) => { + const context = fixture(t, { issues: [{ number: 4, body: marker, pull_request: { url: 'example' } }] }); + + const result = run(context); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(state(context).calls.map((call) => call.method), ['GET', 'POST']); +}); diff --git a/.github/workflows/bump-version-pr.yaml b/.github/workflows/bump-version-pr.yaml index 7f086fb52..40603a493 100644 --- a/.github/workflows/bump-version-pr.yaml +++ b/.github/workflows/bump-version-pr.yaml @@ -6,9 +6,8 @@ on: workflow_dispatch: permissions: - actions: write contents: write - pull-requests: write + issues: write concurrency: group: bump-version-after-release @@ -23,6 +22,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main + fetch-depth: 0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -34,65 +34,12 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} - - name: Create pull request - id: pr + - name: Prepare branch and reminder issue if: steps.bump.outputs.changed == 'true' - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - title: 'chore: prepare ${{ steps.bump.outputs.new-version }} development' - body: | - ## Why this PR exists - - Move `main` to the next development version after a release, so ongoing - development is no longer labelled with an already released version. - The published release and its tag are unchanged. - - ## Version change - - | File | Old version | New version | - | --- | --- | --- | - | `package.json` | `${{ steps.bump.outputs.old-version }}` | **`${{ steps.bump.outputs.new-version }}`** | - | `package-lock.json` (both root version entries) | `${{ steps.bump.outputs.old-version }}` | **`${{ steps.bump.outputs.new-version }}`** | - - `alpha.0` means the **first alpha of the next patch version**. The `.0` - is the prerelease counter, not an additional patch number. Re-running - this workflow does not advance it to `alpha.1`. - - Only the root extension version is updated; workspace package versions - and dependency versions are unchanged. This PR does **not** publish an - extension, create a release, or change release notes. Before publishing - the next release, replace the alpha version with its intended numeric - release version. - - ## Review and merge - - This PR is **ready for review**, not a draft. The workflow explicitly - starts **full CI** on this branch, so no separate token secret or manual - CI dispatch is needed. Review the changes and merge after the required - checks pass. - - GitHub may also request approval for PR-triggered workflows because - this PR was created with `GITHUB_TOKEN`. If shown, select - **Approve workflows to run** for those additional runs. - - ## Source - - - Trigger: ${{ github.event_name == 'release' && format('[Release {0}]({1})', github.event.release.tag_name, github.event.release.html_url) || 'Manual workflow run' }} - - [Workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - commit-message: 'chore: prepare ${{ steps.bump.outputs.new-version }} development' - branch: bot/bump-${{ steps.bump.outputs.new-version }} - base: main - draft: false - delete-branch: true - add-paths: | - package.json - package-lock.json - author: GitHub - - - name: Start full CI for the pull request - if: steps.pr.outputs.pull-request-number != '' - run: gh workflow run main.yml --ref "$PR_BRANCH" -f enforce_full_run=true + run: node .github/scripts/prepare-version-reminder.cjs env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_BRANCH: ${{ steps.pr.outputs.pull-request-branch }} + OLD_VERSION: ${{ steps.bump.outputs.old-version }} + NEW_VERSION: ${{ steps.bump.outputs.new-version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + REMINDER_ASSIGNEE: tnaum-ms diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38d0efdea..f8cd78e36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -436,14 +436,20 @@ Always ship the signed artifact from the pipeline. A locally packaged `.vsix` is 4. Save the release. Publishing a stable GitHub release also starts [Bump version after release](.github/workflows/bump-version-pr.yaml). -When its tag matches the version on `main`, the workflow opens a ready-for-review PR for the next patch's +When its tag matches the version on `main`, the workflow prepares a branch for the next patch's first development version, for example `1.0.1` → `1.0.2-alpha.0`. It updates `package.json` and both root version entries in `package-lock.json`, leaving workspace package versions unchanged. -The workflow explicitly starts full CI on the PR's branch. Review and merge it once the required -checks pass. If GitHub requests approval for additional PR-triggered workflows, select -**Approve workflows to run**. The published release and tag are not modified. -The alpha suffix is for development only; use the intended numeric release -version at [§7.4](#74-bump-the-version) before the next publication. +It creates or updates a reminder issue assigned to `tnaum-ms`, with an old/new version table and +a **Create version-bump PR** link that prefills the branch, title, and description. + +Open that link and submit the PR yourself so normal pull-request CI runs. Review and merge it +after the required checks pass, then close the reminder issue. The workflow summary also contains +the prepared branch and PR link. No bot-created PR, direct push to `main`, additional credential, +or permission to create PRs is needed; the workflow requires contents and issues write access. +If issue creation fails, the run fails explicitly but the prepared branch and PR link remain +available in the summary. The published release and tag are not modified. +The alpha suffix is for development only; use the intended numeric release version at +[§7.4](#74-bump-the-version) before the next publication. To catch up after a release, select **Actions → Bump version after release → Run workflow** on `main`, or run: @@ -452,11 +458,13 @@ To catch up after a release, select **Actions → Bump version after release → gh workflow run bump-version-pr.yaml --ref main ``` -The workflow always operates on `main`. Re-running it reuses the same version-specific PR while -`main` is still on the released version; after the bump is merged, it leaves the existing alpha +The workflow always operates on `main`. Re-running it reuses the same version-specific branch and +reminder issue while `main` is still on the released version; a prematurely closed reminder is +reopened. Existing matching branches, including a branch pushed by an earlier failed run, are +preserved without force-pushing. Conflicting branch changes cause an explicit failure so a +maintainer can reconcile them. After the bump is merged, the workflow leaves the existing alpha version alone. GitHub prereleases are ignored, as are releases whose tags do not match the version -on `main` (for example, a patch release from an older release branch). The repository's -**Settings → Actions → General → Workflow permissions** must allow Actions to create pull requests. +on `main` (for example, a patch release from an older release branch). ### 7.9 Publish to the Marketplace diff --git a/docs/ai-and-plans/features/build-and-test-stack/README.md b/docs/ai-and-plans/features/build-and-test-stack/README.md index 7fd7f7ada..82665887d 100644 --- a/docs/ai-and-plans/features/build-and-test-stack/README.md +++ b/docs/ai-and-plans/features/build-and-test-stack/README.md @@ -131,10 +131,13 @@ coverage into installed-workbench E2E; see the [`bump-version-pr.yaml`](../../../../.github/workflows/bump-version-pr.yaml) runs after stable GitHub releases and on manual dispatch. It always operates on `main`, requires release tags to -match its current version, and proposes the next patch's first alpha in a ready-for-review PR. The root +match its current version, and prepares the next patch's first alpha on a version-specific branch. The root manifest and both lockfile version entries stay synchronized; workspace versions are not bumped. -Reruns reuse the same PR or skip an existing development version. The workflow explicitly -dispatches full CI on the PR branch without a separate token secret. +Reruns reuse the same branch and reminder issue, or skip an existing development version. +The issue is assigned to `tnaum-ms` and includes a prefilled PR link; a maintainer submits the PR +and normal PR CI runs. The workflow summary retains the branch and link even if issue creation +fails. No bot PR, third-party PR action, or additional token secret is needed. +The policy constraint and operator's accepted alternative are recorded in [decision 0016](./decisions.md#0016-prepare-a-version-branch-and-an-assigned-issue-instead-of-a-bot-pr). See [the release process](../../../../CONTRIBUTING.md#78-create-the-github-release). ## Durable documents diff --git a/docs/ai-and-plans/features/build-and-test-stack/decisions.md b/docs/ai-and-plans/features/build-and-test-stack/decisions.md index e9d2ec4b6..933bbeba7 100644 --- a/docs/ai-and-plans/features/build-and-test-stack/decisions.md +++ b/docs/ai-and-plans/features/build-and-test-stack/decisions.md @@ -11,23 +11,24 @@ created: 2026-10-14 > E2E iteration in particular. Decisions taken while executing the stages stay in > [build-and-test-stack.md](./iterations/01-modernization/build-and-test-stack.md), next to the stage they shaped. -| # | Decision | Status | Changed from the proposal? | Date | PR | -| ---- | --------------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------- | ---- | -| 0001 | Workspace packages ship ESM only | Accepted | Accepted as proposed | 2026-10-14 | #880 | -| 0002 | CI runs verification in tiers and reports what it ran and why | Accepted | **Operator added the layer report, the one-shot request label and the merged comment** | 2026-10-14 | #880 | -| 0003 | Negative controls run when their check changes, not every PR | Accepted | Accepted as proposed | 2026-10-14 | #880 | -| 0004 | Use Vite for the host and webviews | Accepted | The documented host-only esbuild fallback was not needed | 2026-09-30 | #880 | -| 0005 | Replace Jest with Vitest in one sweep | Accepted | Adopted for pipeline and ESM unification, not for speed | 2026-10-02 | #880 | -| 0006 | Split each view with ordered named groups | Accepted | Rolldown groups replaced planned `manualChunks`; ordering came from the build experiment | 2026-10-05 | #880 | -| 0007 | Run the extension host as ESM through a thin loader | Accepted | Accepted as proposed | 2026-10-05 | #880 | -| 0008 | Keep the TypeScript server plugin CommonJS | Accepted | The shipped bundle moved to the root and a runtime stub supplies the package path | 2026-10-01 | #880 | -| 0009 | Keep SlickGrid during modernization | Accepted | Replacement remains a separate later iteration | 2026-09-30 | #880 | -| 0010 | Run L3 on GitHub Actions only | Accepted | ADO keeps only offline checks that can run before signing | 2026-09-30 | #880 | -| 0011 | Keep L2-dev runner-free in this iteration | Accepted | CI wiring stays with the E2E iteration | 2026-10-05 | #880 | -| 0012 | Use one branch and one PR, merging `main` | Accepted (modified) | The 2026-10-07 decision restored a merge from `main` before the PR merges | 2026-10-01 | #880 | -| 0013 | The required check passes only when every expected layer passed | Accepted | Accepted as proposed by the Stage 8.5 review (S85-F01) | 2026-10-14 | #880 | -| 0014 | CI jobs run in parallel where safe, under descriptive names | Accepted (modified) | Accepted as proposed; prompted by the operator's comparison with PR #963; 0015 changes its `Merge gate` timing | 2026-10-14 | #880 | -| 0015 | Rename the required check to `Merge gate` on #880 | Accepted | Operator's call; reverses the order in 0014 | 2026-10-14 | #880 | +| # | Decision | Status | Changed from the proposal? | Date | PR | +| ---- | ------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------- | ----- | +| 0001 | Workspace packages ship ESM only | Accepted | Accepted as proposed | 2026-10-14 | #880 | +| 0002 | CI runs verification in tiers and reports what it ran and why | Accepted | **Operator added the layer report, the one-shot request label and the merged comment** | 2026-10-14 | #880 | +| 0003 | Negative controls run when their check changes, not every PR | Accepted | Accepted as proposed | 2026-10-14 | #880 | +| 0004 | Use Vite for the host and webviews | Accepted | The documented host-only esbuild fallback was not needed | 2026-09-30 | #880 | +| 0005 | Replace Jest with Vitest in one sweep | Accepted | Adopted for pipeline and ESM unification, not for speed | 2026-10-02 | #880 | +| 0006 | Split each view with ordered named groups | Accepted | Rolldown groups replaced planned `manualChunks`; ordering came from the build experiment | 2026-10-05 | #880 | +| 0007 | Run the extension host as ESM through a thin loader | Accepted | Accepted as proposed | 2026-10-05 | #880 | +| 0008 | Keep the TypeScript server plugin CommonJS | Accepted | The shipped bundle moved to the root and a runtime stub supplies the package path | 2026-10-01 | #880 | +| 0009 | Keep SlickGrid during modernization | Accepted | Replacement remains a separate later iteration | 2026-09-30 | #880 | +| 0010 | Run L3 on GitHub Actions only | Accepted | ADO keeps only offline checks that can run before signing | 2026-09-30 | #880 | +| 0011 | Keep L2-dev runner-free in this iteration | Accepted | CI wiring stays with the E2E iteration | 2026-10-05 | #880 | +| 0012 | Use one branch and one PR, merging `main` | Accepted (modified) | The 2026-10-07 decision restored a merge from `main` before the PR merges | 2026-10-01 | #880 | +| 0013 | The required check passes only when every expected layer passed | Accepted | Accepted as proposed by the Stage 8.5 review (S85-F01) | 2026-10-14 | #880 | +| 0014 | CI jobs run in parallel where safe, under descriptive names | Accepted (modified) | Accepted as proposed; prompted by the operator's comparison with PR #963; 0015 changes its `Merge gate` timing | 2026-10-14 | #880 | +| 0015 | Rename the required check to `Merge gate` on #880 | Accepted | Operator's call; reverses the order in 0014 | 2026-10-14 | #880 | +| 0016 | Prepare a version branch and an assigned issue instead of a bot PR | Accepted | Operator approved the issue and prefilled-link alternative after bot PR creation was blocked | 2026-10-21 | #1043 | > Entries below are **semantically** immutable: append new entries rather than rewriting old > ones, and record reversals as a new entry plus a status change above. Heading text is frozen @@ -647,3 +648,54 @@ The operator chose to finish the naming in one PR rather than leave one stale na Until #880 merges, other open PRs run `main`'s workflow, which has no `Merge gate` job, so they cannot satisfy the new required check and are blocked from merging (an admin bypass or the merge of #880 releases them). The operator accepted this. + +--- + +## 0016: Prepare a version branch and an assigned issue instead of a bot PR + +**Status:** Accepted · **Date:** 2026-10-21 · **Raised by:** the operator after the first +post-release bump run failed to create a PR + +### Question + +The workflow introduced in #1042 successfully pushed the version-only branch, but +[run 39644385162](https://github.com/microsoft/vscode-documentdb/actions/runs/39644385162) +was denied permission to create a PR even with `pull-requests: write`. The repository's +**Allow GitHub Actions to create and approve pull requests** setting is locked, and the +operator cannot access the governing organization settings. How can the workflow still +prepare the change and prevent a forgotten version update? + +### Decision + +Prepare the version-specific branch and create or update a reminder issue assigned to +`tnaum-ms`. Include the old/new version table and a prefilled **Create version-bump PR** +link in the issue and workflow summary. The maintainer submits the PR under their own +identity; normal PR CI runs. Reruns reuse the branch and issue rather than duplicate them. + +The operator confirmed the proposal: "perfect, so we basically create an issue, with a +prepared branch, correct?" and then requested its implementation on a new branch from `main`. + +### Reasoning + +The branch preparation already worked. An assigned issue provides a persistent reminder and +notification, while the prefilled link leaves only submission to the maintainer. This keeps +the automation useful without bot PR creation, a direct push to `main`, or an additional +credential. Issue write permission remains a prerequisite and errors remain explicit. + +### Rejected alternatives + +- **Change only the PR-creation action:** GitHub CLI or an official action with the same + `GITHUB_TOKEN` cannot override the PR-creation policy. +- **Add a personal token to bypass the locked setting:** not the accepted approach; no new + credential is introduced. +- **Only print a link in the workflow summary:** retains a recovery path, but does not provide + the assigned reminder the operator wants. + +### Consequences + +- Only contents and issues write permissions remain; automatic full-CI dispatch is removed. +- Git and GitHub CLI replace the third-party PR action. +- Existing matching branches are reused without force pushes; conflicting changes require + maintainer intervention. +- A closed reminder is reopened if the bump is still needed. The maintainer closes it after + merging the manually submitted PR. diff --git a/docs/ai-and-plans/features/build-and-test-stack/design.md b/docs/ai-and-plans/features/build-and-test-stack/design.md index 04aca81b2..8944a770f 100644 --- a/docs/ai-and-plans/features/build-and-test-stack/design.md +++ b/docs/ai-and-plans/features/build-and-test-stack/design.md @@ -169,12 +169,20 @@ Both automatic and manual runs propose only the next patch's `alpha.0`; they nev existing development version to `alpha.1`. The root manifest and both root lockfile versions must agree before and after npm runs. Workspace package and dependency versions stay unchanged. -The workflow creates or updates a version-specific, ready-for-review PR using `GITHUB_TOKEN`, -with explicit actions, contents and pull-request write permissions. It dispatches `main.yml` on -the PR branch with `enforce_full_run=true`, so the complete CI suite and required `Merge gate` -run without a separate token secret or approval. Additional PR-triggered workflows may require -a maintainer's approval under GitHub's token-event rules. No release is published or tag modified. -The development alpha must be replaced with a numeric release version before publication. +The workflow pushes a version-specific branch and creates or updates an assigned reminder issue +using `GITHUB_TOKEN`, with only contents and issues write permissions. A version marker identifies +the reminder independently of its title or state; closed reminders are reopened on a retry. +REST pagination avoids search-index delays causing duplicate issues. Matching branches are reused +without rewriting commits. Branches with conflicting version content or unrelated changes fail +explicitly instead of being overwritten. + +The issue and workflow summary include a formatted old/new version table and prefilled PR link. +A maintainer submits the PR manually and normal PR CI runs; there is no bot PR or automatic CI +dispatch. The prepared branch and link are summarized before the issue API call so they remain +available if issue creation fails, without treating that failure as success. No release is +published or tag modified. The development alpha must be replaced with a numeric release version +before publication. [Decision 0016](./decisions.md#0016-prepare-a-version-branch-and-an-assigned-issue-instead-of-a-bot-pr) +records the policy constraint and operator's approved alternative. ## Change discipline diff --git a/docs/ai-and-plans/features/build-and-test-stack/pipelines.md b/docs/ai-and-plans/features/build-and-test-stack/pipelines.md index 4b54cd5e6..85aae49f5 100644 --- a/docs/ai-and-plans/features/build-and-test-stack/pipelines.md +++ b/docs/ai-and-plans/features/build-and-test-stack/pipelines.md @@ -42,14 +42,16 @@ the operator-run ADO build and the G0 manual checklist passed on 2026-10-02 (G0) | `api-extractor.yaml` | Push to `main` and `release/**`; PRs | Extracts the public API typings | | `api-publish.yaml` | Manual | Publishes the API typings package to npm | | `npm-publish-documentdb-js.yml` | Manual; `dry_run` defaults to `true`, one checkbox per package | Builds, type-checks package tests, tests and verifies packages, and dry-runs publication; explicit real publication to npmjs with provenance requires successful verification and environment approval | -| `bump-version-pr.yaml` | Stable GitHub release published; manual | Opens or updates a ready-for-review PR into `main` for the next patch's `alpha.0`, updating the root manifest and lockfile, then explicitly dispatches full CI on its branch. Release tags must match `main`; existing development versions are left alone. | +| `bump-version-pr.yaml` | Stable GitHub release published; manual | Prepares a version-specific branch for the next patch's `alpha.0`, updating the root manifest and lockfile. Creates or updates an assigned reminder issue with a prefilled PR link; a maintainer submits the PR and normal PR CI runs. Release tags must match `main`; existing development versions are left alone. | | `deploy-documentation-production.yml` | Push to `main` touching `docs/**`; manual | Deploys the documentation site | | `seed-build-cache.yml` | Manual (`seed` / `verify`) | Build-size cache | Workflows that install dependencies use **public npmjs**: there is no `.npmrc` at the repository root. The version-bump workflow needs no dependency installation; it runs the repository's -`.github/scripts/bump-version.cjs` with Node from `.nvmrc` and npm. Its integration tests run in -the CI unit-tests job. +`.github/scripts/bump-version.cjs` with Node from `.nvmrc` and npm, then +`.github/scripts/prepare-version-reminder.cjs` with Git and the runner's preinstalled GitHub CLI. +It needs contents and issues write access, not permission to create PRs or dispatch workflows. +Its integration tests run in the CI unit-tests job. All checkouts in `main.yml` use `actions/checkout`'s default ref: GitHub's synthetic **merge ref for PRs**, and `github.sha` for push/manual runs. Stage 6 restores this policy