diff --git a/.agents/notes/implemented/process/2026-09-11-changelog-only-releases.md b/.agents/notes/implemented/process/2026-09-11-changelog-only-releases.md new file mode 100644 index 0000000000..ed02068a47 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-11-changelog-only-releases.md @@ -0,0 +1,32 @@ +# Changelog-only tag releases + +Status: implemented +Translation: pending +PR: https://github.com/LodyAI/Lody/pull/609 + +## Abstract + +Tag pushes previously built three desktop platforms and published installers and +update feeds, with macOS signing credentials gating all publication. The replacement +opens a draft app-version synchronization PR and creates a changelog-only source +release. Tags remain immutable, so manifest synchronization lands separately on the +default branch. Existing binary releases remain intact, but new releases provide +no installer or auto-update assets. + +## Decision and limits + +The [workflow](../../../../.github/workflows/release.yml) accepts stable version tags, +enumerates direct app manifests, and uses one version-specific PR branch. It requires +only repository contents and pull-request write permissions; no dependencies, +submodules, signing credentials, packaging runners, or release environment are needed. +It preserves existing release notes and assets on reruns. It intentionally does not +move tags or directly commit version changes to the protected default branch. + +The lockfile stores app dependencies rather than app manifest versions, so this +version-only operation does not change pnpm-lock.yaml. Superseded version PRs require +maintainer closure, and the repository must allow Actions to create pull requests. +Validation covers the version-edit operation and workflow structure locally; live +tag publication is not exercised. Full repository checks are limited by this nested +checkout's missing dependencies and uninitialized submodules. + +Intent: [tag releases](../../../../specs/tag-releases.md). diff --git a/.github/workflows/release-electron.yml b/.github/workflows/release-electron.yml deleted file mode 100644 index 196b001329..0000000000 --- a/.github/workflows/release-electron.yml +++ /dev/null @@ -1,286 +0,0 @@ -name: Release Electron - -# Builds and publishes the public Lody OSS desktop app for macOS, Windows and -# Linux. Windows/Linux in-app updates use electron-updater metadata (latest*.yml). -# macOS updates use Sparkle appcast.xml produced after packaging. -# -# The tag is the source of truth for the released version: `package-electron.mjs` -# injects it through extraMetadata so apps/electron/package.json never silently -# ships a stale version. electron-updater resolves its feed from the `publish` -# block in electron-builder.yml, which electron-builder bakes into the packaged -# app-update.yml — the public renderer build strips every VITE_* value, so the -# cloud composition's VITE_ELECTRON_UPDATE_URL injection is not available here. -# Sparkle reads SUFeedURL / SUPublicEDKey from Info.plist instead. - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Version to package, e.g. 0.80.1 (defaults to apps/electron/package.json)' - required: false - type: string - publish: - description: 'Create/update the GitHub Release with the built artifacts' - required: false - default: false - type: boolean - -permissions: - contents: read - -concurrency: - group: release-electron-${{ github.ref }} - cancel-in-progress: false - -env: - NODE_VERSION: 22 - COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - target: mac - # Both slices from one runner: beforePack swaps the embedded CLI's - # native bindings per arch, so a universal build is rejected upstream. - package_args: '--mac --arm64 --x64' - - os: windows-2022 - target: win - package_args: '--win' - - os: ubuntu-latest - target: linux - # snap is deliberately omitted: electron-builder needs snapcraft on the - # runner to produce it. `pnpm --dir apps/electron build:linux` still - # builds it locally for anyone who has snapcraft installed. - package_args: '--linux AppImage deb' - runs-on: ${{ matrix.os }} - environment: release - env: - # Signing identities are injected only into the trusted preparation and - # package steps; never discover whatever happens to be in the runner keychain. - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - npm_config_msvs_version: ${{ matrix.target == 'win' && '2022' || '' }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # All four acp-extension submodules are public, so no deploy key is needed. - submodules: recursive - persist-credentials: false - - - name: Resolve release version - shell: bash - run: | - set -euo pipefail - if [[ "${{ github.event_name }}" == "push" ]]; then - version="${GITHUB_REF_NAME#v}" - else - version="${{ inputs.version }}" - version="${version#v}" - fi - - if [[ -z "${version}" ]]; then - version="$(node -p "require('./apps/electron/package.json').version")" - fi - - if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Refusing to package unparseable version: ${version}" - exit 1 - fi - - echo "LODY_OSS_RELEASE_VERSION=${version}" >> "$GITHUB_ENV" - echo "Packaging Lody OSS ${version}" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build bundled CLI and renderer - run: pnpm --dir apps/electron run build - - - name: Prepare macOS signing and notarization credentials - if: matrix.target == 'mac' - shell: bash - env: - CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} - CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - SPARKLE_ED_PUBLIC_KEY: ${{ secrets.SPARKLE_ED_PUBLIC_KEY }} - SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} - run: | - set -euo pipefail - required_envs=( - "CSC_LINK" - "CSC_KEY_PASSWORD" - "APPLE_API_KEY_P8" - "APPLE_API_KEY_ID" - "APPLE_API_ISSUER" - "SPARKLE_ED_PUBLIC_KEY" - "SPARKLE_ED_PRIVATE_KEY" - ) - for key in "${required_envs[@]}"; do - if [[ -z "${!key:-}" ]]; then - echo "Missing required macOS signing or Sparkle secret: ${key}" - echo "macOS Sparkle auto-update needs a signed build and SUPublicEDKey, so this is fatal." - exit 1 - fi - done - - api_key_path="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "${APPLE_API_KEY_P8}" > "${api_key_path}" - chmod 600 "${api_key_path}" - echo "APPLE_API_KEY=${api_key_path}" >> "$GITHUB_ENV" - - - name: Package (${{ matrix.target }}) - env: - CSC_LINK: ${{ matrix.target == 'mac' && secrets.MACOS_CERTIFICATE_P12_BASE64 || '' }} - CSC_KEY_PASSWORD: ${{ matrix.target == 'mac' && secrets.MACOS_CERTIFICATE_PASSWORD || '' }} - CSC_NAME: ${{ matrix.target == 'mac' && secrets.MACOS_CERTIFICATE_NAME || '' }} - APPLE_API_KEY_ID: ${{ matrix.target == 'mac' && secrets.APPLE_API_KEY_ID || '' }} - APPLE_API_ISSUER: ${{ matrix.target == 'mac' && secrets.APPLE_API_ISSUER || '' }} - SPARKLE_ED_PUBLIC_KEY: ${{ matrix.target == 'mac' && secrets.SPARKLE_ED_PUBLIC_KEY || '' }} - run: pnpm --dir apps/electron run package -- ${{ matrix.package_args }} --publish never - - - name: Remove macOS notarization key - if: ${{ always() && matrix.target == 'mac' }} - shell: bash - run: | - if [[ -n "${APPLE_API_KEY:-}" ]]; then - rm -f "${APPLE_API_KEY}" - fi - - - name: Prepare Sparkle archive dir - if: matrix.target == 'mac' - id: sparkle-archive - shell: bash - run: | - set -euo pipefail - archive_dir="${RUNNER_TEMP}/sparkle-appcast" - mkdir -p "${archive_dir}" - find apps/electron/dist -maxdepth 1 -type f -name '*.zip' -exec cp {} "${archive_dir}/" \; - if [[ -z "$(ls -A "${archive_dir}")" ]]; then - echo "No macOS zip archives were produced for Sparkle" - exit 1 - fi - echo "dir=${archive_dir}" >> "$GITHUB_OUTPUT" - - - name: Expose Sparkle CLI for the release Action - if: matrix.target == 'mac' - shell: bash - run: echo "${GITHUB_WORKSPACE}/apps/electron/node_modules/.bin" >> "$GITHUB_PATH" - - # Same Action kansoku uses in desktop-release.yml. publish stays false: - # Lody's ubuntu job publishes mac/win/linux together. - - name: Sign appcast and generate Sparkle deltas - if: matrix.target == 'mac' - uses: Innei/electron-sparkle-updater/action@002472bdb78a9608eddc8b39202572af3f7986fb # v1 - with: - tag: v${{ env.LODY_OSS_RELEASE_VERSION }} - repo: ${{ github.repository }} - tag-prefix: v - archive-dir: ${{ steps.sparkle-archive.outputs.dir }} - ed-private-key: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} - delta-bases: '2' - fetch-delta-bases: 'true' - publish: 'false' - github-token: ${{ github.token }} - - - name: Stage Sparkle feed assets - if: matrix.target == 'mac' - shell: bash - run: | - set -euo pipefail - archive_dir="${{ steps.sparkle-archive.outputs.dir }}" - cp "${archive_dir}/appcast.xml" apps/electron/dist/appcast.xml - find "${archive_dir}" -maxdepth 1 -type f -name '*.delta' -exec cp {} apps/electron/dist/ \; - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-assets - find apps/electron/dist -maxdepth 1 -type f \ - \( \ - -name 'latest*.yml' -o \ - -name '*.blockmap' -o \ - -name '*.exe' -o \ - -name '*.dmg' -o \ - -name '*.zip' -o \ - -name '*.delta' -o \ - -name 'appcast.xml' -o \ - -name '*.AppImage' -o \ - -name '*.deb' \ - \) \ - -exec cp {} release-assets/ \; - - if [[ -z "$(ls -A release-assets)" ]]; then - echo "No release assets were produced" - exit 1 - fi - ls -la release-assets - - - name: Upload release assets - uses: actions/upload-artifact@v4 - with: - name: lody-oss-release-${{ matrix.target }} - path: release-assets/* - if-no-files-found: error - - publish: - needs: build - if: github.event_name == 'push' || inputs.publish - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - steps: - - name: Download release assets - uses: actions/download-artifact@v4 - with: - pattern: lody-oss-release-* - path: release-assets - merge-multiple: true - - - name: Publish GitHub Release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - set -euo pipefail - - if [[ "${{ github.event_name }}" == "push" ]]; then - tag="${GITHUB_REF_NAME}" - else - version="${{ inputs.version }}" - tag="v${version#v}" - fi - - # electron-updater's GitHub provider resolves assets from the tag that - # matches `v${version}`, so the tag name is part of the update contract. - echo "Publishing ${tag}" - ls -la release-assets - - if gh release view "${tag}" >/dev/null 2>&1; then - gh release upload "${tag}" release-assets/* --clobber - else - gh release create "${tag}" release-assets/* \ - --title "Lody OSS ${tag#v}" \ - --generate-notes - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..9effa3a58e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + RELEASE_TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Validate version + shell: bash + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Expected a stable version tag such as v0.90.0" + exit 1 + fi + echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + + - name: Checkout default branch for version synchronization + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Synchronize app versions + run: | + node --input-type=module <<'NODE' + import { readdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs' + for (const entry of readdirSync('apps', { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const file = `apps/${entry.name}/package.json` + if (!existsSync(file)) continue + const manifest = JSON.parse(readFileSync(file, 'utf8')) + manifest.version = process.env.RELEASE_VERSION + writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`) + } + NODE + + - name: Create version synchronization PR + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 + with: + branch: chore/app-versions-${{ env.RELEASE_TAG }} + base: ${{ github.event.repository.default_branch }} + add-paths: apps/*/package.json + commit-message: 'chore: sync app versions to ${{ env.RELEASE_TAG }}' + title: 'chore: sync app versions to ${{ env.RELEASE_TAG }}' + body: | + Synchronize all apps/*/package.json versions to `${{ env.RELEASE_VERSION }}`. + The release tag remains unchanged; this PR updates the default branch only. + draft: true + + - name: Create changelog-only GitHub Release + shell: bash + run: | + set -euo pipefail + releases="$(gh api "repos/$GH_REPO/releases" --paginate --jq '.[].tag_name')" + if printf '%s\n' "$releases" | grep -Fxq "$RELEASE_TAG"; then + echo "Release already exists; preserving its notes and assets." + else + gh release create "$RELEASE_TAG" --verify-tag \ + --title "Lody OSS $RELEASE_VERSION" --generate-notes + fi diff --git a/README.md b/README.md index 113350c055..f4ef6ed3c7 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,16 @@ We want the entire workspace—not only conversations—to become local-first. L Lody is still moving toward full local-first support. +## Releases + +Pushing a stable `vX.Y.Z` tag creates a draft PR synchronizing every +`apps/*/package.json` version on the default branch, followed by a GitHub Release +with generated changelog notes. No installers or auto-update files are built or +uploaded. The original tag is not moved; version synchronization takes effect +on the default branch after the PR is merged. GitHub Actions must be allowed to +create pull requests in repository settings. Bot-created PRs may require a maintainer +action to trigger CI. Existing releases and their assets are preserved on reruns. + ## Repository - `apps/cli` — Connect machines and run coding agents diff --git a/apps/electron/AGENTS.md b/apps/electron/AGENTS.md index c45198350d..61224a3dbc 100644 --- a/apps/electron/AGENTS.md +++ b/apps/electron/AGENTS.md @@ -83,13 +83,8 @@ contracts, and window/renderer integration rules live in `latest*.yml`. Tag contract is `v${version}`. - macOS uses Sparkle (`electron-sparkle-updater`): `SUFeedURL` + `SUPublicEDKey` in Info.plist, `package-electron.mjs` rebuilds the native addon, afterPack injects - `SPARKLE_ED_PUBLIC_KEY` before signing. The release workflow then runs - `Innei/electron-sparkle-updater/action` pinned to a reviewed full commit SHA against - this release's zips only - (`publish: false`); the Action fetches the two previous `v*` zip releases as - delta bases. Keep Apple signing credentials scoped to the packaging step and the - Sparkle private key scoped to validation plus the pinned signing Action; never expose - them as job-level environment variables. Previous zips stay out of the published asset list. Sparkle load + `SPARKLE_ED_PUBLIC_KEY` before signing. Tag releases contain changelogs only; + they do not build installers or generate Sparkle feeds/deltas. Sparkle load failure falls back to electron-updater. Sparkle UI stays silent; progress and ready-to-install go through `ElectronUpdaterState` for the renderer banner. - Linux `.deb` installs go through `app-updater-linux-install.ts`, never @@ -108,8 +103,7 @@ contracts, and window/renderer integration rules live in - macOS releases must be signed and notarized. `generate_appcast` refuses archives that fail `codesign --verify --deep --strict`, and Gatekeeper needs a notarized first-install DMG. Windows and Linux do not have this constraint. -- CI packages Linux as `AppImage deb` only; `snap` stays in the target list for local - builds because it needs snapcraft on the machine. +- `snap` stays in the target list for local builds and needs snapcraft on the machine. ## Verification diff --git a/specs/tag-releases.md b/specs/tag-releases.md new file mode 100644 index 0000000000..efd85087f1 --- /dev/null +++ b/specs/tag-releases.md @@ -0,0 +1,29 @@ +# Tag releases + +Status: draft +Translation: pending + +## Scenario and behavior + +A maintainer pushes a stable `vX.Y.Z` tag to record a source release. Automation +opens a draft PR against the default branch setting every direct app package's +version to `X.Y.Z`, then creates a GitHub Release with generated changelog notes. +If versions already match, no version PR is needed. The tag remains on its original +commit; the PR does not change the tagged source snapshot. + +Release automation does not build or publish installers, updater metadata, npm +packages, or cloud deployments. GitHub's source archives remain available. +Existing releases and assets are preserved when a run is repeated. + +## Operational limits + +Repository settings must permit Actions to create PRs. A failure to synchronize +versions or open the PR prevents the subsequent release step. Default-token PRs +may need maintainer intervention to start CI. Independent tag pushes queued at +the same time follow GitHub concurrency semantics; maintainers should publish +tags sequentially. Merging an older version PR later can regress manifest versions; +maintainers must close superseded PRs. + +## Evidence + +- [Release workflow](../.github/workflows/release.yml)