diff --git a/.github/workflows/intel-x64-verify.yml b/.github/workflows/intel-x64-verify.yml index a9f7b089..4f8efe8d 100644 --- a/.github/workflows/intel-x64-verify.yml +++ b/.github/workflows/intel-x64-verify.yml @@ -32,7 +32,7 @@ jobs: - name: download published x64 DMG run: | - curl -sSL -o /tmp/x64.dmg "https://github.com/${{ github.repository }}/releases/latest/download/OpenSwarm-x64.dmg" + curl -sSL -o /tmp/x64.dmg "https://github.com/openswarm-ai/openswarm/releases/latest/download/OpenSwarm-x64.dmg" ls -la /tmp/x64.dmg hdiutil attach -nobrowse -readonly -mountpoint /tmp/oswmnt /tmp/x64.dmg mkdir -p /tmp/oswapp @@ -106,7 +106,7 @@ jobs: - name: download published Setup.exe shell: pwsh run: | - curl.exe -sSL -o $env:TEMP\OpenSwarm-Setup-x64.exe "https://github.com/${{ github.repository }}/releases/latest/download/OpenSwarm-Setup-x64.exe" + curl.exe -sSL -o $env:TEMP\OpenSwarm-Setup-x64.exe "https://github.com/openswarm-ai/openswarm/releases/latest/download/OpenSwarm-Setup-x64.exe" Get-Item $env:TEMP\OpenSwarm-Setup-x64.exe | Select-Object Name,Length - name: silent install (Squirrel) diff --git a/.github/workflows/promotion-gate.yml b/.github/workflows/promotion-gate.yml index 8a94f4b3..6464fda7 100644 --- a/.github/workflows/promotion-gate.yml +++ b/.github/workflows/promotion-gate.yml @@ -9,9 +9,10 @@ name: Promotion gate (update feeds agree) # missing or versions mismatch, this goes red so the bad release is caught # before users auto-update into it. +# Release events fire in the repo that HOLDS the releases: the public shell (openswarm-ai/openswarm). +# The copy that answers them lives in release-shell/.github/workflows/promotion-gate.yml and is pushed +# by scripts/release/sync-shell.sh; this one is the by-hand form for the repo that builds. on: - release: - types: [published, released, prereleased] workflow_dispatch: inputs: tag: @@ -44,7 +45,7 @@ jobs: shell: bash run: | mkdir -p feeds - gh release download "${{ steps.tag.outputs.tag }}" --repo "${{ github.repository }}" \ + gh release download "${{ steps.tag.outputs.tag }}" --repo openswarm-ai/openswarm \ -p 'latest*.yml' -D feeds || true ls -la feeds - name: Verify both feeds exist, agree, and resolve @@ -53,4 +54,4 @@ jobs: node scripts/release/verify-release.js \ --dir feeds \ --expect-version "${{ steps.tag.outputs.ver }}" \ - --base-url "https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}" + --base-url "https://github.com/openswarm-ai/openswarm/releases/download/${{ steps.tag.outputs.tag }}" diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 1931a214..9683e854 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -99,13 +99,22 @@ jobs: shell: bash run: python3 -m pip install --upgrade castlabs-evs + - name: The release shell must be reachable with the release token + # Releases live in the public shell every installed updater polls (openswarm-ai/openswarm), + # never in the repo that builds them, and a workflow's own token cannot write outside its repo. + if: ${{ env.APPLE_ID != '' && (github.event_name == 'push' || github.event.inputs.publish == 'true') }} + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} + run: bash scripts/release/check-shell-token.sh + - name: Build app # Skip (green) when Apple signing secrets aren't in CI: Mac ships via local # publish.sh, so a secret-less CI run should no-op, not fail red. if: ${{ env.APPLE_ID != '' }} shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }} GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} run: | diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 82340530..aa43c7cb 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -138,10 +138,19 @@ jobs: "SIGNTOOL_PATH=$signtool" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 Write-Host "SIGNTOOL_PATH=$signtool" + - name: The release shell must be reachable with the release token + # Releases live in the public shell every installed updater polls (openswarm-ai/openswarm), + # never in the repo that builds them, and a workflow's own token cannot write outside its repo. + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} + run: bash scripts/release/check-shell-token.sh + - name: Build app shell: pwsh env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }} GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} run: | @@ -206,7 +215,9 @@ jobs: if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') shell: pwsh env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} + # Every gh release call below lands in the shell, whatever repo this workflow runs in. + GH_REPO: openswarm-ai/openswarm run: | $ErrorActionPreference = 'Stop' $version = (Get-Content electron/package.json | ConvertFrom-Json).version diff --git a/.github/workflows/smoke-windows-packaged.yml b/.github/workflows/smoke-windows-packaged.yml index b09b414a..e367f066 100644 --- a/.github/workflows/smoke-windows-packaged.yml +++ b/.github/workflows/smoke-windows-packaged.yml @@ -46,6 +46,8 @@ jobs: - name: Download the shipped installer env: GH_TOKEN: ${{ github.token }} + # Draft releases sit in the public shell and are readable only with a token that can see it. + RELEASE_SHELL_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }} run: | $exe = Join-Path $env:RUNNER_TEMP "OpenSwarm-Setup-x64.exe" $runId = "${{ inputs.run_id }}" @@ -68,14 +70,15 @@ jobs: # "release not found" even though the assets are right there. Resolve it out of the full # list and pull the asset by id, which works for drafts and published releases alike. $tag = "${{ inputs.tag || 'v1.7.0' }}" - $rel = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=50" | ConvertFrom-Json | + if ($env:RELEASE_SHELL_TOKEN) { $env:GH_TOKEN = $env:RELEASE_SHELL_TOKEN } + $rel = gh api "repos/openswarm-ai/openswarm/releases?per_page=50" | ConvertFrom-Json | Where-Object { $_.tag_name -eq $tag } | Select-Object -First 1 if (-not $rel) { throw "no release (draft or published) tagged $tag" } "release: $($rel.tag_name) draft=$($rel.draft)" $asset = $rel.assets | Where-Object { $_.name -eq 'OpenSwarm-Setup-x64.exe' } | Select-Object -First 1 if (-not $asset) { throw "OpenSwarm-Setup-x64.exe is not attached to $tag" } $exe = Join-Path $env:RUNNER_TEMP "OpenSwarm-Setup-x64.exe" - gh api -H "Accept: application/octet-stream" "repos/$env:GITHUB_REPOSITORY/releases/assets/$($asset.id)" > $exe + gh api -H "Accept: application/octet-stream" "repos/openswarm-ai/openswarm/releases/assets/$($asset.id)" > $exe $size = (Get-Item $exe).Length "installer: $size bytes (release says $($asset.size))" if ($size -ne $asset.size) { throw "FAIL: download is truncated" } diff --git a/.github/workflows/suites-matrix.yml b/.github/workflows/suites-matrix.yml index 28baa561..46e77f39 100644 --- a/.github/workflows/suites-matrix.yml +++ b/.github/workflows/suites-matrix.yml @@ -4,6 +4,10 @@ name: Suites on every OS # CI ran a hermetic subset on Ubuntu. Users run a 2017 iMac, Intel Macs and Windows laptops, so the # full suites run on the three hosted OSes. fail-fast is off and every suite runs even after a red # one, so one run reports all nine verdicts. +# +# A private repo bills macOS minutes at ten times Linux; the two Mac legs ran 579 minutes in the +# fortnight before the move (5,790 billable against a 2,000-minute month), so a push runs the Windows +# leg, which no developer machine here can run, and a dispatch runs all three. on: workflow_dispatch: push: @@ -22,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, macos-15-intel, windows-latest] + os: ${{ github.event_name == 'push' && fromJSON('["windows-latest"]') || fromJSON('["macos-latest", "macos-15-intel", "windows-latest"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 60 env: diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index fd9410ed..cfcc0d41 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -71,6 +71,28 @@ Recommended order so neither platform's users skip a version: 3. Verify both `latest.yml` and `latest-mac.yml` exist on the release and their versions match before the release leaves draft. +### Releases live in the public shell, whatever repo builds them + +Every installed copy polls `github.com/openswarm-ai/openswarm` for updates (the +electron-updater feed comes from `electron/package.json`'s `publish` block, the +Windows Squirrel feed is a literal URL in `electron/main.js`), and the landing +page's download links point at that repo's `releases/latest`. So that repo is the +release shell and must stay public with its releases and tags intact; the code +that builds a release may live anywhere else. + +- The release workflows publish with `RELEASE_SHELL_TOKEN`, a fine-grained token + with Contents read and write on the shell only. A workflow's own token cannot + write outside its repo, so `scripts/release/check-shell-token.sh` runs before a + publishing build and fails closed when the token is missing or read-only. +- `publish.sh` exports `GH_REPO=openswarm-ai/openswarm`, so every `gh release` + call and the ENG-319 dangling-tag guard act on the shell. +- `promotion-gate.yml` fires on release events, which happen in the shell, so the + live copy of that workflow is the one in `release-shell/`; `scripts/release/sync-shell.sh` + publishes that directory as the shell's `main` (dry run by default). The shell + also keeps `electron/build/icon.ico` at its old path because the Squirrel + installer fetches its icon from `raw.githubusercontent.com` on `main`. +- `electron/releaseShell.test.js` pins all of the above. + ## Auto-update verification (before promoting) The auto-updater (electron-updater) checks GitHub Releases on launch and every diff --git a/electron/releaseShell.test.js b/electron/releaseShell.test.js new file mode 100644 index 00000000..2a9185f9 --- /dev/null +++ b/electron/releaseShell.test.js @@ -0,0 +1,76 @@ +// Every installed copy polls github.com/openswarm-ai/openswarm for updates, and the landing page links +// its downloads there. When the source moves to a private repo that public repo becomes a releases-only +// shell, so every path that publishes or downloads a build must name the shell, never "the repo I run in". +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const pkg = require('./package.json'); + +const SHELL = 'openswarm-ai/openswarm'; +const root = path.join(__dirname, '..'); +const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8'); + +test('electron-builder publishes into the shell', () => { + assert.deepStrictEqual(pkg.build.publish, { provider: 'github', owner: 'openswarm-ai', repo: 'openswarm' }); +}); + +test('both updater feeds in main.js point at the shell', () => { + const main = read('electron/main.js'); + assert.ok(main.includes(`https://github.com/${SHELL}/releases/latest/download/RELEASES`), 'the Squirrel probe'); + assert.ok(main.includes(`setFeedURL({ url: 'https://github.com/${SHELL}/releases/latest/download/' })`), 'the Squirrel feed'); +}); + +test('the Windows installer icon and alias upload name the shell', () => { + const ps1 = read('scripts/build-app-win.ps1'); + assert.ok(ps1.includes(`https://raw.githubusercontent.com/${SHELL}/main/electron/build/icon.ico`), 'Squirrel reads the icon off the shell main branch'); + assert.ok(ps1.includes(`gh release upload "v$version" $AliasExe --repo ${SHELL} --clobber`)); + assert.ok(fs.existsSync(path.join(root, 'release-shell/electron/build/icon.ico')), 'the shell must keep the icon at that path'); +}); + +test('every release workflow publishes with the shell token and names the shell', () => { + for (const wf of ['release-macos.yml', 'release-windows.yml']) { + const y = read(`.github/workflows/${wf}`); + const check = y.indexOf('run: bash scripts/release/check-shell-token.sh'); + const build = y.indexOf('- name: Build app'); + assert.ok(check > 0 && check < build, `${wf}: the shell check runs before the build`); + const buildEnv = y.slice(build, y.indexOf('run:', build)); + assert.ok(buildEnv.includes('GH_TOKEN: ${{ secrets.RELEASE_SHELL_TOKEN }}'), `${wf}: the build publishes with the shell token`); + assert.ok(!y.includes('GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}'), `${wf}: a workflow token only writes to its own repo`); + } + const win = read('.github/workflows/release-windows.yml'); + assert.ok(win.includes(`GH_REPO: ${SHELL}`), 'gh release calls on Windows land in the shell'); +}); + +test('no download or gate path reads the repo it runs in', () => { + for (const wf of ['promotion-gate.yml', 'intel-x64-verify.yml', 'smoke-windows-packaged.yml']) { + const y = read(`.github/workflows/${wf}`); + assert.ok(!y.includes('github.repository') && !y.includes('GITHUB_REPOSITORY'), `${wf} must name ${SHELL}`); + assert.ok(y.includes(SHELL), `${wf} names the shell`); + } +}); + +test('the source repo gate is by hand and the shell copy answers release events', () => { + assert.ok(!read('.github/workflows/promotion-gate.yml').includes('\n release:'), 'release events never fire in the build repo'); + const shellGate = read('release-shell/.github/workflows/promotion-gate.yml'); + assert.ok(shellGate.includes('\n release:\n types: [published, released, prereleased]')); + assert.ok(shellGate.includes('node scripts/release/verify-release.js')); +}); + +test('the shell carries byte-identical copies of what its gate and installer need', () => { + for (const rel of ['scripts/release/verify-release.js', 'electron/build/icon.ico']) { + assert.ok(fs.readFileSync(path.join(root, rel)).equals(fs.readFileSync(path.join(root, 'release-shell', rel))), `${rel} drifted`); + } +}); + +test('publish.sh sends releases and the ENG-319 tag guard to the shell, not to origin', () => { + const sh = read('publish.sh'); + assert.ok(sh.includes(`export GH_REPO="\${GH_REPO:-${SHELL}}"`)); + assert.ok(sh.includes('gh api -X DELETE "repos/$GH_REPO/git/refs/tags/v$VERSION"')); + assert.ok(!sh.includes('git push origin ":refs/tags/'), 'the build repo tag is not the feed tag'); +}); + +test('a push runs only the Windows suite leg; Mac minutes are spent on purpose', () => { + const y = read('.github/workflows/suites-matrix.yml'); + assert.ok(y.includes(`os: \${{ github.event_name == 'push' && fromJSON('["windows-latest"]') || fromJSON('["macos-latest", "macos-15-intel", "windows-latest"]') }}`)); +}); diff --git a/publish.sh b/publish.sh index e9dc8763..d0c10239 100755 --- a/publish.sh +++ b/publish.sh @@ -26,6 +26,9 @@ PROJECT_ROOT="$(dirname "$PUBLISH_ABSPATH")" cd "$PROJECT_ROOT" # Local release credentials (Apple notarization + castlabs EVS/Widevine), gitignored. Sourcing here means a push never stalls on a missing cred once .release.env exists. See .release.env.example. if [ -f "$PROJECT_ROOT/.release.env" ]; then set -a; . "$PROJECT_ROOT/.release.env"; set +a; echo "==> sourced .release.env"; fi +# Releases live in the public shell every installed updater polls, whatever repo this checkout came from. +export GH_REPO="${GH_REPO:-openswarm-ai/openswarm}" +echo "==> releases go to $GH_REPO" # electron-builder auto-detects prerelease from semver suffix in electron/package.json # (e.g. "1.0.37-exp.1" publishes as GitHub Pre-release; "1.0.37" publishes as stable). @@ -50,9 +53,10 @@ if [[ "$VERSION" == *-* ]]; then # ENG-319 guard: a git tag on origin whose release is a DRAFT poisons releases.atom (the feed # lists bare tags), so every experimental updater resolves it first and 404s on its assets. # This broke "check for updates" fleet-wide for hours on 2026-08-19. Kill it here, always. - if git ls-remote --tags origin "refs/tags/v$VERSION" | grep -q .; then - echo "==> ENG-319 guard: deleting dangling remote tag v$VERSION (release is a draft; a public tag would 404 every experimental updater)" - git push origin ":refs/tags/v$VERSION" || echo "WARN: could not delete remote tag v$VERSION; DELETE IT MANUALLY or updaters 404" + # The feed is the shell's, so the tag that matters is the shell's, not the build repo's. + if gh api "repos/$GH_REPO/git/ref/tags/v$VERSION" >/dev/null 2>&1; then + echo "==> ENG-319 guard: deleting dangling tag v$VERSION on $GH_REPO (release is a draft; a public tag would 404 every experimental updater)" + gh api -X DELETE "repos/$GH_REPO/git/refs/tags/v$VERSION" || echo "WARN: could not delete tag v$VERSION on $GH_REPO; DELETE IT MANUALLY or updaters 404" fi fi diff --git a/release-shell/.github/workflows/promotion-gate.yml b/release-shell/.github/workflows/promotion-gate.yml new file mode 100644 index 00000000..8a94f4b3 --- /dev/null +++ b/release-shell/.github/workflows/promotion-gate.yml @@ -0,0 +1,56 @@ +name: Promotion gate (update feeds agree) + +# The "don't let a half-baked release become latest" gate. Releases should be +# cut as DRAFT first (publish.sh drafts experimental builds; do the same for +# stable and only un-draft after dogfooding — see docs/RELEASE_CHECKLIST.md). +# When a release is published / un-drafted, this verifies BOTH auto-updater +# feeds exist, agree on version (with each other and the tag), and that every +# referenced asset actually resolves (HEAD 200). If a platform's feed is +# missing or versions mismatch, this goes red so the bad release is caught +# before users auto-update into it. + +on: + release: + types: [published, released, prereleased] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to verify (e.g. v1.2.3)' + required: true + +permissions: + contents: read + +jobs: + verify-feeds: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.18.1' + - name: Resolve tag + id: tag + shell: bash + run: | + tag="${{ github.event.release.tag_name }}" + [ -z "$tag" ] && tag="${{ github.event.inputs.tag }}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ver=${tag#v}" >> "$GITHUB_OUTPUT" + - name: Download release feeds + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mkdir -p feeds + gh release download "${{ steps.tag.outputs.tag }}" --repo "${{ github.repository }}" \ + -p 'latest*.yml' -D feeds || true + ls -la feeds + - name: Verify both feeds exist, agree, and resolve + shell: bash + run: | + node scripts/release/verify-release.js \ + --dir feeds \ + --expect-version "${{ steps.tag.outputs.ver }}" \ + --base-url "https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}" diff --git a/release-shell/README.md b/release-shell/README.md new file mode 100644 index 00000000..acfd9f0c --- /dev/null +++ b/release-shell/README.md @@ -0,0 +1,19 @@ +# OpenSwarm releases + +This repository holds the signed builds of OpenSwarm and nothing else. Every installed copy checks here for updates, and the download links on [openswarm.com](https://openswarm.com) point here. + +## Download + +- macOS (Apple silicon): https://github.com/openswarm-ai/openswarm/releases/latest/download/OpenSwarm-arm64.dmg +- macOS (Intel): https://github.com/openswarm-ai/openswarm/releases/latest/download/OpenSwarm-x64.dmg +- Windows: https://github.com/openswarm-ai/openswarm/releases/latest/download/OpenSwarm-Setup-x64.exe + +The release marked **Latest** is the stable build. Releases marked **Pre-release** are the experimental channel, which the app offers when you turn on experimental updates in Settings. + +## Bugs and requests + +Open an issue here: https://github.com/openswarm-ai/openswarm/issues/new + +## Source + +The source code of every version up to v1.7.9 stays available under this repository's tags, under the AGPL-3.0 licence it was published with. Development continues in a private repository. diff --git a/release-shell/electron/build/icon.ico b/release-shell/electron/build/icon.ico new file mode 100644 index 00000000..18621b6e Binary files /dev/null and b/release-shell/electron/build/icon.ico differ diff --git a/release-shell/scripts/release/verify-release.js b/release-shell/scripts/release/verify-release.js new file mode 100644 index 00000000..e8291610 --- /dev/null +++ b/release-shell/scripts/release/verify-release.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Phase 5a promotion gate: before a draft release is allowed to become "latest", +// prove both auto-updater feeds exist, agree on version, and that their assets +// actually resolve. A release that ships latest.yml but not latest-mac.yml (or +// with mismatched versions) silently strands one platform's users on the old +// build, which is the exact "broken latest" failure this gate exists to stop. +// +// Usage: +// node scripts/release/verify-release.js --dir --expect-version 1.2.3 +// node scripts/release/verify-release.js --dir --expect-version 1.2.3 \ +// --base-url https://github.com/openswarm-ai/openswarm/releases/download/v1.2.3 +// +// --dir directory containing latest.yml + latest-mac.yml +// --expect-version version both feeds (and their filenames) must match +// --base-url if given, HEAD-check every referenced asset resolves (200) +// +// Exit 0 = promotable. Exit 1 = blocked (prints the first blocking reason). + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const FEEDS = ['latest.yml', 'latest-mac.yml']; + +function parseArgs(argv) { + const out = { dir: null, expectVersion: null, baseUrl: null, json: false }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--dir') out.dir = argv[++i]; + else if (argv[i] === '--expect-version') out.expectVersion = argv[++i]; + else if (argv[i] === '--base-url') out.baseUrl = argv[++i]; + else if (argv[i] === '--json') out.json = true; + } + return out; +} + +// Minimal electron-builder-feed parser. We only need `version:` and the asset +// filenames (top-level `path:` plus each `- url:` under `files:`). Avoiding a +// YAML dependency keeps this runnable on a bare CI node with no install step. +function parseFeed(text) { + const version = (text.match(/^version:\s*(.+)$/m) || [])[1]; + const assets = new Set(); + const topPath = (text.match(/^path:\s*(.+)$/m) || [])[1]; + if (topPath) assets.add(topPath.trim()); + const urlRe = /^\s*-?\s*url:\s*(.+)$/gm; + let m; + while ((m = urlRe.exec(text)) !== null) assets.add(m[1].trim()); + return { version: version ? version.trim() : null, assets: [...assets] }; +} + +function headOk(url) { + return new Promise((resolve) => { + const req = https.request(url, { method: 'HEAD' }, (res) => { + // GitHub release assets 302 to a signed CDN URL; follow one hop. + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + headOk(res.headers.location).then(resolve); + } else { + resolve(res.statusCode === 200); + } + }); + req.on('error', () => resolve(false)); + req.setTimeout(15000, () => { req.destroy(); resolve(false); }); + req.end(); + }); +} + +function fail(msg, json) { + if (json) process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n'); + else process.stderr.write(`BLOCKED: ${msg}\n`); + process.exit(1); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir) fail('--dir is required', args.json); + + const feeds = {}; + for (const name of FEEDS) { + const p = path.join(args.dir, name); + if (!fs.existsSync(p)) fail(`missing feed: ${name} (one platform would be stranded on the old build)`, args.json); + feeds[name] = parseFeed(fs.readFileSync(p, 'utf8')); + if (!feeds[name].version) fail(`${name} has no version: field`, args.json); + } + + const versions = FEEDS.map((n) => feeds[n].version); + if (new Set(versions).size !== 1) { + fail(`version mismatch across feeds: ${FEEDS.map((n) => `${n}=${feeds[n].version}`).join(', ')}`, args.json); + } + const releaseVersion = versions[0]; + + if (args.expectVersion) { + const want = args.expectVersion.replace(/^v/, ''); + if (releaseVersion !== want) fail(`feeds say ${releaseVersion} but expected ${want}`, args.json); + } + + if (args.baseUrl) { + const base = args.baseUrl.replace(/\/+$/, ''); + for (const name of FEEDS) { + // The .yml itself must resolve, plus every asset it points at. + const toCheck = [name, ...feeds[name].assets]; + for (const asset of toCheck) { + const url = `${base}/${asset}`; + // eslint-disable-next-line no-await-in-loop + const ok = await headOk(url); + if (!ok) fail(`asset does not resolve (HEAD != 200): ${url}`, args.json); + } + } + } + + const result = { ok: true, version: releaseVersion, feeds: FEEDS, checkedUrls: !!args.baseUrl }; + if (args.json) process.stdout.write(JSON.stringify(result) + '\n'); + else { + process.stdout.write(`\nPROMOTABLE: both feeds present, version ${releaseVersion} agrees`); + process.stdout.write(args.baseUrl ? ', all assets resolve.\n\n' : ' (URL check skipped; pass --base-url to enable).\n\n'); + } +} + +main(); diff --git a/scripts/release/check-shell-token.sh b/scripts/release/check-shell-token.sh new file mode 100755 index 00000000..158d6845 --- /dev/null +++ b/scripts/release/check-shell-token.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# The shell is the public repo every installed updater polls; a build repo's own workflow token cannot write to it. +set -euo pipefail + +SHELL_REPO="${RELEASE_SHELL_REPO:-openswarm-ai/openswarm}" + +if [ -z "${GH_TOKEN:-}" ]; then + echo "RELEASE_SHELL_TOKEN is not set: this run cannot publish into $SHELL_REPO" >&2 + exit 1 +fi +if ! gh api "repos/$SHELL_REPO" --jq '.full_name' >/dev/null; then + echo "the release token cannot read $SHELL_REPO" >&2 + exit 1 +fi +if [ "$(gh api "repos/$SHELL_REPO" --jq '.permissions.push')" != "true" ]; then + echo "the release token cannot write to $SHELL_REPO, so the release would land nowhere" >&2 + exit 1 +fi +gh release list --repo "$SHELL_REPO" --limit 1 >/dev/null +echo "release shell $SHELL_REPO is reachable with write access" diff --git a/scripts/release/sync-shell.sh b/scripts/release/sync-shell.sh new file mode 100755 index 00000000..970617dd --- /dev/null +++ b/scripts/release/sync-shell.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Publishes release-shell/ as the only content of the public shell's main. Tags and releases are never touched: +# every installed updater and every download link reads them from this repo, and a deleted tag takes its release with it. +set -euo pipefail + +SHELL_REPO="${RELEASE_SHELL_REPO:-openswarm-ai/openswarm}" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +MODE="${1:---dry-run}" +case "$MODE" in + --dry-run|--apply|--apply-and-prune) ;; + *) echo "usage: sync-shell.sh [--dry-run|--apply|--apply-and-prune]" >&2; exit 2 ;; +esac + +cmp -s "$ROOT/scripts/release/verify-release.js" "$ROOT/release-shell/scripts/release/verify-release.js" \ + || { echo "release-shell/scripts/release/verify-release.js drifted from scripts/release/verify-release.js" >&2; exit 1; } +cmp -s "$ROOT/electron/build/icon.ico" "$ROOT/release-shell/electron/build/icon.ico" \ + || { echo "release-shell/electron/build/icon.ico drifted from electron/build/icon.ico" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +git init -q "$WORK" +cp -R "$ROOT/release-shell/." "$WORK/" +(cd "$WORK" && git add -A && git -c user.name=openswarm -c user.email=releases@openswarm.com commit -q -m "releases shell, synced $(date -u +%Y-%m-%d)") + +echo "== $SHELL_REPO main would carry:" +(cd "$WORK" && git ls-files | sed 's/^/ /') +others="$(gh api "repos/$SHELL_REPO/branches?per_page=100" --paginate --jq '.[].name' | grep -vx main || true)" +echo "== branches on $SHELL_REPO other than main: $(printf '%s\n' "$others" | grep -c . || true)" +echo "== tags on $SHELL_REPO (kept): $(gh api "repos/$SHELL_REPO/tags?per_page=100" --paginate --jq '.[].name' | grep -c . || true)" + +if [ "$MODE" = "--dry-run" ]; then + echo "dry run: nothing pushed, nothing deleted" + exit 0 +fi + +(cd "$WORK" && git push --force "https://github.com/$SHELL_REPO.git" HEAD:refs/heads/main) +echo "pushed release-shell/ as $SHELL_REPO main" + +if [ "$MODE" = "--apply-and-prune" ]; then + for b in $others; do + gh api -X DELETE "repos/$SHELL_REPO/git/refs/heads/$b" >/dev/null && echo "deleted branch $b" + done +fi