diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25606eee2..3fa2c0b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: if: runner.os == 'Linux' run: git diff --exit-code src/bindings.ts - # The remote development server ships as a static Linux binary (release.yml): + # The remote development server ships as a static Linux binary (release-build.yml): # keep that build working. server: name: Remote server (linux musl) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..111c5ed45 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,119 @@ +name: Nightly + +# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own +# version, so remote development finds the server of that version. The `nightly` release holds +# the manifest the nightly app updates from; its tag marks the commit last built, and a run with +# nothing newer stops there. Only the latest builds are kept. +on: + schedule: + - cron: "17 2,14 * * *" + workflow_dispatch: + inputs: + force: + description: "Build even if nothing is newer than the last nightly" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: nightly + +env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + +jobs: + prepare: + permissions: + actions: read + contents: write + if: github.repository == 'l0ng-ai/openidea' + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.commit.outputs.sha }} + tag: ${{ steps.release.outputs.tag }} + release_id: ${{ steps.release.outputs.id }} + steps: + - id: commit + env: + FORCE: ${{ inputs.force }} + run: | + sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \ + --json headSha --jq '.[0].headSha // ""')" + if [ -z "$sha" ]; then + echo "::notice::no main commit has passed CI" + exit 0 + fi + # No `nightly` tag yet (the first run): compare fails and the build goes on. + status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)" + if [ "$status" != ahead ] && [ "$FORCE" != true ]; then + echo "::notice::$sha is not newer than the last nightly" + exit 0 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v5 + if: steps.commit.outputs.sha != '' + with: { ref: "${{ steps.commit.outputs.sha }}" } + - uses: actions/setup-node@v5 + if: steps.commit.outputs.sha != '' + with: { node-version: 22 } + - uses: dtolnay/rust-toolchain@stable + if: steps.commit.outputs.sha != '' + - id: release + if: steps.commit.outputs.sha != '' + env: + SHA: ${{ steps.commit.outputs.sha }} + run: | + version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")" + tag="v$version" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "chore: $tag" + git tag "$tag" + git push origin "$tag" + id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \ + -F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "id=$id" >> "$GITHUB_OUTPUT" + + binaries: + permissions: + contents: write + needs: prepare + if: needs.prepare.outputs.tag != '' + uses: ./.github/workflows/release-build.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + release_id: ${{ needs.prepare.outputs.release_id }} + secrets: inherit + + publish: + permissions: + contents: write + needs: [prepare, binaries] + runs-on: ubuntu-latest + env: + TAG: ${{ needs.prepare.outputs.tag }} + SHA: ${{ needs.prepare.outputs.sha }} + KEEP: 14 + steps: + - name: Publish the build + run: gh release edit "$TAG" --tag "$TAG" --draft=false + - name: Point the updater at it + run: | + gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP" + gh release view nightly >/dev/null 2>&1 || + gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \ + --notes "The update manifest of the nightly builds, and the commit last built." + gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber + gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent + # Failed builds' drafts go too, once they are this old. + - name: Delete old builds + run: | + gh release list --limit 200 --json tagName,createdAt --jq \ + '[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' | + while read -r tag; do + gh release delete "$tag" --cleanup-tag --yes + done diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 000000000..7b2fe937e --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,262 @@ +name: Release build + +# Builds a tag's desktop apps, remote servers and updater manifest into its draft release. +# Stable releases (release.yml) and nightly builds (nightly.yml) both run it. +on: + workflow_call: + inputs: + tag: { type: string, required: true } + release_id: { type: string, required: true } + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + TAG: ${{ inputs.tag }} + +jobs: + plugin-keys: + permissions: {} + runs-on: ubuntu-latest + outputs: + keys: ${{ steps.keys.outputs.keys }} + steps: + # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed + # `openidea-plugin keygen` writes), built into the app: their packages + # must carry its signature whatever the marketplace index lists. Without + # the secret the app takes the index's word, as development builds do. + - id: keys + env: + SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + run: | + if [ -n "$SIGNING_KEY" ]; then + seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')" + key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p | + openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)" + echo "keys=$key" >> "$GITHUB_OUTPUT" + fi + + build: + permissions: + contents: write + needs: plugin-keys + name: Build (${{ matrix.name }}) + env: + OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS arm64 + os: macos-latest + target: aarch64-apple-darwin + - name: macOS x64 + os: macos-latest + target: x86_64-apple-darwin + - name: Linux x64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - name: Linux arm64 + os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - name: Windows arm64 + os: windows-11-arm + target: aarch64-pc-windows-msvc + - name: Windows x64 + os: windows-latest + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + with: { ref: "${{ env.TAG }}" } + - uses: pnpm/action-setup@v5 + with: { version: 11 } + - uses: actions/setup-node@v5 + with: { node-version: 22, cache: pnpm } + - uses: dtolnay/rust-toolchain@stable + with: { targets: "${{ matrix.target }}" } + - uses: Swatinem/rust-cache@v2 + with: { key: "${{ matrix.target }}" } + - name: Linux deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf + - run: pnpm install --frozen-lockfile + # Signing is optional: only secrets that are set get exported, so forks and + # unconfigured repos produce unsigned builds instead of failing. + - name: Signing env + shell: bash + env: + APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }} + APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} + APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} + APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }} + APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \ + APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do + [ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV" + done + true + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + releaseId: ${{ inputs.release_id }} + tagName: ${{ env.TAG }} + args: --target ${{ matrix.target }} + # The parallel builds would race on one latest.json; `updater-manifest` writes it once. + uploadUpdaterJson: false + # tauri-action notarizes the .app but not the DMG around it; notarize and + # staple the DMG too, then replace the uploaded asset. + - name: Notarize DMG + if: runner.os == 'macOS' && env.APPLE_ID != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do + xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait + xcrun stapler staple "$dmg" + # Release assets have no spaces: GitHub stored the first upload with dots. + asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)" + cp "$dmg" "$asset" + gh release upload "$TAG" "$asset" --clobber + done + # Portable Windows builds (#1857): on Windows Tauri embeds the + # frontend in the exe and WebView2 is a system component, so zipping + # the raw exe gives an unpack-and-run alternative to the installers. + - name: Portable zip + if: runner.os == 'Windows' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $target = "${{ matrix.target }}" + $arch = if ($target -like "x86_64*") { "x64" } else { "arm64" } + $version = $env:TAG -replace '^v', '' + $asset = "OpenIDEA_${version}_windows_${arch}_portable.zip" + $dir = "target/$target/release" + $files = @("$dir/openidea.exe") + if (Test-Path "$dir/WebView2Loader.dll") { + $files += "$dir/WebView2Loader.dll" + } + Compress-Archive -Path $files -DestinationPath $asset -Force + $hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower() + "$hash $asset" | Out-File -Encoding ascii "$asset.sha256" + gh release upload $env:TAG $asset "$asset.sha256" --clobber + + # The remote development server, per host platform it can run on. The desktop + # app downloads the one for the host (ide-tools `server`) and installs it over SSH. + # Linux builds are static (musl), so they run whatever the host's glibc. + server: + permissions: + contents: write + name: Server (${{ matrix.os }}-${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 } + - { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 } + - { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 } + - { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: { ref: "${{ env.TAG }}" } + - uses: dtolnay/rust-toolchain@stable + with: { targets: "${{ matrix.target }}" } + - uses: Swatinem/rust-cache@v2 + with: { key: "server-${{ matrix.target }}" } + - name: musl toolchain + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Build + run: | + # musl-tools only ships `musl-gcc`; cc-rs would look for `-linux-musl-gcc`. + if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi + cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET" + env: + TARGET: ${{ matrix.target }} + - name: Upload + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz + run: | + gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET" + if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256" + gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + + # The updater manifest (latest.json), built once from the signed bundles every build uploaded. + # Fails when a platform is missing, so a release never ships a manifest without it. + updater-manifest: + permissions: + contents: write + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ inputs.release_id }} + with: + script: | + const { owner, repo } = context.repo; + const release_id = Number(process.env.RELEASE_ID); + const tag = process.env.TAG; + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id }); + const sigs = assets.filter((a) => a.name.endsWith(".sig")); + if (sigs.length === 0) { + core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json"); + return; + } + // Bundle suffix → updater platform keys, as tauri-action names them. + // Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows. + const nightly = tag.includes("-nightly."); + const windows = (arch, msi, nsis) => nightly + ? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]] + : [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]]; + const kinds = [ + [/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]], + [/_arm64\.deb$/, ["linux-aarch64-deb"]], + ...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]), + [/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]], + [/_amd64\.deb$/, ["linux-x86_64-deb"]], + ...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]), + [/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]], + [/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]], + ...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/), + ...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/), + ]; + const platforms = {}; + for (const sig of sigs) { + const bundle = sig.name.slice(0, -".sig".length); + const kind = kinds.find(([re]) => re.test(bundle)); + if (!kind) continue; + const { data } = await github.rest.repos.getReleaseAsset({ + owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" }, + }); + const signature = Buffer.from(data).toString("utf8").trim(); + const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`; + for (const key of kind[1]) platforms[key] = { signature, url }; + } + const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]); + if (missing.length) { + core.setFailed(`latest.json would miss ${missing.join(", ")}`); + return; + } + const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms }; + const old = assets.find((a) => a.name === "latest.json"); + if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id }); + await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id, name: "latest.json", + data: JSON.stringify(manifest, null, 2), + headers: { "content-type": "application/json" }, + }); + core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b44d7d99..172dbe3ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release on: push: - tags: ["v*"] + tags: ["v*", "!v*-nightly.*"] workflow_dispatch: inputs: tag: @@ -9,7 +9,7 @@ on: required: true permissions: - contents: write + contents: read concurrency: group: release-${{ github.event.inputs.tag || github.ref_name }} @@ -23,10 +23,12 @@ env: jobs: # Creates (or reuses) one draft release that every platform build uploads into. create-release: + if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }} + permissions: + contents: write runs-on: ubuntu-latest outputs: release_id: ${{ steps.release.outputs.id }} - official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }} steps: - uses: actions/checkout@v5 with: { ref: "${{ env.TAG }}" } @@ -47,20 +49,6 @@ jobs: echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/" exit 1 fi - # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed - # `openidea-plugin keygen` writes), built into the app: their packages - # must carry its signature whatever the marketplace index lists. Without - # the secret the app takes the index's word, as development builds do. - - id: plugin-keys - env: - SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} - run: | - if [ -n "$SIGNING_KEY" ]; then - seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')" - key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p | - openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)" - echo "keys=$key" >> "$GITHUB_OUTPUT" - fi - id: release uses: actions/github-script@v8 with: @@ -74,161 +62,26 @@ jobs: core.setOutput("id", existing.id); return; } + // Nightly builds are prereleases: the notes span stable release to stable release. + const previous = releases.find((r) => !r.draft && !r.prerelease); + const { data: notes } = await github.rest.repos.generateReleaseNotes({ + owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name, + }); const { data } = await github.rest.repos.createRelease({ owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`, - draft: true, prerelease: tag.includes("-"), generate_release_notes: true, + draft: true, prerelease: tag.includes("-"), body: notes.body, }); core.setOutput("id", data.id); - build: + binaries: + permissions: + contents: write needs: create-release - name: Build (${{ matrix.name }}) - env: - OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }} - strategy: - fail-fast: false - matrix: - include: - - name: macOS arm64 - os: macos-latest - target: aarch64-apple-darwin - - name: macOS x64 - os: macos-latest - target: x86_64-apple-darwin - - name: Linux x64 - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - - name: Linux arm64 - os: ubuntu-22.04-arm - target: aarch64-unknown-linux-gnu - - name: Windows arm64 - os: windows-11-arm - target: aarch64-pc-windows-msvc - - name: Windows x64 - os: windows-latest - target: x86_64-pc-windows-msvc - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v5 - with: { ref: "${{ env.TAG }}" } - - uses: pnpm/action-setup@v5 - with: { version: 11 } - - uses: actions/setup-node@v5 - with: { node-version: 22, cache: pnpm } - - uses: dtolnay/rust-toolchain@stable - with: { targets: "${{ matrix.target }}" } - - uses: Swatinem/rust-cache@v2 - with: { key: "${{ matrix.target }}" } - - name: Linux deps - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf - - run: pnpm install --frozen-lockfile - # Signing is optional: only secrets that are set get exported, so forks and - # unconfigured repos produce unsigned builds instead of failing. - - name: Signing env - shell: bash - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \ - APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do - [ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV" - done - true - - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - releaseId: ${{ needs.create-release.outputs.release_id }} - tagName: ${{ env.TAG }} - args: --target ${{ matrix.target }} - # The parallel builds would race on one latest.json; `updater-manifest` writes it once. - uploadUpdaterJson: false - # tauri-action notarizes the .app but not the DMG around it; notarize and - # staple the DMG too, then replace the uploaded asset. - - name: Notarize DMG - if: runner.os == 'macOS' && env.APPLE_ID != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do - xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" --wait - xcrun stapler staple "$dmg" - gh release upload "$TAG" "$dmg" --clobber - done - # Portable Windows builds (#1857): on Windows Tauri embeds the - # frontend in the exe and WebView2 is a system component, so zipping - # the raw exe gives an unpack-and-run alternative to the installers. - - name: Portable zip - if: runner.os == 'Windows' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $target = "${{ matrix.target }}" - $arch = if ($target -like "x86_64*") { "x64" } else { "arm64" } - $version = $env:TAG -replace '^v', '' - $asset = "OpenIDEA_${version}_windows_${arch}_portable.zip" - $dir = "target/$target/release" - $files = @("$dir/openidea.exe") - if (Test-Path "$dir/WebView2Loader.dll") { - $files += "$dir/WebView2Loader.dll" - } - Compress-Archive -Path $files -DestinationPath $asset -Force - $hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower() - "$hash $asset" | Out-File -Encoding ascii "$asset.sha256" - gh release upload $env:TAG $asset "$asset.sha256" --clobber - - # The remote development server, per host platform it can run on. The desktop - # app downloads the one for the host (ide-tools `server`) and installs it over SSH. - # Linux builds are static (musl), so they run whatever the host's glibc. - server: - needs: create-release - name: Server (${{ matrix.os }}-${{ matrix.arch }}) - strategy: - fail-fast: false - matrix: - include: - - { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 } - - { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 } - - { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 } - - { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 } - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v5 - with: { ref: "${{ env.TAG }}" } - - uses: dtolnay/rust-toolchain@stable - with: { targets: "${{ matrix.target }}" } - - uses: Swatinem/rust-cache@v2 - with: { key: "server-${{ matrix.target }}" } - - name: musl toolchain - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y musl-tools - - name: Build - run: | - # musl-tools only ships `musl-gcc`; cc-rs would look for `-linux-musl-gcc`. - if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi - cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET" - env: - TARGET: ${{ matrix.target }} - - name: Upload - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz - run: | - gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET" - if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256" - gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + uses: ./.github/workflows/release-build.yml + with: + tag: ${{ github.event.inputs.tag || github.ref_name }} + release_id: ${{ needs.create-release.outputs.release_id }} + secrets: inherit # The official plugins, signed, and the marketplace index released apps read # (releases/latest/download/plugins-index.json). They are not in the @@ -240,6 +93,8 @@ jobs: # openidea-plugin tool for Linux x64, which that workflow and the registry's # checks run. plugins: + permissions: + contents: write needs: create-release name: Official plugins runs-on: ubuntu-22.04 @@ -303,70 +158,10 @@ jobs: gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \ "$out/openidea-plugin-linux-x86_64.gz" --clobber - # The updater manifest (latest.json), built once from the signed bundles every build uploaded. - # Fails when a platform is missing, so a release never ships a manifest without it. - updater-manifest: - needs: [create-release, build] - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v8 - env: - RELEASE_ID: ${{ needs.create-release.outputs.release_id }} - with: - script: | - const { owner, repo } = context.repo; - const release_id = Number(process.env.RELEASE_ID); - const tag = process.env.TAG; - const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id }); - const sigs = assets.filter((a) => a.name.endsWith(".sig")); - if (sigs.length === 0) { - core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json"); - return; - } - // Bundle suffix → updater platform keys, as tauri-action names them. - const kinds = [ - [/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]], - [/_arm64\.deb$/, ["linux-aarch64-deb"]], - [/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]], - [/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]], - [/_amd64\.deb$/, ["linux-x86_64-deb"]], - [/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]], - [/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]], - [/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]], - [/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]], - [/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]], - [/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]], - [/_x64-setup\.exe$/, ["windows-x86_64-nsis"]], - ]; - const platforms = {}; - for (const sig of sigs) { - const bundle = sig.name.slice(0, -".sig".length); - const kind = kinds.find(([re]) => re.test(bundle)); - if (!kind) continue; - const { data } = await github.rest.repos.getReleaseAsset({ - owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" }, - }); - const signature = Buffer.from(data).toString("utf8").trim(); - const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`; - for (const key of kind[1]) platforms[key] = { signature, url }; - } - const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]); - if (missing.length) { - core.setFailed(`latest.json would miss ${missing.join(", ")}`); - return; - } - const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms }; - const old = assets.find((a) => a.name === "latest.json"); - if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id }); - await github.rest.repos.uploadReleaseAsset({ - owner, repo, release_id, name: "latest.json", - data: JSON.stringify(manifest, null, 2), - headers: { "content-type": "application/json" }, - }); - core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`); - publish: - needs: [create-release, build, server, plugins, updater-manifest] + permissions: + contents: write + needs: [create-release, binaries, plugins] runs-on: ubuntu-latest steps: - uses: actions/github-script@v8 diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs index d62f2af67..1c5be71cb 100644 --- a/crates/ide-tools/src/server.rs +++ b/crates/ide-tools/src/server.rs @@ -1,5 +1,5 @@ //! `openidea-server` builds for remote development: the release of this -//! version publishes one per supported host platform (see `release.yml`). +//! version publishes one per supported host platform (see `release-build.yml`). use crate::process::Http; use crate::{InstallEvent, fetch_verified, sources}; diff --git a/package.json b/package.json index 1244e4e1d..f1c6d2033 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "e2e": "playwright test", "perf": "node perf/run.ts", "perf:rust": "node perf/rust.ts", - "release:prepare": "node scripts/release-prepare.ts" + "release:prepare": "node scripts/release-prepare.ts", + "nightly:prepare": "node scripts/nightly-prepare.ts" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/scripts/nightly-prepare.test.ts b/scripts/nightly-prepare.test.ts new file mode 100644 index 000000000..eba47de8a --- /dev/null +++ b/scripts/nightly-prepare.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { NIGHTLY_ENDPOINT, nightlyVersion, setNightlyIdentity } from "./nightly-prepare.ts"; + +describe("nightlyVersion", () => { + it("is one patch above the current version, numbered by its stamp", () => { + expect(nightlyVersion("0.1.39", "202611081417")).toBe("0.1.40-nightly.202611081417"); + }); + + it.each([undefined, "", "2026", "012611081417", "20261108141a"])("rejects the stamp %s", (stamp) => { + expect(() => nightlyVersion("0.1.39", stamp)).toThrow("stamp"); + }); + + it("rejects a current version that is not a release version", () => { + expect(() => nightlyVersion("0.1.40-nightly.202611081417", "202611081417")).toThrow("expected a version"); + }); +}); + +describe("setNightlyIdentity", () => { + const conf = JSON.parse(setNightlyIdentity(readFileSync("src-tauri/tauri.conf.json", "utf8"))); + + it("is an app of its own that updates from the nightly manifest", () => { + expect(conf.productName).toBe("OpenIDEA Nightly"); + expect(conf.identifier).toBe("ai.l0ng.openidea.nightly"); + expect(conf.plugins.updater.endpoints).toEqual([NIGHTLY_ENDPOINT]); + }); + + it("leaves out the bundles that reject prerelease versions", () => { + expect(conf.bundle.targets).not.toContain("msi"); + expect(conf.bundle.targets).not.toContain("rpm"); + expect(conf.bundle.createUpdaterArtifacts).toBe(true); + }); +}); diff --git a/scripts/nightly-prepare.ts b/scripts/nightly-prepare.ts new file mode 100644 index 000000000..6697e43f3 --- /dev/null +++ b/scripts/nightly-prepare.ts @@ -0,0 +1,54 @@ +/** + * Prepares a nightly build's commit: `node scripts/nightly-prepare.ts 202611081417` sets the + * version one patch above the current one, with that stamp as its prerelease number, in the + * files `release:prepare` covers, and gives tauri.conf.json the nightly app's identity, so it + * installs next to the stable app and updates from its own manifest. Prints the version. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { cargoVersion, edit, parseVersion, setCargoVersion, setJsonVersion } from "./release-prepare.ts"; + +export const NIGHTLY_ENDPOINT = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json"; + +/** `0.1.40-nightly.202611081417` from `0.1.39`: always above the last stable version and the last nightly. */ +export function nightlyVersion(current: string, stamp: string | undefined): string { + if (!stamp || !/^[1-9]\d{11}$/.test(stamp)) throw new Error(`expected a YYYYMMDDHHMM stamp, got ${stamp}`); + const [major, minor, patch] = parseVersion(current).split(".").map(Number); + return `${major}.${minor}.${patch! + 1}-nightly.${stamp}`; +} + +/** MSI and RPM reject prerelease versions, so a nightly build leaves them out of its bundles. */ +export function setNightlyIdentity(json: string): string { + const conf = JSON.parse(json); + conf.productName = "OpenIDEA Nightly"; + conf.identifier = "ai.l0ng.openidea.nightly"; + conf.bundle.targets = ["app", "dmg", "appimage", "deb", "nsis"]; + conf.plugins.updater.endpoints = [NIGHTLY_ENDPOINT]; + return `${JSON.stringify(conf, null, 2)}\n`; +} + +function main() { + const root = fileURLToPath(new URL("..", import.meta.url)); + const version = nightlyVersion(cargoVersion(readFileSync(join(root, "Cargo.toml"), "utf8")), process.argv[2]); + + edit(join(root, "Cargo.toml"), (text) => setCargoVersion(text, version)); + edit(join(root, "package.json"), (text) => setJsonVersion(text, version)); + edit(join(root, "src-tauri/tauri.conf.json"), (text) => setNightlyIdentity(setJsonVersion(text, version))); + const cargo = spawnSync("cargo", ["update", "--workspace"], { + cwd: root, + stdio: ["ignore", 2, 2], + }); + if (cargo.status !== 0) throw new Error("cargo update --workspace failed"); + console.log(version); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + console.error(`nightly-prepare: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/scripts/release-prepare.ts b/scripts/release-prepare.ts index ae6cd3ac4..6b52ad065 100644 --- a/scripts/release-prepare.ts +++ b/scripts/release-prepare.ts @@ -75,7 +75,7 @@ export function syncApiSnapshot(root: string): { from: string; to: string; copie return { from, to, copied: true }; } -function edit(path: string, change: (text: string) => string) { +export function edit(path: string, change: (text: string) => string) { const before = readFileSync(path, "utf8"); const after = change(before); if (after === before) throw new Error(`${path}: nothing changed`); diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 9645f4787..8561b3353 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -413,9 +413,10 @@ fn config_endpoints(app: &AppHandle) -> Vec { /// The endpoints tried in order: `source` first, the other source when it fails or has /// no manifest. Bundles are signed, so either source's manifest and downloads verify. -/// No official endpoint (a build without updates) means no mirror either. -fn source_endpoints(official: Vec, source: UpdateSource) -> Vec { - if official.is_empty() { +/// No official endpoint (a build without updates) means no mirror either. Nightly builds +/// aren't mirrored: the mirror's manifest would move them to the stable version. +fn source_endpoints(official: Vec, source: UpdateSource, nightly: bool) -> Vec { + if official.is_empty() || nightly { return official; } let mirror = vec![CNB_ENDPOINT.to_string()]; @@ -425,6 +426,10 @@ fn source_endpoints(official: Vec, source: UpdateSource) -> Vec } } +fn is_nightly(version: &str) -> bool { + version.contains("-nightly.") +} + fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult> { use tauri_plugin_updater::UpdaterExt; if !updates_configured(app) { @@ -432,7 +437,7 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult vec![url], - None => source_endpoints(config_endpoints(app), source), + None => source_endpoints(config_endpoints(app), source, is_nightly(&app.package_info().version.to_string())), }; let endpoints = endpoints .iter() @@ -646,10 +651,22 @@ mod tests { #[test] fn chosen_source_is_tried_first_and_the_other_after_it() { let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string(); - assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]); - assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]); + assert_eq!( + source_endpoints(vec![github.clone()], UpdateSource::Github, false), + [github.clone(), CNB_ENDPOINT.into()] + ); + assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb, false), [CNB_ENDPOINT.into(), github]); // A build without an update endpoint stays without one. - assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty()); + assert!(source_endpoints(Vec::new(), UpdateSource::Cnb, false).is_empty()); + } + + #[test] + fn nightly_builds_update_from_their_own_manifest_whatever_the_source() { + let nightly = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json".to_string(); + assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Cnb, true), vec![nightly.clone()]); + assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Github, true), [nightly]); + assert!(is_nightly("0.1.40-nightly.202611081417")); + assert!(!is_nightly("0.1.40")); } #[test] diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts index 562f27b8b..fd24690f6 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -403,6 +403,68 @@ describe("Check for Updates", () => { }); }); +describe("automatic nightly update prompts", () => { + const DAY = 24 * 60 * 60 * 1000; + const update = (currentVersion: string): UpdateInfo => ({ + version: "0.1.41", + currentVersion, + notes: null, + date: null, + }); + const startupCheck = async (currentVersion: string) => { + notes = []; + await notifyUpdate(update(currentVersion)); + return notes.length; + }; + + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers({ toFake: ["Date"], now: 10 * DAY }); + }); + afterEach(() => vi.useRealTimers()); + + it("leaves stable prompts to the backend announcement policy", async () => { + expect(await startupCheck("0.1.40")).toBe(1); + expect(await startupCheck("0.1.40")).toBe(1); + }); + + it("asks a nightly build at most once a day", async () => { + const nightly = "0.1.41-nightly.202611081417"; + expect(await startupCheck(nightly)).toBe(1); + vi.setSystemTime(11 * DAY - 1); + expect(await startupCheck(nightly)).toBe(0); + vi.setSystemTime(11 * DAY); + expect(await startupCheck(nightly)).toBe(1); + }); + + it("does not prompt twice a day for periodic nightly checks, but always answers a manual check", async () => { + const nightly = "0.1.41-nightly.202611081417"; + const dispose = installUpdateChecks(); + await tick(); + try { + const available = updateListeners.get("updateAvailable")!; + available({ update: update(nightly) }); + await tick(); + expect(notes).toHaveLength(1); + vi.setSystemTime(10 * DAY + 4 * 60 * 60 * 1000); + available({ update: { ...update(nightly), version: "0.1.42-nightly.202611090200" } }); + await tick(); + expect(notes).toHaveLength(1); + const done = checkForUpdates(); + check.resolve(ok({ enabled: true, update: update(nightly) })); + await done; + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.41 is available", userInitiated: true }); + notes = []; + vi.setSystemTime(11 * DAY); + available({ update: { ...update(nightly), version: "0.1.42-nightly.202611091400" } }); + await tick(); + expect(notes).toHaveLength(1); + } finally { + dispose(); + } + }); +}); + describe("updateSource", () => { const withSettings = (updateSource: string | undefined, locale: string | undefined) => useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings }); diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index adcc1142a..ea0af9ec1 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -127,6 +127,17 @@ async function runCheck() { } } +const NIGHTLY_PROMPT_KEY = "openidea.update.nightlyPromptedAt"; +const NIGHTLY_PROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** Nightly builds come out more often than anyone wants to be asked: at most one prompt a day. */ +function nightlyPromptDue(now = Date.now()): boolean { + const last = Number(localStorage.getItem(NIGHTLY_PROMPT_KEY)); + if (now - last < NIGHTLY_PROMPT_INTERVAL_MS) return false; + localStorage.setItem(NIGHTLY_PROMPT_KEY, String(now)); + return true; +} + /** Start one backend schedule after this window can receive its notifications. */ export function installUpdateChecks(): () => void { let disposed = false; @@ -154,6 +165,7 @@ export async function notifyUpdate(update: UpdateInfo, userInitiated = false): P ); if (!claimed) return; } + if (!userInitiated && update.currentVersion.includes("-nightly.") && !nightlyPromptDue()) return; notifications().notify({ level: "info", title: t("app.update.available", { version: update.version }),