diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53f6a0c..56ddeb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: workflow_run: workflows: - Tests + branches: + - main types: - completed @@ -15,12 +17,43 @@ concurrency: cancel-in-progress: false jobs: - package: - name: Package ${{ matrix.name }} + version: + name: Determine release version if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - name: Check out tested source + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Compute prerelease version + id: version + env: + TEST_RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + run: | + node --input-type=module <<'NODE' + import { readFileSync, appendFileSync } from 'node:fs'; + const base = JSON.parse(readFileSync('package.json', 'utf8')).version.replace(/[+-].*$/, ''); + if (!/^\d+\.\d+\.\d+$/.test(base) || !/^\d+$/.test(process.env.TEST_RUN_NUMBER)) { + throw new Error('Invalid release version or test run number'); + } + const version = `${base}-main.${process.env.TEST_RUN_NUMBER}`; + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ntag=v${version}\n`); + NODE + + package: + name: Package ${{ matrix.name }} + needs: version strategy: fail-fast: false matrix: @@ -41,13 +74,16 @@ jobs: artifact: linux files: release/*.AppImage runs-on: ${{ matrix.os }} + timeout-minutes: 30 env: CSC_IDENTITY_AUTO_DISCOVERY: "false" + RELEASE_VERSION: ${{ needs.version.outputs.version }} steps: - name: Check out tested source uses: actions/checkout@v4 with: ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v4 @@ -58,43 +94,80 @@ jobs: - name: Install dependencies run: npm ci + - name: Set installer version + shell: bash + run: npm version "$RELEASE_VERSION" --no-git-tag-version --ignore-scripts + - name: Build package - run: ${{ matrix.command }} + run: ${{ matrix.command }} --publish never - name: Upload package uses: actions/upload-artifact@v4 with: - name: ${{ matrix.artifact }} + name: installer-${{ matrix.artifact }} path: ${{ matrix.files }} if-no-files-found: error retention-days: 7 + overwrite: true publish: name: Publish GitHub release - needs: package + needs: [version, package] runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.version.outputs.tag }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} steps: - name: Download packages uses: actions/download-artifact@v4 with: path: artifacts + pattern: installer-* + merge-multiple: true - - name: Publish prerelease - uses: softprops/action-gh-release@v2 - with: - tag_name: main-${{ github.event.workflow_run.run_number }} - target_commitish: ${{ github.event.workflow_run.head_sha }} - name: Local Forge main build ${{ github.event.workflow_run.run_number }} - prerelease: true - make_latest: false - generate_release_notes: true - body: | - Automated unsigned build from `${{ github.event.workflow_run.head_sha }}`. + - name: Validate installers and generate checksums + working-directory: artifacts + shell: bash + run: | + shopt -s nullglob + for extension in exe dmg AppImage; do + files=(*."$extension") + if [[ ${#files[@]} -ne 1 || ! -s "${files[0]}" ]]; then + echo "::error::Expected one non-empty $extension installer" + exit 1 + fi + done + sha256sum -- *.exe *.dmg *.AppImage > SHA256SUMS.txt + sha256sum --check SHA256SUMS.txt + + - name: Publish complete prerelease + working-directory: artifacts + shell: bash + run: | + state=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \ + --jq '.[] | select(.tag_name == env.RELEASE_TAG) | .draft') + if [[ "$state" == "false" ]]; then + echo "Release $RELEASE_TAG is already published; leaving its assets unchanged." + exit 0 + fi + if [[ -z "$state" ]]; then + gh release create "$RELEASE_TAG" \ + --target "$RELEASE_SHA" \ + --title "Local Forge $RELEASE_TAG" \ + --draft --prerelease --generate-notes \ + --notes "Automated unsigned build from $RELEASE_SHA. - Windows and macOS may show security warnings until release signing is configured. - files: | - artifacts/windows/*.exe - artifacts/macos/*.dmg - artifacts/linux/*.AppImage + Installers: Windows x64 (.exe), macOS universal (.dmg), Linux x64 (.AppImage). + Verify downloads using SHA256SUMS.txt. + Windows and macOS may show security warnings until signing and notarization are configured." + elif [[ "$state" != "true" ]]; then + echo "::error::Unexpected release state: $state" + exit 1 + fi + gh release upload "$RELEASE_TAG" --clobber -- *.exe *.dmg *.AppImage SHA256SUMS.txt + gh release edit "$RELEASE_TAG" --draft=false --prerelease --latest=false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f476f4f..1a7e6e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,13 +10,14 @@ permissions: contents: read concurrency: - group: tests-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: name: Test, lint, and build runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out source uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c00c618..8c2b83b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,17 @@ npm run build 3. Update public documentation when contracts or workflows change. 4. Explain user-visible behavior, tradeoffs, and validation in the pull request. -The Tests workflow runs on pull requests and pushes to `main`. A successful push to `main` triggers unsigned native prerelease builds. Do not commit `dist/`, `dist-electron/`, `release/`, local models, imported images, or workspace data. +The Tests workflow runs on pull requests and pushes to `main`. A successful push to `main` triggers unsigned native prerelease builds. New main commits do not cancel checks for earlier commits; obsolete pull-request checks are cancelled. Do not commit `dist/`, `dist-electron/`, `release/`, local models, imported images, or workspace data. + +## Release maintenance + +- Enable GitHub Actions and allow the actions used by the workflows. The publish job requests `contents: write` for the automatic `GITHUB_TOKEN`; repository or organization policy must permit that permission. No personal access token or publishing secret is required. +- Protect `main` with pull requests and the **Test, lint, and build** required check. Direct pushes also release, so restrict them if releases should only follow merges. +- The Release workflow accepts only successful Tests runs from pushes to this repository's `main`. Pull-request runs, including forks, never publish. Packaging checks out the tested SHA, not the current branch tip. +- The numeric base version comes from `package.json`; any existing prerelease/build suffix is replaced with `-main.`. To advance the base version, use `npm version --no-git-tag-version` locally and commit both `package.json` and `package-lock.json` in a pull request. CI sets the matching installer version only in its build workspace; it does not push version commits or trigger a release loop. +- Packaging jobs have read-only repository access and explicitly disable electron-builder publishing. Only the final publish job has write access. A release remains a draft until all three non-empty installers and their SHA-256 checksums have been uploaded. +- If packaging or publishing fails, rerun failed jobs from the **Release** workflow in Actions. A full rerun is also safe. Rerunning the same Tests run retains its run number and therefore its release tag. An incomplete draft is reused and its assets replaced; an already published release is left unchanged. +- Installers are retained as workflow artifacts for seven days; published release assets persist. If artifacts have expired, rerun all Release jobs to rebuild them. +- Signing and macOS notarization are not configured. Keep these builds as prereleases until signing credentials and a stable-release policy are established; the pipeline deliberately does not replace the latest stable release. By contributing, you agree that your contributions are licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index bc17ec7..de07ca6 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,23 @@ npm run lint npm run build ``` -`npm run dist` builds an installer for the current platform. Successful test runs on `main` also publish unsigned Windows, macOS, and Linux prereleases through GitHub Actions. +`npm run dist` builds an installer for the current platform. + +## Automated releases + +Every push to `main` (including a merged pull request) runs tests, lint, and the application build. After those checks pass, GitHub Actions packages that exact commit for Windows x64 (`.exe`), macOS universal (`.dmg`, Intel and Apple Silicon), and Linux x64 (`.AppImage`). + +Download installers from [GitHub Releases](https://github.com/Azayzel/local-forge/releases). Each main build is an unsigned prerelease, tagged `v-main.` (for example, `v0.1.0-main.42`). The installer version matches the tag without its `v` prefix. These alpha builds are not marked as the latest stable release. + +All three installers and `SHA256SUMS.txt` are uploaded before the release is published. To verify a download, compare its SHA-256 hash with the matching entry in that file: + +```powershell +Get-FileHash .\Local-Forge-0.1.0-main.42-win-x64.exe -Algorithm SHA256 +``` + +Use your downloaded installer's actual filename. On Linux, download all three installers and the checksum file into one directory and run `sha256sum --check SHA256SUMS.txt`; on macOS use `shasum -a 256 -c SHA256SUMS.txt`. + +Windows and macOS may show security warnings: these builds are not yet signed or notarized. See [release maintenance](CONTRIBUTING.md#release-maintenance) for setup, versioning, and retries. ## Project status diff --git a/package.json b/package.json index 8825f41..26a18bc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "build": { "appId": "io.localforge.desktop", "productName": "Local Forge", + "artifactName": "Local-Forge-${version}-${os}-${arch}.${ext}", "icon": "build/icon.png", "asar": true, "directories": {