diff --git a/.github/workflows/kiro-extension.yml b/.github/workflows/kiro-extension.yml index b41edb5..aad5a34 100644 --- a/.github/workflows/kiro-extension.yml +++ b/.github/workflows/kiro-extension.yml @@ -174,27 +174,57 @@ jobs: gh release upload "$TAG" release/*.vsix --clobber - # Publishes the moment THIS platform is ready, without waiting for the - # others. Only ever runs from a release tag. + # Uploading to the registry is an HTTP request, not a build, so it does not need + # the platform it targets. Splitting it out buys two things. The secret can live + # under a GitHub environment, which is only readable by a job declaring + # `environment:`, and no half-published version is possible: Open VSX treats a + # version as immutable, so if one platform failed to build after another had + # already published, that gap could never be filled, only superseded by a + # higher version. + publish: + name: publish ${{ matrix.target }} + needs: release + if: startsWith(github.ref, 'refs/tags/kiro-extension-v') + runs-on: ubuntu-22.04 + timeout-minutes: 15 + + # Only needs to read back the build artifact. Stated explicitly so a + # restrictive repository default cannot fail the download confusingly. + permissions: + contents: read + actions: read + + # Names the environment holding the OVSX_PAT secret. This must match the + # environment exactly or the secret below silently resolves to an empty + # string. The environment happens to be named after the secret it carries. + environment: OVSX_PAT + + strategy: + # One platform failing to upload must not strand the others. + fail-fast: false + matrix: + target: [darwin-arm64, darwin-x64, win32-x64, linux-x64] + + defaults: + run: + working-directory: kiro-extension + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: actions/download-artifact@v4 + with: + name: vsix-${{ matrix.target }} + path: kiro-extension/release + - name: Publish ${{ matrix.target }} to Open VSX - if: startsWith(github.ref, 'refs/tags/kiro-extension-v') env: OVSX_PAT: ${{ secrets.OVSX_PAT }} - shell: bash - run: | - if [ -z "$OVSX_PAT" ]; then - echo "OVSX_PAT resolved to an empty string, refusing to publish." >&2 - echo "" >&2 - echo "If you added it under Settings > Environments, it is only" >&2 - echo "visible to a job that declares 'environment:'. This job does" >&2 - echo "not, deliberately: an environment with protection rules would" >&2 - echo "also gate every pull request build." >&2 - echo "" >&2 - echo "Add it as a repository secret instead:" >&2 - echo "Settings > Secrets and variables > Actions > New repository secret" >&2 - exit 1 - fi - node scripts/release.mjs --publish + run: node scripts/publish.mjs --target=${{ matrix.target }} # A platform-specific VSIX declares TargetPlatform, and a mismatched host # refuses to install it. This produces one file with every companion binary and diff --git a/kiro-extension/scripts/publish.mjs b/kiro-extension/scripts/publish.mjs new file mode 100644 index 0000000..e27ffa3 --- /dev/null +++ b/kiro-extension/scripts/publish.mjs @@ -0,0 +1,94 @@ +/** + * Publishes an already-packaged VSIX to Open VSX, the registry Kiro installs + * from. Packaging happens on the platform that can compile the native mascot; + * uploading is just an HTTP request, so it is split out and runs anywhere. + * + * node scripts/publish.mjs --target darwin-arm64 + * + * Expects the matching VSIX to already exist in release/, which is how the CI + * publish job consumes the artifact the build job produced. + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const manifest = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf-8')); + +const target = process.argv.find((arg) => arg.startsWith('--target='))?.split('=')[1]; +if (!target) { + console.error('[doraemon] usage: node scripts/publish.mjs --target='); + process.exit(1); +} + +if (!process.env['OVSX_PAT']) { + console.error('[doraemon] OVSX_PAT resolved to an empty string, refusing to publish.'); + console.error(''); + console.error('[doraemon] In CI this usually means the secret lives under an'); + console.error('[doraemon] environment, but the job did not declare "environment:".'); + console.error('[doraemon] Locally, create a token at:'); + console.error('[doraemon] https://open-vsx.org/user-settings/tokens'); + process.exit(1); +} + +/* + * The tag names the release but the version comes from package.json. Publishing a + * mismatch is unrecoverable: Open VSX treats a version as immutable, so the wrong + * number cannot be replaced afterwards, only superseded by a higher one. + */ +const tag = process.env['GITHUB_REF_NAME']; +if (tag?.startsWith('kiro-extension-v')) { + const tagged = tag.slice('kiro-extension-v'.length); + if (tagged !== manifest.version) { + console.error(`[doraemon] tag ${tag} does not match package.json ${manifest.version}.`); + console.error('[doraemon] bump the version and retag, rather than publishing a mismatch.'); + process.exit(1); + } +} + +// Mirrors the name release.mjs packages under, so no globbing is needed. +const vsix = path.join(root, 'release', `${manifest.name}-${manifest.version}-${target}.vsix`); +try { + await fs.access(vsix); +} catch { + console.error(`[doraemon] ${path.relative(root, vsix)} is missing.`); + console.error('[doraemon] package it first, or check the build artifact was downloaded.'); + process.exit(1); +} + +const { size } = await fs.stat(vsix); +console.log(`[doraemon] publishing ${path.relative(root, vsix)} (${(size / 1024 / 1024).toFixed(2)} MB)`); + +/* + * Publishing into a namespace that does not exist fails outright. Creating it is + * idempotent, so doing it here means the first ever release needs no manual step. + * Four platforms race to create the same namespace and three will lose, which is + * why an "already exists" result is treated as success. + */ +console.log(`[doraemon] ensuring namespace "${manifest.publisher}" exists`); +const namespace = spawnSync('npx', ['--yes', 'ovsx', 'create-namespace', manifest.publisher], { + cwd: root, + encoding: 'utf-8', + shell: process.platform === 'win32', +}); +const namespaceOutput = `${namespace.stdout ?? ''}${namespace.stderr ?? ''}`; +if (namespace.status !== 0 && !/already (exists|owned)/i.test(namespaceOutput)) { + console.error('[doraemon] could not create the namespace:'); + console.error(namespaceOutput.trim()); + console.error('[doraemon] claim it manually at https://open-vsx.org and retry.'); + process.exit(1); +} + +console.log(`[doraemon] uploading ${target} as "${manifest.publisher}"`); +const publish = spawnSync('npx', ['--yes', 'ovsx', 'publish', vsix, '--target', target], { + cwd: root, + stdio: 'inherit', + shell: process.platform === 'win32', +}); +if (publish.status !== 0) { + console.error(`[doraemon] publishing ${target} failed`); + process.exit(publish.status ?? 1); +} + +console.log(`[doraemon] published ${manifest.name} ${manifest.version} (${target})`);