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:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user