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
58 changes: 58 additions & 0 deletions .github/scripts/extension-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const fs = require('node:fs');
const path = require('node:path');

function parseVersion(version) {
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error(`Expected a numeric major.minor.patch version, got ${version}`);
}
const parts = version.split('.').map(Number);
if (parts.some(part => !Number.isSafeInteger(part) || part > 0xffffffff)) {
throw new Error(`Version component exceeds uint32: ${version}`);
}
return parts;
}

function prereleaseVersion(version, date) {
const [major, minor] = parseVersion(version);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)
|| new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) {
throw new Error(`Expected a UTC date in YYYY-MM-DD format, got ${date}`);
}
// Keep an explicitly selected odd series (e.g. 3.999 before 4.0).
const result = `${major}.${minor % 2 === 0 ? minor + 1 : minor}.${date.replaceAll('-', '')}`;
parseVersion(result);
return result;
}

function checkStableVersion(version, tag) {
const [, minor] = parseVersion(version);
if (minor % 2 !== 0) {
throw new Error('Stable releases must use an even minor version');
}
if (tag !== `v${version}`) {
throw new Error(`Release tag ${tag} does not match package.json version ${version}`);
}
}

function rewriteVersion(manifestPath, date) {
const source = fs.readFileSync(manifestPath, 'utf8');
const manifest = JSON.parse(source);
const version = prereleaseVersion(manifest.version, date);
// Change only the top-level version, preserving the manifest's formatting.
manifest.version = version;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
return version;
}

module.exports = { parseVersion, prereleaseVersion, checkStableVersion, rewriteVersion };
if (require.main === module) {
const [command, value] = process.argv.slice(2);
const manifestPath = path.join(__dirname, '..', '..', 'package.json');
if (command === 'prerelease') {
console.log(rewriteVersion(manifestPath, value));
} else if (command === 'check-stable') {
checkStableVersion(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version, value);
} else {
throw new Error('Usage: node .github/scripts/extension-version.js <prerelease YYYY-MM-DD|check-stable vX.Y.Z>');
}
}
60 changes: 60 additions & 0 deletions .github/scripts/extension-version.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { prereleaseVersion, checkStableVersion, rewriteVersion } = require('./extension-version');

test('daily versions are independent of stable patch releases', () => {
for (const version of ['3.0.1', '3.0.2', '3.0.99']) {
assert.equal(prereleaseVersion(version, '2026-10-06'), '3.1.20261006');
}
assert.equal(prereleaseVersion('3.2.0', '2026-10-07'), '3.3.20261007');
assert.equal(prereleaseVersion('3.999.0', '2026-10-07'), '3.999.20261007');
assert.equal(prereleaseVersion('4.0.0', '2026-10-08'), '4.1.20261008');
});

test('versions increase across days, years, stable releases and majors', () => {
const versions = ['3.0.2', prereleaseVersion('3.0.2', '2026-12-31'),
prereleaseVersion('3.0.2', '2027-01-01'), '3.2.0',
prereleaseVersion('3.999.0', '2027-01-02'), '4.0.0'];
const compare = (a, b) => {
const aa = a.split('.').map(Number);
const bb = b.split('.').map(Number);
return aa[0] - bb[0] || aa[1] - bb[1] || aa[2] - bb[2];
};
assert.deepEqual([...versions].sort(compare), versions);
});

test('stable releases require even minors and matching tags', () => {
checkStableVersion('3.0.2', 'v3.0.2');
checkStableVersion('3.2.0', 'v3.2.0');
checkStableVersion('4.0.0', 'v4.0.0');
for (const [version, tag] of [['3.1.0', 'v3.1.0'], ['3.0.2', 'v3.0.1'],
['3.0.2-rc.1', 'v3.0.2-rc.1'], ['3.00.2', 'v3.00.2']]) {
assert.throws(() => checkStableVersion(version, tag));
}
});

test('invalid dates and non-Marketplace versions fail closed', () => {
for (const date of ['2026-02-30', '2026-13-01', '20261006', '']) {
assert.throws(() => prereleaseVersion('3.0.1', date));
}
for (const version of ['3.0.1-rc.0', '3.0', '3.0.4294967296']) {
assert.throws(() => prereleaseVersion(version, '2026-10-06'));
}
});

test('rewrite changes only the version and is repeatable for retries', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-version-'));
try {
const file = path.join(directory, 'package.json');
const original = { name: 'r', version: '3.0.1', contributes: { commands: [] } };
fs.writeFileSync(file, `${JSON.stringify(original, null, 2)}\n`);
assert.equal(rewriteVersion(file, '2026-10-06'), '3.1.20261006');
assert.equal(rewriteVersion(file, '2026-10-06'), '3.1.20261006');
assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { ...original, version: '3.1.20261006' });
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
16 changes: 16 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
branches:
- main
workflow_call:
inputs:
prerelease-date:
description: UTC date for the Marketplace pre-release (YYYY-MM-DD)
type: string
default: ''
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -104,7 +109,18 @@ jobs:
node-version: 24
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Test extension versioning
run: node --test .github/scripts/extension-version.test.js
- name: Set Marketplace pre-release version
if: inputs.prerelease-date
env:
PRERELEASE_DATE: ${{ inputs.prerelease-date }}
run: node .github/scripts/extension-version.js prerelease "$PRERELEASE_DATE"
- name: Package Marketplace pre-release
if: inputs.prerelease-date
run: pnpm exec vsce package --pre-release --no-dependencies -o $VSIX_FILE
- name: Package extension
if: ${{ !inputs.prerelease-date }}
run: pnpm exec vsce package --no-dependencies -o $VSIX_FILE
- name: Verify packaged sess identity
run: node .github/scripts/verify-sess-vsix.js
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/marketplace-pre-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: marketplace-pre-release

on:
schedule:
- cron: '23 3 * * *'

concurrency:
group: marketplace-pre-release
cancel-in-progress: false

permissions:
contents: read
actions: read

jobs:
prepare:
if: github.repository == 'REditorSupport/vscode-R'
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.previous.outputs.publish }}
date: ${{ steps.previous.outputs.date }}
steps:
- name: Check for changes since the last successful daily run
id: previous
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# A retry retains its original UTC date and therefore its version.
RUN_DATE=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq '.created_at[:10]')
PREVIOUS=$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/marketplace-pre-release.yml/runs" \
-f event=schedule -f status=success -f branch=main -f per_page=100 \
--jq "[.workflow_runs[] | select(.id != $GITHUB_RUN_ID)] | sort_by(.created_at) | last // empty")
if [ -n "$PREVIOUS" ]; then
PREVIOUS_SHA=$(jq -r '.head_sha' <<< "$PREVIOUS")
PREVIOUS_DATE=$(jq -r '.created_at[:10]' <<< "$PREVIOUS")
if [ "$PREVIOUS_SHA" = "$GITHUB_SHA" ] || [[ "$PREVIOUS_DATE" > "$RUN_DATE" ]] || [ "$PREVIOUS_DATE" = "$RUN_DATE" ]; then
echo 'No new daily pre-release to publish.'
exit 0
fi
fi
echo "date=$RUN_DATE" >> "$GITHUB_OUTPUT"
echo 'publish=true' >> "$GITHUB_OUTPUT"

verify:
needs: prepare
if: needs.prepare.outputs.publish == 'true'
uses: ./.github/workflows/main.yml
with:
prerelease-date: ${{ needs.prepare.outputs.date }}

publish:
needs: [prepare, verify]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Download verified pre-release
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: vscode-R.vsix
path: artifacts/
- name: Publish to Visual Studio Marketplace
env:
VSCE_PAT: ${{ secrets.VSCE_TOKEN }}
run: pnpm exec vsce publish --pre-release --skip-duplicate --packagePath artifacts/vscode-R.vsix
- name: Publish to Open VSX Registry
env:
OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }}
run: pnpm exec ovsx publish --skip-duplicate artifacts/vscode-R.vsix
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,22 @@ env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
check-version:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- name: Check stable version and release tag
env:
RELEASE_TAG: ${{ github.ref_name }}
run: node .github/scripts/extension-version.js check-stable "$RELEASE_TAG"

verify:
needs: check-version
uses: ./.github/workflows/main.yml

release:
Expand Down
31 changes: 31 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,34 @@ Open <http://127.0.0.1:8765/src/test/browser/interactiveRenderer.html>. The harn
The [analysis fixtures](src/test/examples/README.md) document public/research examples, reference plots, widget checks, and large-table allocation measurements. Keep repeatable procedures there; record dated results and environment-specific limitations in the PR discussion.

For changes to persistence or supervision, also exercise the intended Remote SSH host: create managed arf sessions, adopt an existing terminal arf, create distinct objects, and submit jobs that stream text and plots. Close VS Code and disconnect SSH during execution, then reconnect. Verify the same R PIDs and objects, retained output, session isolation, usable plots, and no duplicate evaluation. Check that the adopted terminal remains usable and that stop/restart targets only the selected session. Local process tests do not establish server-specific logout, systemd, or network behavior.

## Release versioning

Stable releases use an even minor version (for example, `3.0.2` or `3.2.0`).
The release workflow rejects odd minors, non-numeric versions, and tags that do
not match `package.json`. Stable patch releases remain independent of daily
pre-releases.

The Marketplace pre-release workflow runs daily at 03:23 UTC. It skips commits
already covered by a successful daily run, runs the existing build, lint and
all-OS tests, and publishes only the verified pre-release VSIX. The GitHub
`latest` development VSIX continues to update on every verified push.

`.github/scripts/extension-version.js prerelease YYYY-MM-DD` rewrites `package.json`
only in the packaging checkout. It uses the next odd minor after an even minor,
or keeps an explicitly selected odd minor, with a UTC `YYYYMMDD` patch:
`3.0.1` → `3.1.20261006`; after releasing `3.2.0`, builds use `3.3.YYYYMMDD`.
Keep the base version in `package.json`; do not commit generated daily versions.
For development toward `4.0.0`, maintainers can explicitly set the base version
to `3.999.0`, then change it to `4.0.0` for the stable release.

The workflow reuses `VSCE_TOKEN` and `OPEN_VSX_TOKEN` to publish the same VSIX
to the VS Code Marketplace and Open VSX Registry. Both publications must succeed
for the daily run to count as successful.
Retries retain the original run's UTC date and tolerate an already-published
version in either registry, allowing a partially failed publication to recover.
A retry older than a successful daily run is skipped. No Git tags or
version-bump commits are created by daily publishing.

Run `node --test .github/scripts/extension-version.test.js` to test version generation
and stable-release validation without installing dependencies.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ for other changes and upgrade notes.
code --install-extension REditorSupport.r
```

To receive daily development updates, choose **Switch to Pre-Release Version** on the R extension's page in VS Code. New pre-releases are published to the VS Code Marketplace and Open VSX Registry when `main` changes and verification passes. Choose **Switch to Release Version** to return to stable releases. When reporting problems, include the extension version and reproduction steps.

Alternatively, you can test the latest development version by installing from GitHub:

<details>
Expand Down
Loading