name: Release # Triggered two ways: # 1. Semver tags: git tag v0.3.0 && git push --tags (must match package.json). # 2. workflow_run: after the CI workflow succeeds on master, the version in # root package.json is released automatically if no matching tag exists. on: push: tags: ["v*.*.*"] workflow_run: workflows: ["CI"] types: [completed] branches: [master] permissions: contents: write packages: write jobs: verify: name: Verify version runs-on: ubuntu-latest outputs: version: ${{ steps.check.outputs.version }} needed: ${{ steps.check.outputs.needed }} steps: - uses: actions/checkout@v4 - name: Resolve version + decide whether to release id: check run: | if [ "${{ github.event_name }}" = "workflow_run" ]; then if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then echo "CI did not succeed — skipping release." echo "needed=false" >> "$GITHUB_OUTPUT" exit 0 fi VERSION="v$(node -p "require('./package.json').version")" if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null 2>&1; then echo "$VERSION already released — nothing to do." echo "needed=false" >> "$GITHUB_OUTPUT" exit 0 fi else VERSION="${{ github.ref_name }}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "needed=true" >> "$GITHUB_OUTPUT" - name: Check tag matches package versions if: steps.check.outputs.needed == 'true' env: VERSION: ${{ steps.check.outputs.version }} run: | PKG=$(node -p "require('./package.json').version") DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version") TAURI=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version") if [ "v$PKG" != "$VERSION" ] || [ "$DESKTOP_PKG" != "$PKG" ] || [ "$TAURI" != "$PKG" ]; then echo "tag $VERSION mismatch: root=$PKG desktop=$DESKTOP_PKG tauri=$TAURI" exit 1 fi images: name: Build + push ${{ matrix.app }} (${{ matrix.platform }}) needs: verify if: needs.verify.outputs.needed == 'true' # Native runners per arch — QEMU emulation SIGILLs on the current # node:20-alpine arm64 layers (uncaught target signal 4) and wedges # the build step indefinitely. runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} strategy: fail-fast: false matrix: app: [backend, auth, frontend] platform: [amd64, arm64] steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 # ghcr.io requires lowercase repo paths; the owner login may be mixed-case. - name: Compute lowercase GHCR owner run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . file: apps/${{ matrix.app }}/Dockerfile platforms: linux/${{ matrix.platform }} push: true tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }} # Joins the per-arch images into the real version + latest tags. manifests: name: Image manifests needs: [verify, images] if: needs.verify.outputs.needed == 'true' runs-on: ubuntu-latest steps: - uses: docker/setup-buildx-action@v3 - name: Compute lowercase GHCR owner run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create multi-arch manifests env: VERSION: ${{ needs.verify.outputs.version }} run: | set -e for app in backend auth frontend; do img="ghcr.io/${GHCR_OWNER}/primora-${app}" docker buildx imagetools create \ -t "$img:$VERSION" -t "$img:latest" \ "$img:$VERSION-amd64" "$img:$VERSION-arm64" done cli-binaries: name: Compile ${{ matrix.name }} needs: verify if: needs.verify.outputs.needed == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - { target: bun-linux-x64, name: linux-x64 } - { target: bun-linux-arm64, name: linux-arm64 } - { target: bun-darwin-x64, name: darwin-x64 } - { target: bun-darwin-arm64, name: darwin-arm64 } - { target: bun-windows-x64, name: windows-x64, ext: .exe } steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: npm ci - name: Compile primora + primora-mcp run: | set -e mkdir -p dist-release bun build --compile apps/cli/src/cli.ts --target=${{ matrix.target }} --outfile dist-release/primora-${{ matrix.name }}${{ matrix.ext }} bun build --compile apps/mcp/src/server.ts --target=${{ matrix.target }} --outfile dist-release/primora-mcp-${{ matrix.name }}${{ matrix.ext }} - uses: actions/upload-artifact@v4 with: name: binaries-${{ matrix.name }} path: dist-release/ if-no-files-found: error desktop: name: Desktop bundle (${{ matrix.name }}) needs: verify if: needs.verify.outputs.needed == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - { os: ubuntu-22.04, name: linux } - { os: macos-latest, name: macos } - { os: windows-latest, name: windows } steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" - run: npm ci - name: Install Linux webview dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf - name: Build Tauri bundles # Updater artifacts are minisign-signed; without the key the build # still succeeds but emits no .sig files (updater can't verify them). env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run bundle --workspace @primora/desktop - uses: actions/upload-artifact@v4 with: name: desktop-${{ matrix.name }} path: | apps/desktop/src-tauri/target/release/bundle/**/*.AppImage apps/desktop/src-tauri/target/release/bundle/**/*.deb apps/desktop/src-tauri/target/release/bundle/**/*.rpm apps/desktop/src-tauri/target/release/bundle/**/*.dmg apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz apps/desktop/src-tauri/target/release/bundle/**/*.msi apps/desktop/src-tauri/target/release/bundle/**/*.exe apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip apps/desktop/src-tauri/target/release/bundle/**/*.sig if-no-files-found: error npm-packages: name: Pack npm tarballs needs: verify if: needs.verify.outputs.needed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" - run: npm ci - run: | npm run build --workspace @primora/cli npm run build --workspace @primora/mcp mkdir -p dist-release cd apps/cli && npm pack --pack-destination ../../dist-release cd ../mcp && npm pack --pack-destination ../../dist-release - uses: actions/upload-artifact@v4 with: name: npm-tarballs path: dist-release/ if-no-files-found: error release: name: GitHub Release needs: [verify, manifests, cli-binaries, desktop, npm-packages] if: needs.verify.outputs.needed == 'true' runs-on: ubuntu-latest env: VERSION: ${{ needs.verify.outputs.version }} steps: - uses: actions/checkout@v4 # build-push-action uploads "*.dockerbuild" metadata artifacts that # download-artifact cannot extract — match only the real artifacts. - uses: actions/download-artifact@v4 with: pattern: "binaries-*" merge-multiple: true path: dist-release - uses: actions/download-artifact@v4 with: pattern: "desktop-*" merge-multiple: true path: dist-release - uses: actions/download-artifact@v4 with: name: npm-tarballs path: dist-release # workflow_run runs leave the release untagged — create it so the # GitHub Release points at a real tag. GITHUB_TOKEN pushes do not # re-trigger workflows, so this cannot recurse. - name: Tag release if: github.event_name == 'workflow_run' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag "$VERSION" git push origin "$VERSION" - name: Extract changelog section run: | awk -v ver="${VERSION#v}" ' $0 ~ "^## \\[" ver "\\]" { flag=1; next } /^## \[/ && flag { exit } flag ' CHANGELOG.md > notes.md [ -s notes.md ] || echo "Release ${VERSION}" > notes.md # Tauri updater manifest: one platform entry per signed updater # artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing # sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no # manifest and no update, which is correct (never ship unsigned). - name: Build updater manifest (latest.json) run: | python3 - <<'PY' import json, os, glob base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"] platforms = {} for sig in glob.glob("dist-release/**/*.sig", recursive=True): asset = sig[:-4] name = os.path.basename(asset) if name.endswith(".AppImage"): plat = "linux-x86_64" elif name.endswith(".app.tar.gz"): plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64" if "aarch64" not in name and "x86_64" not in name: plat = "darwin-aarch64" # macos-latest is arm64 elif name.endswith(".nsis.zip"): plat = "windows-x86_64" else: continue platforms[plat] = { "signature": open(sig).read().strip(), "url": f"{base}/{name}", } manifest = { "version": os.environ["VERSION"], "notes": "See the release notes.", "pub_date": __import__("datetime").datetime.now( __import__("datetime").timezone.utc ).strftime("%Y-%m-%dT%H:%M:%SZ"), "platforms": platforms, } if platforms: with open("dist-release/latest.json", "w") as f: json.dump(manifest, f, indent=2) print("latest.json:", ", ".join(platforms)) else: print("no signed updater artifacts — skipping latest.json") PY - name: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Desktop artifacts extract to nested dirs (appimage/, deb/, ...) — # a bare glob hands gh a directory and the upload dies mid-flight. run: | mapfile -t files < <(find dist-release -type f) [ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; } gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}" # The manifest must be fetchable at /releases/latest/download/latest.json if [ -f dist-release/latest.json ]; then gh release upload "$VERSION" dist-release/latest.json --clobber fi # Software bill of materials — CycloneDX JSON per published image plus # one for the source tree (npm + Go modules). Attached to the release # next to the binaries; downstream consumers can verify provenance. sbom: name: SBOMs needs: [verify, release] if: needs.verify.outputs.needed == 'true' runs-on: ubuntu-latest permissions: packages: read # contents: write — the job uploads SBOM assets onto the release. contents: write env: VERSION: ${{ needs.verify.outputs.version }} steps: - uses: actions/checkout@v4 # ghcr.io requires lowercase repo paths; the owner login may be mixed-case. - name: Compute lowercase GHCR owner run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # anchore/sbom-action does not create the output directory. - run: mkdir -p dist-release - uses: anchore/sbom-action@v0 with: image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }} format: cyclonedx-json output-file: dist-release/sbom-backend.cdx.json - uses: anchore/sbom-action@v0 with: image: ghcr.io/${{ env.GHCR_OWNER }}/primora-auth:${{ needs.verify.outputs.version }} format: cyclonedx-json output-file: dist-release/sbom-auth.cdx.json - uses: anchore/sbom-action@v0 with: image: ghcr.io/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }} format: cyclonedx-json output-file: dist-release/sbom-frontend.cdx.json - uses: anchore/sbom-action@v0 with: path: . format: cyclonedx-json output-file: dist-release/sbom-source.cdx.json - name: Attach SBOMs to the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber npm-publish: name: Publish to npm needs: release runs-on: ubuntu-latest env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" registry-url: https://registry.npmjs.org - run: npm ci - run: npm run build --workspace @primora/cli && npm run build --workspace @primora/mcp - name: Publish @primora/cli + @primora/mcp if: env.NPM_TOKEN != '' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | npm publish --workspace @primora/cli --access public npm publish --workspace @primora/mcp --access public - name: Note skipped publish if: env.NPM_TOKEN == '' run: echo "NPM_TOKEN secret not set — tarballs attached to the GitHub release instead."