Skip to content

Commit 789e638

Browse files
authored
Merge branch 'main' into feat/i1-stashes-view
2 parents 0055f9b + 1700f41 commit 789e638

8 files changed

Lines changed: 269 additions & 9 deletions

File tree

.github/workflows/pre-release.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Pre-release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
force:
7+
description: 'Publish even if there are no commits since the last tag'
8+
required: false
9+
type: boolean
10+
default: false
11+
12+
jobs:
13+
pre-release:
14+
runs-on: ubuntu-latest
15+
permissions:
16+
contents: write
17+
issues: write
18+
pull-requests: write
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v6
23+
with:
24+
fetch-depth: 0
25+
persist-credentials: false
26+
27+
- name: Check for new commits since the last tag
28+
id: guard
29+
run: |
30+
last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
31+
if [ "${{ inputs.force }}" = "true" ] || [ -z "$last_tag" ]; then
32+
echo "should-release=true" >> "$GITHUB_OUTPUT"
33+
elif [ -z "$(git log "$last_tag"..HEAD --oneline)" ]; then
34+
echo "should-release=false" >> "$GITHUB_OUTPUT"
35+
echo "No commits since $last_tag -- skipping pre-release. Re-run with force=true to override."
36+
else
37+
echo "should-release=true" >> "$GITHUB_OUTPUT"
38+
fi
39+
40+
- name: Setup Node.js
41+
if: steps.guard.outputs.should-release == 'true'
42+
uses: actions/setup-node@v6
43+
with:
44+
node-version: '24'
45+
cache: 'yarn'
46+
47+
- name: Install dependencies
48+
if: steps.guard.outputs.should-release == 'true'
49+
run: yarn install --frozen-lockfile
50+
51+
- name: Generate GitHub App token
52+
if: steps.guard.outputs.should-release == 'true'
53+
id: app-token
54+
uses: actions/create-github-app-token@v2
55+
with:
56+
app-id: ${{ vars.RELEASE_APP_ID }}
57+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
58+
59+
- name: Pre-release
60+
if: steps.guard.outputs.should-release == 'true'
61+
run: yarn semantic-release
62+
env:
63+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
64+
VSCODE_PAT: ${{ secrets.VSCODE_PAT }}
65+
OPEN_VSX_PAT: ${{ secrets.OPEN_VSX_PAT }}
66+
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
67+
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
68+
RELEASE_CHANNEL: pre

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ jobs:
4242
OPEN_VSX_PAT: ${{ secrets.OPEN_VSX_PAT }}
4343
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
4444
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
45+
RELEASE_CHANNEL: stable

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ HMR: run `yarn dev`, then launch extension (F5). The extension detects `context.
4545
- `yarn build-vsix-dev` - Dev VSIX without bundled dependencies
4646
- `yarn publish` - Publish to VS Code marketplace
4747

48+
Actual Marketplace/Open VSX releases run through `semantic-release` via the `Release` and `Pre-release` GitHub Actions workflows, not these local scripts — see @docs/releasing.md for the stable/pre-release channel strategy.
49+
4850
## Architecture
4951

5052
See @.claude/rules/architecture.md for full architecture details.

docs/releasing.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Releasing
2+
3+
Git Smart Checkout publishes to the VS Code Marketplace and Open VSX through two
4+
GitHub Actions workflows, both driven by `semantic-release` (`release.config.js`)
5+
and both manual (`workflow_dispatch`) — nothing publishes automatically on push.
6+
7+
## Two channels, one branch
8+
9+
There is no separate `next`/`beta` branch. Both channels release from `main`;
10+
"pre-release" means *merged to `main` but not yet blessed as stable*, not a
11+
separate line of code.
12+
13+
| | Stable | Pre-release |
14+
|---|---|---|
15+
| Workflow | **Release** (`.github/workflows/release.yml`) | **Pre-release** (`.github/workflows/pre-release.yml`) |
16+
| Version | `major.EVEN.patch` | `major.ODD.patch` |
17+
| Packaging | `vsce package` | `vsce package --pre-release` |
18+
| GitHub release | Latest | marked **Pre-release**, not Latest |
19+
20+
Both lanes share one strictly increasing version sequence and one `v${version}`
21+
git tag line, so a stable release can never collide with or undercut an
22+
already-published pre-release.
23+
24+
## Odd/even minor convention
25+
26+
Following [Microsoft's documented convention](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions):
27+
stable releases use an **even** minor, pre-releases use an **odd** minor. The
28+
bump is mechanical:
29+
30+
- **Pre-release run:** last release had an even minor (or none exists yet) →
31+
bump minor; last minor was already odd → bump patch.
32+
- **Stable run:** last release had an odd minor → force a minor bump (jump
33+
over the pre-release corridor); otherwise defer to conventional commits as
34+
usual.
35+
36+
```
37+
stable 0.16.1
38+
pre 0.17.0 first pre-release of the cycle (minor bump)
39+
pre 0.17.1 further pre-releases stay on the odd minor (patch bumps)
40+
pre 0.17.2
41+
stable 0.18.0 cut stable (minor bump, jumps over 0.17.x)
42+
pre 0.19.0 next pre-release cycle opens
43+
```
44+
45+
The bump logic lives in `scripts/nextReleaseType.mjs`, wired into
46+
`release.config.js` as the `analyzeCommitsCmd` for `@semantic-release/exec`.
47+
48+
**Operational rule:** ship stable patches before opening the next pre-release
49+
cycle. Once a pre-release minor (e.g. `0.19.0`) is published, the next stable
50+
release — hotfix or not — jumps to `0.20.0` and carries everything merged to
51+
`main` since the last stable. This is the cost of the single-branch model.
52+
53+
## Running a release
54+
55+
- **Release** — dispatch from the Actions tab on `main`. Runs `semantic-release`
56+
with `RELEASE_CHANNEL=stable`.
57+
- **Pre-release** — dispatch from the Actions tab on `main`. Runs
58+
`semantic-release` with `RELEASE_CHANNEL=pre`. Skips publishing (with a log
59+
message) if there are no commits since the last tag, unless the `force`
60+
input is set.
61+
62+
Neither workflow requires manual version bumping — `semantic-release` computes
63+
the next version, updates `package.json`/`CHANGELOG.md`, tags, packages the
64+
VSIX, and publishes to both registries in one run.
65+
66+
## Opting in to pre-releases
67+
68+
End users switch channels from the VS Code Extensions view: open the Git Smart
69+
Checkout entry and choose **Switch to Pre-Release Version**. VS Code always
70+
installs the highest version available on the user's channel, so a pre-release
71+
user is still offered later stable releases.

release.config.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
// Two channels share one version line: stable = major.EVEN.patch, pre-release =
2+
// major.ODD.patch (see docs/releasing.md). RELEASE_CHANNEL is set by the two
3+
// dispatchable workflows (release.yml -> 'stable', pre-release.yml -> 'pre') and
4+
// defaults to 'stable' for local/dry-run use.
5+
const channel = process.env.RELEASE_CHANNEL === 'pre' ? 'pre' : 'stable';
6+
const isPre = channel === 'pre';
7+
18
/** @type {import('semantic-release').GlobalConfig} */
29
module.exports = {
310
branches: ['main'],
411
plugins: [
5-
'@semantic-release/commit-analyzer',
12+
// Only the stable lane defers to conventional commits. The pre-release lane
13+
// must be the sole analyzeCommits authority so it can cap the bump at patch
14+
// while on an odd minor -- semantic-release takes the highest type across all
15+
// analyzeCommits plugins, so commit-analyzer running here would silently
16+
// override that cap.
17+
...(isPre ? [] : ['@semantic-release/commit-analyzer']),
618
'@semantic-release/release-notes-generator',
719
['@semantic-release/changelog', {
820
changelogFile: 'CHANGELOG.md',
@@ -11,16 +23,27 @@ module.exports = {
1123
npmPublish: false,
1224
}],
1325
['@semantic-release/exec', {
26+
analyzeCommitsCmd: `node scripts/nextReleaseType.mjs ${channel} "\${lastRelease.version}"`,
1427
// package.json version is already updated by @semantic-release/npm above,
1528
// so vsce package will embed the correct version in the VSIX filename.
16-
prepareCmd: 'yarn build-all && yarn vsce package --yarn',
29+
prepareCmd: `yarn build-all && yarn vsce package --yarn${isPre ? ' --pre-release' : ''}`,
1730
publishCmd:
18-
'yarn vsce publish --pat ${process.env.VSCODE_PAT} --packagePath git-smart-checkout-${nextRelease.version}.vsix' +
31+
`yarn vsce publish --pat \${process.env.VSCODE_PAT} --packagePath git-smart-checkout-\${nextRelease.version}.vsix${isPre ? ' --pre-release' : ''}` +
32+
// ovsx ignores/warns on --pre-release for a prepackaged vsix -- the
33+
// Microsoft.VisualStudio.Code.PreRelease marker baked in by `vsce package
34+
// --pre-release` above is what Open VSX actually reads.
1935
' && yarn ovsx publish --pat ${process.env.OPEN_VSX_PAT} --packagePath git-smart-checkout-${nextRelease.version}.vsix',
36+
// semantic-release/github has no `prerelease` option and only infers it from
37+
// its own prerelease-branch mode, which this single-branch design doesn't use.
38+
...(isPre && {
39+
successCmd: 'gh release edit v${nextRelease.version} --prerelease --latest=false',
40+
}),
2041
}],
2142
['@semantic-release/git', {
2243
assets: ['CHANGELOG.md', 'package.json'],
23-
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
44+
message: isPre
45+
? 'chore(pre-release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}'
46+
: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
2447
}],
2548
['@semantic-release/github', {
2649
assets: [{ path: '*.vsix', label: 'VS Code Extension' }],

scripts/nextReleaseType.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env node
2+
// Deterministic release-type resolver for the odd/even minor pre-release convention
3+
// (stable = major.EVEN.patch, pre-release = major.ODD.patch). Used as the
4+
// analyzeCommitsCmd for @semantic-release/exec in release.config.js.
5+
6+
/**
7+
* @param {'pre' | 'stable'} channel
8+
* @param {string | undefined} lastVersion e.g. "0.16.1"
9+
* @returns {'major' | 'minor' | 'patch' | ''}
10+
*/
11+
export function releaseType(channel, lastVersion) {
12+
const minor = lastVersion ? Number(lastVersion.split('.')[1]) : undefined;
13+
const isOddMinor = minor !== undefined && minor % 2 !== 0;
14+
15+
if (channel === 'pre') {
16+
return isOddMinor ? 'patch' : 'minor';
17+
}
18+
19+
// stable: jump over the pre-release corridor when the last release was odd;
20+
// otherwise defer to @semantic-release/commit-analyzer (no opinion).
21+
return isOddMinor ? 'minor' : '';
22+
}
23+
24+
async function main() {
25+
const [channel, lastVersion] = process.argv.slice(2);
26+
process.stdout.write(releaseType(channel, lastVersion || undefined));
27+
}
28+
29+
if (import.meta.url === `file://${process.argv[1]}`) {
30+
main();
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as assert from 'assert';
2+
import * as path from 'path';
3+
4+
describe('release type', () => {
5+
let releaseType: (channel: 'pre' | 'stable', lastVersion?: string) => string;
6+
7+
before(async () => {
8+
const modulePath = path.join(__dirname, '..', '..', '..', 'scripts', 'nextReleaseType.mjs');
9+
({ releaseType } = await import(modulePath));
10+
});
11+
12+
it('bumps the pre-release channel to the next odd minor after a stable release', () => {
13+
assert.strictEqual(releaseType('pre', '0.16.1'), 'minor');
14+
});
15+
16+
it('bumps the pre-release channel to a patch while the minor is already odd', () => {
17+
assert.strictEqual(releaseType('pre', '0.17.0'), 'patch');
18+
});
19+
20+
it('bumps to a minor when there is no prior release', () => {
21+
assert.strictEqual(releaseType('pre', undefined), 'minor');
22+
});
23+
24+
it('forces a stable minor to jump over an odd pre-release corridor', () => {
25+
assert.strictEqual(releaseType('stable', '0.17.3'), 'minor');
26+
});
27+
28+
it('defers to commit-analyzer for a stable release following an even minor', () => {
29+
assert.strictEqual(releaseType('stable', '0.18.0'), '');
30+
});
31+
});

website/src/components/VersionBadge/VersionBadge.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ const [PUBLISHER, EXT_NAME] = EXTENSION_ID.split('.');
77

88
const MARKETPLACE_URL = `https://marketplace.visualstudio.com/items?itemName=${EXTENSION_ID}`;
99

10+
// This repo publishes on an odd/even minor convention: stable = major.EVEN.patch,
11+
// pre-release = major.ODD.patch (see docs/releasing.md). The website badge should
12+
// only ever advertise the latest stable version.
13+
function isEvenMinor(version: string): boolean {
14+
const minor = Number(version.split('.')[1]);
15+
return Number.isFinite(minor) && minor % 2 === 0;
16+
}
17+
18+
interface GalleryVersion {
19+
version?: string;
20+
properties?: Array<{ key: string; value: string }>;
21+
}
22+
1023
async function fetchMsVersion(): Promise<string> {
1124
const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
1225
method: 'POST',
@@ -16,22 +29,42 @@ async function fetchMsVersion(): Promise<string> {
1629
},
1730
body: JSON.stringify({
1831
filters: [{ criteria: [{ filterType: 7, value: EXTENSION_ID }] }],
19-
flags: 950,
32+
// Same flags as before minus IncludeLatestVersionOnly (0x200 / 512), so the
33+
// full version history comes back (each with `properties`) instead of only
34+
// the newest publish -- needed to skip past pre-release versions below.
35+
flags: 438,
2036
}),
2137
});
2238
if (!response.ok) throw new Error(`MS API ${response.status}`);
2339
const data = await response.json();
24-
const version: string | undefined = data.results[0].extensions[0].versions[0].version;
25-
if (!version) throw new Error('MS API: no version');
26-
return version;
40+
const versions: GalleryVersion[] = data.results[0].extensions[0].versions ?? [];
41+
const stable = versions.find((v) => {
42+
if (!v.version) return false;
43+
const isPreRelease = v.properties?.some(
44+
(p) => p.key === 'Microsoft.VisualStudio.Code.PreRelease' && p.value === 'true'
45+
);
46+
return !isPreRelease && isEvenMinor(v.version);
47+
});
48+
if (!stable?.version) throw new Error('MS API: no stable version');
49+
return stable.version;
2750
}
2851

2952
async function fetchOvsxVersion(): Promise<string> {
3053
const response = await fetch(`https://open-vsx.org/api/${PUBLISHER}/${EXT_NAME}`);
3154
if (!response.ok) throw new Error(`Open VSX API ${response.status}`);
3255
const data = await response.json();
3356
if (!data || data.error || !data.version) throw new Error('Open VSX API: no version');
34-
return data.version;
57+
if (data.preRelease !== true) return data.version;
58+
59+
// The latest published version is a pre-release: fall back to the newest
60+
// stable (even-minor) entry in the version list rather than fetching every
61+
// version's metadata just to read its `preRelease` flag.
62+
const allVersions: Record<string, string> = data.allVersions ?? {};
63+
const stable = Object.keys(allVersions)
64+
.filter((v) => v !== 'latest' && isEvenMinor(v))
65+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
66+
if (!stable) throw new Error('Open VSX API: no stable version');
67+
return stable;
3568
}
3669

3770
export function VersionBadge() {

0 commit comments

Comments
 (0)