[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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFgFyiXdf8goqjeyckFLj8
This commit is contained in:
ciregenz
2026-09-08 00:23:06 -07:00
co-authored by Claude Fable 5.1
parent 62c8535967
commit cc5f0d9d22
15 changed files with 401 additions and 15 deletions
+2 -2
View File
@@ -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)
+5 -4
View File
@@ -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 }}"
+10 -1
View File
@@ -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: |
+13 -2
View File
@@ -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
+5 -2
View File
@@ -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" }
+5 -1
View File
@@ -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:
+22
View File
@@ -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
+76
View File
@@ -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"]') }}`));
});
+7 -3
View File
@@ -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
+56
View File
@@ -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 }}"
+19
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

@@ -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 <artifacts-dir> --expect-version 1.2.3
// node scripts/release/verify-release.js --dir <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 <artifacts-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();
+20
View File
@@ -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"
+43
View File
@@ -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