mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[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:
co-authored by
Claude Fable 5.1
parent
62c8535967
commit
cc5f0d9d22
@@ -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)
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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();
|
||||
Executable
+20
@@ -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"
|
||||
Executable
+43
@@ -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
|
||||
Reference in New Issue
Block a user