Build, publish to Gitea releases, and install the three extensions

Answers two things: how to install these without a marketplace, and how to keep
settings in sync when VS Code's Settings Sync cannot be pointed at Gitea.

- build.ps1 packages each extension into dist/
- publish.ps1 creates a release and uploads them, replacing same-named assets so a
  rolling "latest" tag stays clean
- install.ps1 downloads a release's assets and installs them, or -Local from dist/
- settings.ps1 copies User/ between the repo and the machine, and installs the
  marketplace extensions recorded in extensions.txt
- dev-link.ps1 junctions the sources into ~/.vscode/extensions for development
- ci-release.sh plus a Gitea Actions workflow do the build and publish on a tag push

Settings are copied, not symlinked: an atomic save replaces a symlink with a regular
file and the sync stops without looking broken.

vsce/ pins vsce and undici so packaging works on Node 18, which current vsce does not
support.

test/run.ps1 drives the real scripts against a stub of the Gitea release API. It
caught the multipart Content-Disposition being unquoted on .NET Framework, and
Invoke-WebRequest dropping the Authorization header across a redirect.
This commit is contained in:
max
2026-09-07 19:35:20 +02:00
commit d45639bf2f
15 changed files with 4043 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
// A stub of the parts of the Gitea API that publish.ps1 and install.ps1 use.
//
// The point is to prove the real scripts work: that the token header is sent, that the
// multipart upload delivers the .vsix byte-for-byte (a zip, so any encoding step would
// corrupt it), that re-publishing replaces assets instead of duplicating them, and that
// the download survives a redirect with the auth header intact.
const http = require('http');
const crypto = require('crypto');
const PORT = Number(process.env.PORT || 5602);
const TOKEN = 'TESTTOKEN';
let nextId = 100;
const releases = []; // { id, tag_name, name, body, assets: [] }
const blobs = new Map(); // uuid -> Buffer
const log = [];
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex').toUpperCase();
}
function authorized(request) {
return request.headers.authorization === `token ${TOKEN}`;
}
function send(response, status, payload) {
const body = payload === undefined ? '' : JSON.stringify(payload);
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(body);
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => resolve(Buffer.concat(chunks)));
request.on('error', reject);
});
}
/** The one file out of a multipart/form-data body, as raw bytes. */
function parseMultipart(buffer, contentType) {
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
if (!match) { return null; }
const boundary = Buffer.from(`--${match[1] || match[2]}`);
const parts = [];
let at = buffer.indexOf(boundary);
while (at >= 0) {
const from = at + boundary.length;
const next = buffer.indexOf(boundary, from);
if (next < 0) { break; }
parts.push(buffer.slice(from, next));
at = next;
}
for (const part of parts) {
const split = part.indexOf('\r\n\r\n');
if (split < 0) { continue; }
const headers = part.slice(0, split).toString('latin1');
// Trailing CRLF belongs to the delimiter, not the content.
let content = part.slice(split + 4);
if (content.slice(-2).toString('latin1') === '\r\n') {
content = content.slice(0, -2);
}
// Values may be unquoted: .NET Framework's MultipartFormDataContent writes
// name=attachment, and Go's mime/multipart (what Gitea uses) accepts both.
const name = /\bname=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const filename = /\bfilename=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const fieldName = name ? (name[1] !== undefined ? name[1] : name[2]) : null;
if (fieldName === 'attachment') {
return {
content,
filename: filename ? (filename[1] !== undefined ? filename[1] : filename[2]) : null,
headers: headers.trim(),
};
}
}
return null;
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
const path = url.pathname;
log.push(`${request.method} ${request.url}`);
// Serving an attachment: what browser_download_url points at.
let hit = /^\/attachments\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
// Redirect once, so the client has to carry the auth header across it.
response.writeHead(302, { Location: `/files/${hit[1]}` });
return response.end();
}
hit = /^\/files\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
if (!authorized(request)) {
log.push(' !! download had no token');
return send(response, 401, { message: 'token required on download' });
}
const blob = blobs.get(hit[1]);
if (!blob) { return send(response, 404, { message: 'no such attachment' }); }
response.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': blob.length,
});
return response.end(blob);
}
// Test-only: lets the harness assert on what the server actually received.
if (path === '/__state') {
return send(response, 200, {
log,
releases: releases.map(release => ({
id: release.id,
tag_name: release.tag_name,
name: release.name,
assets: release.assets.map(asset => ({
id: asset.id,
name: asset.name,
size: asset.size,
sha256: asset.sha256,
uploadFilename: asset.uploadFilename,
})),
})),
});
}
if (!authorized(request)) {
log.push(' !! missing or wrong token');
return send(response, 401, { message: 'token required' });
}
const api = /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/releases(.*)$/.exec(path);
if (!api) { return send(response, 404, { message: `unhandled ${path}` }); }
const rest = api[3];
// GET /releases/tags/:tag
hit = /^\/tags\/(.+)$/.exec(rest);
if (hit && request.method === 'GET') {
const tag = decodeURIComponent(hit[1]);
const release = releases.find(candidate => candidate.tag_name === tag);
if (!release) { return send(response, 404, { message: 'release not found' }); }
return send(response, 200, release);
}
// POST /releases
if (rest === '' && request.method === 'POST') {
const body = JSON.parse((await readBody(request)).toString('utf8') || '{}');
if (!body.tag_name) { return send(response, 422, { message: 'tag_name required' }); }
const release = {
id: nextId++,
tag_name: body.tag_name,
name: body.name || body.tag_name,
body: body.body || '',
draft: !!body.draft,
prerelease: !!body.prerelease,
target_commitish: body.target_commitish || 'main',
assets: [],
};
releases.push(release);
return send(response, 201, release);
}
// POST /releases/:id/assets?name=...
hit = /^\/(\d+)\/assets$/.exec(rest);
if (hit && request.method === 'POST') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const raw = await readBody(request);
const part = parseMultipart(raw, request.headers['content-type']);
if (!part) {
const preview = raw.slice(0, 400).toString('latin1').replace(/\r\n/g, '\\r\\n');
log.push(` !! no attachment field. content-type=${request.headers['content-type']}`);
log.push(` !! body starts: ${preview}`);
return send(response, 400, { message: 'expected multipart with an attachment field' });
}
log.push(` disposition: ${part.headers.replace(/\r\n/g, ' | ')}`);
const name = url.searchParams.get('name') || part.filename || 'unnamed';
const uuid = crypto.randomBytes(8).toString('hex');
blobs.set(uuid, part.content);
const asset = {
id: nextId++,
name,
size: part.content.length,
sha256: sha256(part.content),
uploadFilename: part.filename,
browser_download_url: `http://127.0.0.1:${PORT}/attachments/${uuid}`,
};
release.assets.push(asset);
return send(response, 201, asset);
}
// DELETE /releases/:id/assets/:assetId
hit = /^\/(\d+)\/assets\/(\d+)$/.exec(rest);
if (hit && request.method === 'DELETE') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const before = release.assets.length;
release.assets = release.assets.filter(asset => asset.id !== Number(hit[2]));
if (release.assets.length === before) {
return send(response, 404, { message: 'no such asset' });
}
response.writeHead(204);
return response.end();
}
return send(response, 404, { message: `unhandled ${request.method} ${path}` });
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`fake gitea on http://127.0.0.1:${PORT} (token ${TOKEN})`);
});
+204
View File
@@ -0,0 +1,204 @@
<#
.SYNOPSIS
Exercises publish.ps1, install.ps1 and ci-release.sh against a stub Gitea.
.DESCRIPTION
Runs the real scripts, pointed at test/fake-gitea.js instead of a server, with a
stub "code" on PATH so nothing is installed into the editor. What it checks:
- the token reaches the API, and the download after the redirect
- the .vsix arrives byte-for-byte (it is a zip, so any text encoding step shows up)
- republishing a tag replaces assets instead of duplicating them
- install passes --force, without which VS Code silently skips a same-version
reinstall
- build.ps1 and install.ps1 -Local work with no Gitea configured
- ci-release.sh agrees with publish.ps1
Needs dist/*.vsix, so it runs build.ps1 first unless -SkipBuild.
.EXAMPLE
.\test\run.ps1
.\test\run.ps1 -SkipBuild
#>
[CmdletBinding()]
param(
[int] $Port = 5602,
[switch] $SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$scripts = Join-Path $root 'scripts'
$dist = Join-Path $root 'dist'
$base = "http://127.0.0.1:$Port"
$failures = New-Object System.Collections.ArrayList
function Assert-That {
param([string] $What, [bool] $Condition, [string] $Detail = '')
if ($Condition) {
Write-Host " PASS $What" -ForegroundColor Green
} else {
Write-Host " FAIL $What" -ForegroundColor Red
if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray }
[void] $failures.Add($What)
}
}
function Get-State { Invoke-RestMethod -Uri "$base/__state" -UseBasicParsing }
# A stub code CLI, so an install never touches the real editor. It has to be found by
# Get-CodeCommand, which looks at PATH first.
$stubDir = Join-Path ([System.IO.Path]::GetTempPath()) "vscode-setup-test-$PID"
New-Item -ItemType Directory -Path $stubDir -Force | Out-Null
$callLog = Join-Path $stubDir 'calls.log'
Set-Content -Path (Join-Path $stubDir 'code.cmd') -Encoding ASCII -Value @(
'@echo off',
"echo %* >> `"$callLog`"",
'exit /b 0'
)
$server = $null
$savedPath = $env:PATH
try {
if (-not $SkipBuild) {
Write-Host 'building...' -ForegroundColor Cyan
& (Join-Path $scripts 'build.ps1') | Out-Null
}
$packages = @(Get-ChildItem -Path $dist -Filter '*.vsix')
if ($packages.Count -eq 0) { throw "no .vsix in $dist" }
Write-Host "starting the stub on $base" -ForegroundColor Cyan
# Set before spawning: the child inherits the environment as it is at that moment.
$env:PORT = "$Port"
$server = Start-Process -FilePath 'node' `
-ArgumentList (Join-Path $PSScriptRoot 'fake-gitea.js') `
-WindowStyle Hidden -PassThru
$up = $false
foreach ($attempt in 1..20) {
try { Get-State | Out-Null; $up = $true; break } catch { Start-Sleep -Milliseconds 250 }
}
if (-not $up) { throw "the stub did not come up on $base" }
$env:GITEA_URL = $base
$env:GITEA_OWNER = 'max'
$env:GITEA_REPO = 'vscode-extensions'
$env:GITEA_TOKEN = 'TESTTOKEN'
$env:PATH = "$stubDir;$savedPath"
Write-Host ''
Write-Host 'publish.ps1' -ForegroundColor Cyan
& (Join-Path $scripts 'publish.ps1') -Tag 'test' | Out-Null
$state = Get-State
$release = $state.releases | Where-Object { $_.tag_name -eq 'test' }
Assert-That 'the release is created' ($null -ne $release)
Assert-That "all $($packages.Count) packages are uploaded" `
($release.assets.Count -eq $packages.Count) "got $($release.assets.Count)"
$intact = $true
foreach ($asset in $release.assets) {
$local = Join-Path $dist $asset.name
if ((Get-FileHash $local -Algorithm SHA256).Hash -ne $asset.sha256) { $intact = $false }
}
Assert-That 'the uploaded bytes match dist/ exactly' $intact
$quoted = @($state.log | Where-Object { $_ -like '*disposition*' })
Assert-That 'the multipart field is name="attachment"' `
(($quoted.Count -gt 0) -and ($quoted[-1] -like '*name="attachment"*')) `
($(if ($quoted.Count) { $quoted[-1] } else { 'no disposition logged' }))
Write-Host ''
Write-Host 'publish.ps1 again' -ForegroundColor Cyan
& (Join-Path $scripts 'publish.ps1') -Tag 'test' | Out-Null
$release = (Get-State).releases | Where-Object { $_.tag_name -eq 'test' }
Assert-That 'republishing replaces rather than duplicates' `
($release.assets.Count -eq $packages.Count) "got $($release.assets.Count)"
Write-Host ''
Write-Host 'install.ps1' -ForegroundColor Cyan
Remove-Item $callLog -ErrorAction SilentlyContinue
& (Join-Path $scripts 'install.ps1') -Tag 'test' | Out-Null
$calls = @(Get-Content $callLog -ErrorAction SilentlyContinue)
Assert-That "code was called once per extension" ($calls.Count -eq $packages.Count) `
"got $($calls.Count)"
Assert-That 'every install passes --force' `
(($calls.Count -gt 0) -and -not ($calls | Where-Object { $_ -notmatch '--force' }))
$downloadedOk = $calls.Count -gt 0
foreach ($call in $calls) {
$path = ($call -replace '^--install-extension\s+', '' -replace '\s+--force\s*$', '').Trim()
$local = Join-Path $dist (Split-Path -Leaf $path)
if (-not (Test-Path $path) -or
(Get-FileHash $path -Algorithm SHA256).Hash -ne
(Get-FileHash $local -Algorithm SHA256).Hash) { $downloadedOk = $false }
}
Assert-That 'the downloaded files match dist/ (token survived the redirect)' $downloadedOk
Write-Host ''
Write-Host 'no Gitea configured' -ForegroundColor Cyan
$env:GITEA_URL = ''; $env:GITEA_OWNER = ''; $env:GITEA_REPO = ''
Remove-Item $callLog -ErrorAction SilentlyContinue
$localWorked = $true
try { & (Join-Path $scripts 'install.ps1') -Local | Out-Null } catch { $localWorked = $false }
Assert-That 'install.ps1 -Local needs no Gitea config' $localWorked
$env:GITEA_URL = $base; $env:GITEA_OWNER = 'max'; $env:GITEA_REPO = 'vscode-extensions'
# Git's bash, not the one on PATH: on Windows that is usually
# System32\bash.exe, the WSL launcher, which sees D:\ as /mnt/d and so cannot open
# the script by the path we have.
$bash = @(
'C:\Program Files\Git\bin\bash.exe',
'C:\Program Files (x86)\Git\bin\bash.exe',
(Join-Path $env:LOCALAPPDATA 'Programs\Git\bin\bash.exe')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $bash) {
$onPath = Get-Command bash -ErrorAction SilentlyContinue
if ($onPath -and $onPath.Source -notlike "$env:WINDIR*") { $bash = $onPath.Source }
}
if ($bash) {
Write-Host ''
Write-Host 'ci-release.sh' -ForegroundColor Cyan
$env:GITEA_SERVER = $base
$env:TAG = 'test-ci'
$env:SKIP_PACKAGE = '1'
# Forward slashes too: bash reads the backslashes in a Windows path as escapes,
# so D:\a\b arrives as D:ab.
$shPath = (Join-Path $scripts 'ci-release.sh') -replace '\\', '/'
& $bash $shPath 2>&1 | Out-Null
$ci = (Get-State).releases | Where-Object { $_.tag_name -eq 'test-ci' }
Assert-That 'ci-release.sh publishes the same set' `
(($null -ne $ci) -and ($ci.assets.Count -eq $packages.Count))
$ciIntact = $null -ne $ci
if ($ci) {
foreach ($asset in $ci.assets) {
$local = Join-Path $dist $asset.name
if ((Get-FileHash $local -Algorithm SHA256).Hash -ne $asset.sha256) {
$ciIntact = $false
}
}
}
Assert-That 'ci-release.sh uploads intact bytes' $ciIntact
Remove-Item Env:\SKIP_PACKAGE, Env:\TAG, Env:\GITEA_SERVER -ErrorAction SilentlyContinue
} else {
Write-Host ' SKIP ci-release.sh (no bash on PATH)' -ForegroundColor Yellow
}
} finally {
$env:PATH = $savedPath
Remove-Item Env:\GITEA_URL, Env:\GITEA_OWNER, Env:\GITEA_REPO, Env:\GITEA_TOKEN, Env:\PORT `
-ErrorAction SilentlyContinue
if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force }
Remove-Item $stubDir -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ''
if ($failures.Count -eq 0) {
Write-Host 'all checks passed' -ForegroundColor Green
exit 0
}
Write-Host "$($failures.Count) check(s) failed:" -ForegroundColor Red
$failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1