From cc5f0d9d22d4cb896f1abd0a43b12530d6edde64 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 8 Sep 2026 00:23:06 -0700 Subject: [PATCH] [eric] release: every publish and download path names the public shell by literal, the release workflows publish with a shell-scoped token behind a fail-closed check, and a push runs only the Windows suite leg (ENG-516) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WFgFyiXdf8goqjeyckFLj8 --- .github/workflows/intel-x64-verify.yml | 4 +- .github/workflows/promotion-gate.yml | 9 +- .github/workflows/release-macos.yml | 11 +- .github/workflows/release-windows.yml | 15 ++- .github/workflows/smoke-windows-packaged.yml | 7 +- .github/workflows/suites-matrix.yml | 6 +- docs/RELEASE_RUNBOOK.md | 22 ++++ electron/releaseShell.test.js | 76 +++++++++++ publish.sh | 10 +- .../.github/workflows/promotion-gate.yml | 56 +++++++++ release-shell/README.md | 19 +++ release-shell/electron/build/icon.ico | Bin 0 -> 285478 bytes .../scripts/release/verify-release.js | 118 ++++++++++++++++++ scripts/release/check-shell-token.sh | 20 +++ scripts/release/sync-shell.sh | 43 +++++++ 15 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 electron/releaseShell.test.js create mode 100644 release-shell/.github/workflows/promotion-gate.yml create mode 100644 release-shell/README.md create mode 100644 release-shell/electron/build/icon.ico create mode 100644 release-shell/scripts/release/verify-release.js create mode 100755 scripts/release/check-shell-token.sh create mode 100755 scripts/release/sync-shell.sh 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 0000000000000000000000000000000000000000..18621b6e6592f90ceb062051215bb58077df42bc GIT binary patch literal 285478 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 caQ0#JvVDXLnQt2(&SPo>jDQg^0y81-4Vxt --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