Skip to content
Merged
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
78 changes: 78 additions & 0 deletions .github/scripts/bump-version.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');

function readVersion() {
const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
if (
typeof manifest.version !== 'string' ||
lock.version !== manifest.version ||
lock.packages?.['']?.version !== manifest.version
) {
throw new Error('package.json and both root package-lock.json versions must match before and after the bump.');
}
return manifest.version;
}

function report(outputs, message) {
console.log(message);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(outputs)
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
);
}
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Development version bump\n\n${message}\n`);
}
}

function main() {
const oldVersion = readVersion();
const releaseTag = process.env.RELEASE_TAG;
if (releaseTag && releaseTag !== oldVersion && releaseTag !== `v${oldVersion}`) {
report(
{ changed: false },
`Skipped: release ${releaseTag} does not match the current main version ${oldVersion}.`,
);
return;
}
if (/^\d+\.\d+\.\d+-/.test(oldVersion)) {
report({ changed: false }, `Skipped: main is already on development version ${oldVersion}.`);
return;
}
if (!/^\d+\.\d+\.\d+$/.test(oldVersion)) {
throw new Error(`Expected a numeric release version, received ${oldVersion}.`);
}

execFileSync(
'npm',
[
'version',
'prerelease',
'--preid=alpha',
'--no-git-tag-version',
'--ignore-scripts',
'--workspaces=false',
'--package-lock=true',
],
{ stdio: 'inherit' },
);
const newVersion = readVersion();
if (!/^\d+\.\d+\.\d+-alpha\.0$/.test(newVersion)) {
throw new Error(`Expected the first alpha of the next patch version, received ${newVersion}.`);
}
report(
{ changed: true, 'old-version': oldVersion, 'new-version': newVersion },
`Updated package.json and both root package-lock.json versions: **${oldVersion} -> ${newVersion}**.`,
);
}

main();
165 changes: 165 additions & 0 deletions .github/scripts/bump-version.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*---------------------------------------------------------------------------------------------
* 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, 'bump-version.cjs');

function fixture(t, version = '1.0.1') {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'documentdb-version-bump-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const lifecycle = 'node -e "throw new Error(\'Version lifecycle scripts must not run\')"';
const manifest = {
name: 'version-bump-fixture',
version,
private: true,
workspaces: ['packages/*'],
dependencies: { 'fixture-dependency': '7.8.9' },
scripts: { preversion: lifecycle, version: lifecycle, postversion: lifecycle },
};
const lock = {
name: manifest.name,
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: manifest.name,
version,
workspaces: manifest.workspaces,
dependencies: manifest.dependencies,
},
'packages/example': { name: 'fixture-workspace', version: '4.5.6' },
'node_modules/fixture-workspace': { resolved: 'packages/example', link: true },
'node_modules/fixture-dependency': { version: '7.8.9' },
},
};
fs.mkdirSync(path.join(directory, 'packages/example'), { recursive: true });
fs.writeFileSync(path.join(directory, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
fs.writeFileSync(path.join(directory, 'package-lock.json'), `${JSON.stringify(lock, null, 2)}\n`);
fs.writeFileSync(
path.join(directory, 'packages/example/package.json'),
`${JSON.stringify({ name: 'fixture-workspace', version: '4.5.6' }, null, 2)}\n`,
);
return { directory, manifest, lock };
}

function run(directory, releaseTag = '') {
return spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
...process.env,
RELEASE_TAG: releaseTag,
GITHUB_OUTPUT: path.join(directory, 'outputs'),
GITHUB_STEP_SUMMARY: path.join(directory, 'summary'),
npm_config_update_notifier: 'false',
},
});
}

function read(directory, file) {
return JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
}

for (const releaseTag of ['', 'v1.0.1', '1.0.1']) {
test(`Bumps only the root manifest and lockfile to alpha.0 (${releaseTag || 'manual'})`, (t) => {
const { directory, manifest, lock } = fixture(t);
const workspace = fs.readFileSync(path.join(directory, 'packages/example/package.json'), 'utf8');
execFileSync('git', ['init', '--quiet'], { cwd: directory });

const result = run(directory, releaseTag);

assert.equal(result.status, 0, result.stderr);
assert.deepEqual(read(directory, 'package.json'), { ...manifest, version: '1.0.2-alpha.0' });
assert.deepEqual(read(directory, 'package-lock.json'), {
...lock,
version: '1.0.2-alpha.0',
packages: { ...lock.packages, '': { ...lock.packages[''], version: '1.0.2-alpha.0' } },
});
assert.equal(fs.readFileSync(path.join(directory, 'packages/example/package.json'), 'utf8'), workspace);
assert.equal(
fs.readFileSync(path.join(directory, 'outputs'), 'utf8'),
'changed=true\nold-version=1.0.1\nnew-version=1.0.2-alpha.0\n',
);
assert.match(fs.readFileSync(path.join(directory, 'summary'), 'utf8'), /1\.0\.1 -> 1\.0\.2-alpha\.0/);
assert.equal(execFileSync('git', ['tag', '--list'], { cwd: directory, encoding: 'utf8' }), '');
assert.notEqual(spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: directory }).status, 0);
});
}

for (const version of ['1.0.2-alpha.0', '1.0.2-alpha.1', '1.1.0-beta.0']) {
test(`Does not advance an existing development version (${version})`, (t) => {
const { directory, manifest, lock } = fixture(t, version);

const result = run(directory);

assert.equal(result.status, 0, result.stderr);
assert.deepEqual(read(directory, 'package.json'), manifest);
assert.deepEqual(read(directory, 'package-lock.json'), lock);
assert.equal(fs.readFileSync(path.join(directory, 'outputs'), 'utf8'), 'changed=false\n');
assert.match(result.stdout, /already on development version/);
});
}

for (const releaseTag of ['v0.11.2', 'v1.1.0', 'unrelated-package-v1.0.1']) {
test(`Skips a release that does not match main (${releaseTag})`, (t) => {
const { directory, manifest, lock } = fixture(t);

const result = run(directory, releaseTag);

assert.equal(result.status, 0, result.stderr);
assert.deepEqual(read(directory, 'package.json'), manifest);
assert.deepEqual(read(directory, 'package-lock.json'), lock);
assert.equal(fs.readFileSync(path.join(directory, 'outputs'), 'utf8'), 'changed=false\n');
assert.match(result.stdout, /does not match the current main version/);
});
}

test('A repeated manual run does not produce alpha.1', (t) => {
const { directory } = fixture(t);
assert.equal(run(directory).status, 0);
fs.writeFileSync(path.join(directory, 'outputs'), '');

const result = run(directory);

assert.equal(result.status, 0, result.stderr);
assert.equal(read(directory, 'package.json').version, '1.0.2-alpha.0');
assert.equal(fs.readFileSync(path.join(directory, 'outputs'), 'utf8'), 'changed=false\n');
});

for (const field of ['version', 'root-package']) {
test(`Fails before modifying files if the lockfile is out of sync (${field})`, (t) => {
const { directory, manifest, lock } = fixture(t);
if (field === 'version') {
lock.version = '1.0.0';
} else {
lock.packages[''].version = '1.0.0';
}
fs.writeFileSync(path.join(directory, 'package-lock.json'), JSON.stringify(lock));

const result = run(directory);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /both root package-lock.json versions must match/);
assert.deepEqual(read(directory, 'package.json'), manifest);
assert.deepEqual(read(directory, 'package-lock.json'), lock);
assert.equal(fs.existsSync(path.join(directory, 'outputs')), false);
});
}

test('Fails explicitly for an unsupported version', (t) => {
const { directory } = fixture(t, 'not-a-version');

const result = run(directory);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Expected a numeric release version/);
});
86 changes: 77 additions & 9 deletions .github/workflows/bump-version-pr.yaml
Original file line number Diff line number Diff line change
@@ -1,30 +1,98 @@
name: Bump version after release

# Disabled for now — comment out the trigger block to prevent execution
on:
# release:
# types: [published]
release:
types: [published]
workflow_dispatch:

permissions:
actions: write
contents: write
pull-requests: write

concurrency:
group: bump-version-after-release
cancel-in-progress: false

jobs:
bump:
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc

- name: Bump version
id: bump
uses: alexweininger/bump-prerelease-version@91183ecfa908d49e9e7c1c62e9c781a49aa7c307 # v0.1.1
run: node .github/scripts/bump-version.cjs
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}

- name: Create pull request
id: pr
if: steps.bump.outputs.changed == 'true'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
title: ${{ env.MESSAGE }}
body: Automatically created by ${{ env.RUN_LINK }}
commit-message: ${{ env.MESSAGE }}
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 <noreply@github.com>

- 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
env:
RUN_LINK: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MESSAGE: Bump version after release
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_BRANCH: ${{ steps.pr.outputs.pull-request-branch }}
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ jobs:
- name: 🧪 Run Unit Tests (Vitest)
run: npm test

- name: Test release automation
run: node --test .github/scripts/*.test.cjs

package-checks:
name: 'npm packages: verify tarballs'
needs: [plan]
Expand Down
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,29 @@ Always ship the signed artifact from the pipeline. A locally packaged `.vsix` is
3. Attach the **signed** `.vsix` downloaded in the previous step.
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
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.

To catch up after a release, select **Actions → Bump version after release → Run workflow** on
`main`, or run:

```bash
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
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.

### 7.9 Publish to the Marketplace

Run the second internal Azure DevOps pipeline, the **release** pipeline, which publishes to the Visual Studio Marketplace from whichever branch holds the release commit — `main` for a normal release, or the `release/<X.Y.Z>` branch when a patch was cut off a tag ([§1.3](#13-releases)). This is a separate pipeline from the build in §7.7: one produces and signs the artifact, the other ships it.
Expand Down
Loading
Loading