Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
name: Release

on:
# Cut a GitHub Release with desktop installers (Linux / macOS / Windows).
workflow_dispatch:
inputs:
version:
description: "Semver without leading v (e.g. 0.1.0-alpha.1)"
required: true
type: string
default: "0.1.0-alpha.1"
prerelease:
description: "Mark the GitHub Release as a pre-release"
required: true
type: boolean
default: true
Comment on lines +12 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Derive prerelease status from the release version.

A manual run can publish 0.1.0-alpha.1 with prerelease: false. That release can then become /releases/latest, despite the desktop updater excluding prereleases.

Derive PRERELEASE from the validated SemVer prerelease component for both triggers. Alternatively, fail when the manual input conflicts with the version.

Also applies to: 41-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 12 - 16, Update the release
workflow’s prerelease handling to derive PRERELEASE from the validated SemVer
prerelease component for both workflow triggers, rather than trusting the manual
prerelease input. Ensure versions such as 0.1.0-alpha.1 are always marked as
prereleases; alternatively, validate and fail manual runs when the input
conflicts with the version.

push:
tags:
- "v*"

permissions:
contents: write
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict repository write permission to the publish job.

test and desktop do not create tags or releases. They currently receive contents: write.

Set the workflow default to contents: read. Grant contents: write only to publish.

Proposed fix
 permissions:
-  contents: write
+  contents: read
...
   publish:
     name: Publish GitHub Release
     needs: [meta, desktop]
     runs-on: ubuntu-latest
+    permissions:
+      contents: write

Also applies to: 225-228

🧰 Tools
🪛 zizmor (1.29.0)

[error] 22-22: overly broad permissions (excessive-permissions): contents: write is overly broad at the workflow level

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 21 - 22, Set the workflow-level
permissions default to contents: read, then add contents: write only within the
publish job. Ensure the test and desktop jobs inherit read-only contents access
and retain their existing behavior.

Source: Linters/SAST tools


concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
meta:
name: Resolve version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.out.outputs.version }}
tag: ${{ steps.out.outputs.tag }}
prerelease: ${{ steps.out.outputs.prerelease }}
steps:
- id: out
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
PRERELEASE="${{ inputs.prerelease }}"
else
TAG_NAME="${GITHUB_REF_NAME}"
VERSION="${TAG_NAME#v}"
if [[ "$VERSION" == *"-"* ]]; then
PRERELEASE=true
else
PRERELEASE=false
fi
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-].+)?$ ]]; then
echo "Invalid version: $VERSION" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
Comment on lines +37 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- workflow files ---'
git ls-files '.github/workflows/*'

printf '%s\n' '--- release workflow outline/context ---'
wc -l .github/workflows/release.yml
cat -n .github/workflows/release.yml | sed -n '1,180p'

printf '%s\n' '--- relevant references ---'
rg -n -C 3 'inputs\.version|GITHUB_EVENT_NAME|GITHUB_REF_NAME|VERSION=|GITHUB_OUTPUT|package\.json|npm version|tag|release' .github/workflows/release.yml

printf '%s\n' '--- behavioral probe: generated Bash from the current interpolation ---'
python3 - <<'PY'
import subprocess

template = r'''set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
  VERSION="${{ inputs.version }}"
  VERSION="${VERSION#v}"
else
  TAG_NAME="${GITHUB_REF_NAME}"
  VERSION="${TAG_NAME#v}"
fi
printf 'VERSION=<%s>\n' "$VERSION"
'''

values = {
    "benign": "v1.2.3",
    "quote": '"; printf "INJECTED"; #',
    "command_substitution": '$(printf INJECTED)',
    "newline": '1.2.3\nprintf INJECTED\n#',
}
for name, value in values.items():
    generated = template.replace("${{ inputs.version }}", value)
    result = subprocess.run(
        ["bash", "-c", generated],
        text=True,
        capture_output=True,
        env={"GITHUB_REF_NAME": "v9.9.9"},
    )
    print(f"[{name}] exit={result.returncode}")
    print("stdout:", repr(result.stdout))
    print("stderr:", repr(result.stderr))
PY

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 13281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess

current = r'''set -euo pipefail
if [[ "workflow_dispatch" == "workflow_dispatch" ]]; then
  VERSION="${INPUT_VERSION}"
  VERSION="${VERSION#v}"
  printf 'VERSION=<%s>\n' "$VERSION"
fi
'''

direct_template = r'''set -euo pipefail
if [[ "workflow_dispatch" == "workflow_dispatch" ]]; then
  VERSION="${{ inputs.version }}"
  VERSION="${VERSION#v}"
  printf 'VERSION=<%s>\n' "$VERSION"
fi
'''

env_template = r'''set -euo pipefail
if [[ "workflow_dispatch" == "workflow_dispatch" ]]; then
  VERSION="${RELEASE_VERSION#v}"
  printf 'VERSION=<%s>\n' "$VERSION"
fi
'''

values = {
    "benign": "v1.2.3",
    "command_substitution": '$(printf INJECTED)',
    "quote_and_command": '"; printf INJECTED; #',
    "newline_command": '1.2.3\nprintf INJECTED\n#',
}

for name, value in values.items():
    direct = direct_template.replace("${{ inputs.version }}", value)
    direct_result = subprocess.run(
        ["bash", "-c", direct],
        text=True,
        capture_output=True,
        env={"PATH": "/usr/bin:/bin"},
    )
    env_result = subprocess.run(
        ["bash", "-c", env_template],
        text=True,
        capture_output=True,
        env={"PATH": "/usr/bin:/bin", "RELEASE_VERSION": value},
    )
    print(f"[{name}] direct exit={direct_result.returncode}")
    print(f"direct stdout={direct_result.stdout!r}")
    print(f"direct stderr={direct_result.stderr!r}")
    print(f"[{name}] env exit={env_result.returncode}")
    print(f"env stdout={env_result.stdout!r}")
    print(f"env stderr={env_result.stderr!r}")
PY

printf '%s\n' '--- current regex versus strict SemVer-shaped cases ---'
python3 - <<'PY'
import re

current = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-].+)?$')
strict = re.compile(
    r'^(0|[1-9][0-9]*)\.'
    r'(0|[1-9][0-9]*)\.'
    r'(0|[1-9][0-9]*)'
    r'(?:-('
    r'(?:0|[1-9][0-9]*|'
    r'[0-9]*[A-Za-z-][0-9A-Za-z-]*'
    r')(?:\.'
    r'(?:0|[1-9][0-9]*|'
    r'[0-9]*[A-Za-z-][0-9A-Za-z-]*'
    r'))*)?'
    r'(?:\+'
    r'[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
)

cases = [
    "1.2.3",
    "0.1.0-alpha.1",
    "1.2.3+build.7",
    "1.2.3-alpha+build.7",
    "01.2.3",
    "1.2.3-01",
    "1.2.3.",
    "1.2.3.foo",
    "1.2.3-alpha..1",
]
for value in cases:
    print(f"{value:24} current={bool(current.fullmatch(value))} strict={bool(strict.fullmatch(value))}")
PY

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 1742


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re

workflow_pattern = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-].+)?$')
cases = [
    ("1.2.3", True),
    ("0.1.0-alpha.1", True),
    ("1.2.3+build.7", False),
    ("1.2.3-alpha+build.7", True),
    ("01.2.3", False),
    ("1.2.3-01", False),
    ("1.2.3.", False),
    ("1.2.3.foo", False),
    ("1.2.3-alpha..1", False),
]
for value, strict_semver_expected in cases:
    accepted = bool(workflow_pattern.fullmatch(value))
    print(f"{value:24} workflow_regex={accepted} strict_semver_expected={strict_semver_expected}")
PY

printf '%s\n' '--- exact Bash validation behavior ---'
for value in '1.2.3+build.7' '01.2.3' '1.2.3-01' '1.2.3.foo' '1.2.3-alpha..1'; do
  if [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-].+)?$ ]]; then
    result=accepted
  else
    result=rejected
  fi
  printf '%s: %s\n' "$value" "$result"
done

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 987


Pass inputs.version through the step environment before validation.

inputs.version is inserted into Bash source at line 42. A value such as $(...) executes during assignment, and a quote can inject commands. Read the value from RELEASE_VERSION instead, and use $GITHUB_EVENT_NAME rather than interpolating github.event_name. Replace the current regex with strict SemVer validation; it accepts values such as 01.2.3, 1.2.3-01, and 1.2.3-alpha..1.

🧰 Tools
🪛 zizmor (1.29.0)

[error] 42-42: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 37 - 60, Update the step
identified by id "out" to pass inputs.version via the RELEASE_VERSION
environment variable and branch on $GITHUB_EVENT_NAME, avoiding direct
GitHub-expression interpolation in Bash. Read the dispatch version from
"$RELEASE_VERSION" before stripping an optional v, and replace the current regex
with strict SemVer validation that rejects leading-zero numeric components,
numeric prerelease identifiers with leading zeros, and empty or repeated
prerelease segments while preserving valid build metadata.

Source: Linters/SAST tools

echo "Resolved tag=v$VERSION prerelease=$PRERELEASE"

test:
name: Test (Ubuntu)
needs: meta
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10.33.3
- uses: actions/setup-node@v7
Comment on lines +68 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- workflow action references ---'
rg -n '^[[:space:]]*-[[:space:]]+uses:' .github/workflows/release.yml
printf '%s\n' '--- relevant workflow sections ---'
sed -n '1,280p' .github/workflows/release.yml

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 10348


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import json
import re
import urllib.request
from pathlib import Path

workflow = Path(".github/workflows/release.yml").read_text()
refs = re.findall(r'uses:\s*([^@\s]+)@([^\s#]+)', workflow)

for repo, ref in refs:
    if "/" not in repo:
        continue
    if ref.startswith(("v", "stable")):
        kind = "heads" if ref == "stable" else "tags"
        url = f"https://api.github.com/repos/{repo}/git/ref/{kind}/{ref}"
        try:
            with urllib.request.urlopen(url) as response:
                data = json.load(response)
            obj = data.get("object", {})
            print(f"{repo}@{ref}: {kind}, type={obj.get('type')}, sha={obj.get('sha')}")
        except Exception as exc:
            print(f"{repo}@{ref}: lookup failed: {exc}")
    else:
        print(f"{repo}@{ref}: non-version ref")
PY

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 2454


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import json
import re
import subprocess
from pathlib import Path

refs = sorted(set(re.findall(
    r'uses:\s*([^@\s]+)@([^\s#]+)',
    Path(".github/workflows/release.yml").read_text(),
)))

for repo, ref in refs:
    endpoint = "heads" if ref == "stable" else "tags"
    url = f"https://api.github.com/repos/{repo}/git/ref/{endpoint}/{ref}"
    result = subprocess.run(
        ["curl", "-kfsSL", "-H", "Accept: application/vnd.github+json", url],
        text=True, capture_output=True, check=False,
    )
    try:
        data = json.loads(result.stdout)
        obj = data.get("object", {})
        print(json.dumps({
            "uses": f"{repo}@{ref}",
            "ref_type": endpoint,
            "object_type": obj.get("type"),
            "object_sha": obj.get("sha"),
            "error": data.get("message"),
        }, sort_keys=True))
    except Exception:
        print(json.dumps({
            "uses": f"{repo}@{ref}",
            "error": result.stderr.strip() or result.stdout[:200],
        }, sort_keys=True))
PY

Repository: Modsofthenation/PerformanceReviewManager

Length of output: 1570


Pin all release-pipeline actions to immutable commit SHAs.

The workflow uses version tags and the stable branch. An upstream ref change can execute unreviewed code with contents: write. Replace every uses: reference with a full commit SHA. Keep the intended version in an adjacent comment and use an automated dependency updater for future updates.

🧰 Tools
🪛 zizmor (1.29.0)

[warning] 68-68: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 72-72: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): this step

(cache-poisoning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 68 - 72, Update every uses
reference in the release workflow, including actions/checkout,
pnpm/action-setup, and actions/setup-node, to a full immutable commit SHA
instead of a tag or branch ref. Add an adjacent comment documenting each pinned
action’s intended version, preserving the current versions, and configure the
repository’s automated dependency updater to maintain these SHA pins.

with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm rebuild better-sqlite3
- run: pnpm -r typecheck
- run: pnpm test

desktop:
name: Desktop ${{ matrix.platform }}
needs: [meta, test]
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
args: --target x86_64-unknown-linux-gnu
rust_target: x86_64-unknown-linux-gnu
artifact: desktop-linux-x86_64
- platform: macos-latest
args: --target aarch64-apple-darwin
rust_target: aarch64-apple-darwin
artifact: desktop-macos-aarch64
- platform: windows-latest
args: --target x86_64-pc-windows-msvc
rust_target: x86_64-pc-windows-msvc
artifact: desktop-windows-x86_64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10.33.3
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- name: Install Linux WebKit deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- run: pnpm install --frozen-lockfile
- run: pnpm rebuild better-sqlite3
- name: Align package versions to release
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.meta.outputs.version }}"
python3 - <<'PY' "$VERSION"
import json, pathlib, re, sys
version = sys.argv[1]
roots = [
pathlib.Path("package.json"),
pathlib.Path("apps/api/package.json"),
pathlib.Path("apps/ui/package.json"),
pathlib.Path("apps/desktop/package.json"),
pathlib.Path("packages/db/package.json"),
pathlib.Path("packages/mcp/package.json"),
pathlib.Path("packages/shared/package.json"),
]
for path in roots:
data = json.loads(path.read_text())
data["version"] = version
path.write_text(json.dumps(data, indent=2) + "\n")
conf = pathlib.Path("apps/desktop/src-tauri/tauri.conf.json")
conf_data = json.loads(conf.read_text())
conf_data["version"] = version
conf.write_text(json.dumps(conf_data, indent=2) + "\n")
cargo = pathlib.Path("apps/desktop/src-tauri/Cargo.toml")
text = cargo.read_text()
text, n = re.subn(r'(?m)^version = "[^"]+"', f'version = "{version}"', text, count=1)
if n != 1:
raise SystemExit("failed to patch Cargo.toml version")
cargo.write_text(text)
print(f"set version {version}")
PY
- name: Bundle API sidecar + Node
run: node apps/desktop/scripts/bundle-api-sidecar.mjs --node
env:
PRM_BUNDLE_NODE: "1"
- uses: tauri-apps/tauri-action@v0
id: tauri
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop
args: ${{ matrix.args }}
includeUpdaterJson: false
- name: Smoke API sidecar
shell: bash
run: |
set -euo pipefail
SIDE="apps/desktop/src-tauri/sidecars"
UI="apps/desktop/src-tauri/ui-dist"
if [[ "${{ runner.os }}" == "Windows" ]]; then
NODE="$SIDE/node.exe"
else
NODE="$SIDE/node"
chmod +x "$NODE" || true
fi
test -f "$NODE"
test -f "$SIDE/prm-api.mjs"
test -f "$UI/index.html"
DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke"
rm -rf "$DATA"
mkdir -p "$DATA"
PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke PRM_UI_DIR="$UI" \
"$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 &
PID=$!
cleanup() { kill "$PID" 2>/dev/null || true; }
trap cleanup EXIT
for i in $(seq 1 50); do
if curl -sf "http://127.0.0.1:8799/api/health" >/dev/null; then
echo "sidecar healthy"
curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke"
echo
exit 0
fi
sleep 0.2
done
echo "sidecar failed to become healthy" >&2
cat "$DATA/out.log" >&2 || true
exit 1
- name: Stage installer artifacts
shell: bash
run: |
set -euo pipefail
SRC="apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
DST="${RUNNER_TEMP}/release-assets"
mkdir -p "$DST"
# Prefer distributable installers; skip raw .app directories.
find "$SRC" -type f \( \
-name '*.deb' -o -name '*.AppImage' -o -name '*.rpm' -o \
-name '*.dmg' -o -name '*.msi' -o -name '*.exe' \
\) -print -exec cp -v {} "$DST/" \;
ls -la "$DST"
test -n "$(ls -A "$DST")"
- name: Upload desktop installers
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: ${{ runner.temp }}/release-assets/*
if-no-files-found: error
retention-days: 30

publish:
name: Publish GitHub Release
needs: [meta, desktop]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Download installer artifacts
uses: actions/download-artifact@v7
with:
path: release-assets
pattern: desktop-*
merge-multiple: true
- name: List assets
run: find release-assets -type f | sort
- name: Ensure release tag exists
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ needs.meta.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists"
else
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
fi
Comment on lines +241 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Verify the existing tag points to the built commit.

Line 249 only checks whether the tag exists. A manual run from another ref can upload new installers to a release whose tag identifies an older commit.

If the tag exists, compare its dereferenced commit with HEAD and fail on mismatch.

Proposed fix
-          if git rev-parse "$TAG" >/dev/null 2>&1; then
-            echo "Tag $TAG already exists"
+          if git rev-parse -q --verify "refs/tags/$TAG^{commit}" >/dev/null; then
+            TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
+            HEAD_COMMIT="$(git rev-parse HEAD)"
+            if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then
+              echo "Tag $TAG points to $TAG_COMMIT, but this build uses $HEAD_COMMIT" >&2
+              exit 1
+            fi
+            echo "Tag $TAG already exists at the built commit"
           else
             git tag -a "$TAG" -m "Release $TAG"
             git push origin "$TAG"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Ensure release tag exists
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ needs.meta.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists"
else
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
fi
- name: Ensure release tag exists
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ needs.meta.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]`@users.noreply.github.com`"
if git rev-parse -q --verify "refs/tags/$TAG^{commit}" >/dev/null; then
TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
HEAD_COMMIT="$(git rev-parse HEAD)"
if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then
echo "Tag $TAG points to $TAG_COMMIT, but this build uses $HEAD_COMMIT" >&2
exit 1
fi
echo "Tag $TAG already exists at the built commit"
else
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 241 - 254, Update the “Ensure
release tag exists” step to validate an existing tag’s dereferenced commit
against HEAD using the tag-resolution logic around git rev-parse. Keep the
existing success path when they match, but fail the workflow with a clear
mismatch message before uploading when the tag points to a different commit;
retain tag creation and pushing for missing tags.

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.meta.outputs.tag }}
name: Performance Review Manager ${{ needs.meta.outputs.tag }}
prerelease: ${{ needs.meta.outputs.prerelease == 'true' }}
generate_release_notes: true
body: |
## Alpha release

Local workbench for evidence-backed performance reviews and promotion cases.

### Desktop installers

| Platform | Artifact |
|----------|----------|
| Linux x86_64 | `.deb` / `.AppImage` |
| macOS Apple Silicon | `.dmg` |
| Windows x86_64 | `.msi` / NSIS `.exe` |

### Notes

- First-run: create an empty workspace or demo team; lock/encrypt between sessions.
- Demo password is for exploration only — do not store real employee data behind it.
- Signed auto-update is not enabled yet; use **Settings → Check for desktop updates** or download from this release.

### Verify

Compare checksums after download if your browser provides them, then install and unlock a fresh workspace.
files: |
release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Performance Review Manager — a local, single-user ("solo EM") pnpm monorepo. S
- `@prm/api` (`apps/api`) — Hono + better-sqlite3 backend on `http://127.0.0.1:8787`. Run with `pnpm dev:api` (uses `tsx watch`, no strict typecheck).
- `@prm/ui` (`apps/ui`) — React + Vite frontend on `http://localhost:5173`, proxies `/api` → the API. Run with `pnpm dev:ui`.
- `pnpm dev` runs both in parallel — this is the primary dev entrypoint.
- `@prm/desktop` (`apps/desktop`) — Tauri shell. `desktop:dev`/`desktop:build` need Rust + system WebView libraries and are not validated in the headless cloud VM. Packaged desktop installers run only via Actions → **Desktop Installers** → **Run workflow** (not on every merge; defaults to Windows + Linux).
- `@prm/desktop` (`apps/desktop`) — Tauri shell. `desktop:dev`/`desktop:build` need Rust + system WebView libraries and are not validated in the headless cloud VM. Packaged desktop installers run via Actions → **Desktop Installers** → **Run workflow** (not on every merge; defaults to Windows + Linux). Formal GitHub Releases (with installers attached) use Actions → **Release** → **Run workflow**.

### Package manager (important, non-obvious)
- This repo requires **pnpm 10.33.3**. The VM's default corepack resolves pnpm 11, which (a) rejects this committed lockfile via a `minimumReleaseAge` supply-chain policy and (b) ignores the `pnpm.onlyBuiltDependencies` field in `package.json` that is needed to build the `better-sqlite3` native module. The update script pins 10.33.3 via `corepack prepare [email protected] --activate`; keep using pnpm 10.x. If native `better-sqlite3` is missing, run `pnpm rebuild better-sqlite3`.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ pnpm desktop:dev # native window (needs WebView + Rust)
pnpm desktop:build
```

Desktop installers are built on demand: Actions → **Desktop Installers** → **Run workflow** (defaults to Windows + Linux; uploads `desktop-windows-x86_64` / `desktop-linux-x86_64`, optional macOS). Merges to `main` only run unit tests + typecheck. Details: [`apps/desktop/README.md`](./apps/desktop/README.md).
Desktop installers:

- **Artifacts only:** Actions → **Desktop Installers** → **Run workflow** (defaults to Windows + Linux; uploads `desktop-windows-x86_64` / `desktop-linux-x86_64`, optional macOS)
- **GitHub Release:** Actions → **Release** → **Run workflow** with version `0.1.0-alpha.1` (or push tag `v0.1.0-alpha.1`)

Merges to `main` only run unit tests + typecheck. Details: [`apps/desktop/README.md`](./apps/desktop/README.md).

### Cursor MCP (bulk load)

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prm/api",
"version": "0.1.0",
"version": "0.1.0-alpha.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Settings → **Check for updates** compares the packaged version (`get_app_versi

Packaged data lives in the OS app-data directory (for example `%APPDATA%\com.prm.workbench` on Windows). Sidecar stdout/stderr is appended to `desktop-api.log` there for debugging startup failures.

### Desktop Installers (artifacts only)

CI (`ci.yml`) runs unit tests + typecheck only. Desktop installers are built on demand via the **Desktop Installers** workflow (`workflow_dispatch`). Default platform choice is **windows-and-linux**; the Actions UI also offers Windows-only, Linux-only, macOS, or all three. Each green job uploads an Actions artifact (retained 30 days):

| Artifact name | Typical contents |
Expand All @@ -56,14 +58,22 @@ CI (`ci.yml`) runs unit tests + typecheck only. Desktop installers are built on
| `desktop-macos-aarch64` | `.dmg` / `.app` under `bundle/` |
| `desktop-windows-x86_64` | `.msi` / NSIS `.exe` under `bundle/` |

Download from the run’s **Artifacts** section in the GitHub UI, or:

```bash
gh workflow run "Desktop Installers" -f platforms=windows-and-linux
gh run download <run-id> -n desktop-windows-x86_64
# also: desktop-linux-x86_64, desktop-macos-aarch64
```

### GitHub Releases (alpha / tagged)

Use the **Release** workflow (Actions → **Release** → **Run workflow**) to build installers and publish a GitHub Release:

- Input `version`: semver without `v` (default `0.1.0-alpha.1`)
- Input `prerelease`: leave on for alphas
- Or push a tag `v*` (hyphenated versions such as `v0.1.0-alpha.1` are treated as pre-releases)

Assets attached to the release are the platform installers (`.deb` / `.AppImage`, `.dmg`, `.msi` / `.exe`).

## Icons

Source mark: `apps/desktop/app-icon.png` (1024²). Regenerate platform icons with:
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prm/desktop",
"version": "0.1.0",
"version": "0.1.0-alpha.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "prm-desktop"
version = "0.1.0"
version = "0.1.0-alpha.1"
description = "Performance Review Manager desktop shell"
authors = ["Damon van der Linde"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Performance Review Manager",
"version": "0.1.0",
"version": "0.1.0-alpha.1",
"identifier": "com.prm.workbench",
"build": {
"beforeDevCommand": "pnpm prepare:dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prm/ui",
"version": "0.1.0",
"version": "0.1.0-alpha.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ export function SettingsPage({
className="btn secondary"
onClick={async () => {
try {
let localVersion = "0.1.0";
let localVersion = "0.1.0-alpha.1";
const invoke = (window as unknown as {
__TAURI__?: { core?: { invoke?: (cmd: string) => Promise<string> } };
}).__TAURI__?.core?.invoke;
Expand Down
Loading