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.
8.6 KiB
VS Code setup
Builds three local VS Code extensions, publishes them to a Gitea release, installs them from there, and keeps user settings in git.
| Extension | What it does |
|---|---|
colored-references |
Find All References in a syntax-highlighted virtual document, plus a sortable results panel |
vertical-tabs |
A vertical list of open tabs, colour-coded by project, with pinning |
dotnet-hot-reload |
A hot reload button in the debug toolbar, driving dotnet watch |
The short version
# once per machine, on the machine that builds
$env:GITEA_TOKEN = '<token>'
.\scripts\publish.ps1 -Build # package all three, upload to the "latest" release
# on any machine, including a fresh one
.\scripts\install.ps1 # download from the release and install
.\scripts\settings.ps1 -Pull # apply settings, keybindings, marketplace extensions
Set the server first — either in config.json or with GITEA_URL,
GITEA_OWNER and GITEA_REPO. The repo named there is where releases go; it does not
have to be this repo.
Why not Settings Sync
VS Code's built-in Settings Sync cannot be pointed at Gitea. It signs in with a Microsoft or GitHub account and talks to Microsoft's own sync service; there is no setting for a different backend, self-hosted or otherwise. The old Gist-based Settings Sync extension is out too, because Gitea has no gists.
So settings live in User/ as ordinary files, and
scripts/settings.ps1 copies them in either direction. That is
less magic than Settings Sync and more legible: a diff shows what changed.
It copies rather than symlinks on purpose. A symlink at
%APPDATA%\Code\User\settings.json is fragile — an editor that saves atomically (write
a temp file, rename it over the target) replaces the link with a regular file, and the
sync stops without anything looking broken.
Extensions from the marketplace are handled separately: settings.ps1 -Push records
them in User/extensions.txt, -Pull installs the missing ones. Ids starting local.
are skipped, because those are the three above and they come from the release.
Scripts
| Script | Does |
|---|---|
build.ps1 |
Packages each extension into dist/*.vsix. -Clone fetches missing sources from Gitea, -Pull updates them, -Only <id> narrows it |
publish.ps1 |
Creates the release if needed and uploads dist/*.vsix. -Build builds first, -Tag picks the tag |
install.ps1 |
Downloads a release's assets and installs them. -Local installs from dist/ instead, -Uninstall removes them |
settings.ps1 |
-Push machine to repo, -Pull repo to machine, -Diff compares |
dev-link.ps1 |
Junctions the sources into ~/.vscode/extensions for development. -Unlink undoes it |
ci-release.sh |
The bash equivalent of publish.ps1, for the Gitea Actions runner. SKIP_PACKAGE=1 uploads dist/ without rebuilding |
Every script takes -? for its full help.
Tags
publish.ps1 defaults to a rolling latest release: publishing again replaces the
assets in place rather than adding duplicates, so install.ps1 with no arguments always
gets current builds. Use -Tag v0.2.0 for a snapshot you want to keep, and
install.ps1 -Tag v0.2.0 to go back to it.
The tag itself is created on the default branch the first time. For a release that only
carries binaries the commit it points at does not mean much, but it is why latest
keeps pointing at an old commit — the assets are what move.
Tokens
Never in this repo. publish.ps1 and install.ps1 read, in order:
-Token <value>$env:GITEA_TOKEN%USERPROFILE%\.gitea-token
Create one at <gitea>/user/settings/applications with write:repository scope.
install.ps1 only reads, so a read:repository token is enough there — and if the
release repo is public it needs no token at all for the download, though the API call
that finds the release still wants one on most instances.
Building on Node 18
vsce needs Node 20 or newer. On Node 18 it fails with
ReferenceError: File is not defined, from a transitive undici, and pinning an older
vsce does not help because undici still resolves to a current version.
vsce/package.json works around it by pinning both, and
build.ps1 installs into that folder on first run. If you upgrade Node to 20+ you can
delete vsce/ and use npx @vscode/vsce package directly.
Upgrading Node is the better fix. The workaround exists so a machine stuck on 18 is not blocked.
Automating it
.gitea/workflows/release.yml builds and publishes on a
v* tag push, or on demand. It needs:
- a registered
act_runneron the instance - a
GITEA_TOKENsecret withwrite:repository, and read access to the extension repos - the
actions/checkoutandactions/setup-nodeactions reachable — Gitea pulls those from github.com unless the instance sets[actions] DEFAULT_ACTIONS_URL
The runner uses Node 20, so CI sidesteps the problem above entirely.
ci-release.sh needs only bash, git, node and curl — JSON goes through node, not jq,
since node is required anyway and jq frequently is not.
Why not tea
Gitea's CLI (tea) can do this — tea release create
and tea release assets create cover the upload. It is not used here because these
scripts then need nothing but PowerShell and code: no Go binary to install per
machine, no tea login add step, no second place a token lives. Downloading assets on
the install side is also plainer over the API than through tea.
If you already have tea set up, tea release create --tag latest --asset dist/*.vsix
is a fine substitute for publish.ps1.
Layout
config.json Gitea server + the extensions and where they live
User/ settings.json, keybindings.json, snippets/, extensions.txt
dist/ built .vsix files (ignored)
scripts/ the scripts above
vsce/ pinned vsce, for Node 18 (node_modules ignored)
.gitea/workflows/ the release workflow
config.json paths are relative to this repo, so it expects the extension folders as
siblings. Move them and it is one edit.
Tests
.\test\run.ps1
Runs the real scripts against test/fake-gitea.js, a stub of the
release endpoints, with a stub code on PATH so nothing is installed into the editor.
It checks the things that fail quietly:
- the
.vsixsurvives the upload byte for byte — it is a zip, so any text-encoding step in the multipart body would corrupt it and still look like a success - the token reaches the API, and survives the redirect on the download
- republishing a tag replaces its assets instead of accumulating duplicates
- every install passes
--force build.ps1andinstall.ps1 -Localwork with no Gitea configured at allci-release.shpublishes the same set aspublish.ps1
Two bugs came out of writing it. MultipartFormDataContent.Add(content, name, fileName)
emits an unquoted name=attachment on .NET Framework, which Gitea's Go parser accepts
but nothing guarantees — the header is now set explicitly. And Invoke-WebRequest drops
the Authorization header when it follows a redirect, so downloading a private repo's
asset returned 401; Save-GiteaAsset follows redirects itself, re-sending the token only
while the host does not change.
What it does not cover: a real Gitea instance, and whether VS Code loads the packages once installed. Both were checked by hand.
Known limitations
- No auto-update. VS Code only offers updates for marketplace extensions, so a new
release does not notify you. Re-run
install.ps1. --forceon every install, because VS Code otherwise declines to reinstall a version it already has, which would silently do nothing whenever you republish without bumping the version.- A private extension gallery — pointing VS Code's
product.jsonat your own gallery so updates work normally — is possible but gets overwritten by VS Code updates. Not worth it for three extensions. dev-link.ps1andinstall.ps1conflict. A junctioned source and an installed.vsixare two copies of the same extension id; VS Code loads one arbitrarily.dev-link.ps1 -Unlinkbefore installing.- Windows-only paths. The scripts assume
%APPDATA%\Code\Userand%USERPROFILE%\.vscode\extensions.ci-release.shis the portable half.