diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e1313ac0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,885 @@ +name: Release + +# Tag-triggered release pipeline with a manual dry-run path for existing tags. +# Pushing an annotated, signed `v*` tag is the production path; workflow_dispatch +# rehearses one with dry_run defaulting to true. Everything downstream refuses +# to run until the tag is proven signed and consistent with its committed tree. +# +# Third-party actions are pinned to a full commit SHA with a version comment, +# matching the convention in .github/workflows/ci.yml. The toolchain versions +# (Node 24.18.1 via .node-version, pnpm 10.34.0, Rust 1.95.0) are the same ones +# ci.yml pins, so a release builds with the toolchain that was tested. +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing signed tag to build (for example v0.0.1)' + required: true + type: string + dry_run: + description: 'Build and verify without creating a GitHub Release' + required: false + default: true + type: boolean + +# Nothing here needs write access by default. The single job that publishes the +# release asks for `contents: write` explicitly; every other job is read-only. +permissions: {} + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + # --------------------------------------------------------------------------- + # Gate. The release does not build until the tag is proven to be an annotated, + # signed tag whose version matches every manifest in the tree. + # --------------------------------------------------------------------------- + verify-tag: + name: Verify signed tag + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: release-signing + permissions: + contents: read + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.check.outputs.version }} + prerelease: ${{ steps.check.outputs.prerelease }} + sha: ${{ steps.resolve.outputs.sha }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + steps: + - id: resolve + name: Resolve and verify the remote tag + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + tag="${INPUT_TAG:-${GITHUB_REF_NAME}}" + if ! printf '%s' "${tag}" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then + echo "::error::Tag '${tag}' is not a v..[-prerelease] tag." + exit 1 + fi + + ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" + object_type="$(jq -r '.object.type' <<<"${ref_json}")" + object_sha="$(jq -r '.object.sha' <<<"${ref_json}")" + if [ "${object_type}" != "tag" ]; then + echo "::error::${tag} is a lightweight tag (${object_type}); a release requires an annotated, signed tag (git tag -s)." + exit 1 + fi + + tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${object_sha}")" + if [ "$(jq -r '.verification.signature // empty' <<<"${tag_json}")" = "" ]; then + echo "::error::${tag} is annotated but carries no GPG/SSH signature. Create it with 'git tag -s'." + exit 1 + fi + + verified="$(jq -r '.verification.verified' <<<"${tag_json}")" + reason="$(jq -r '.verification.reason' <<<"${tag_json}")" + if [ "${verified}" != "true" ]; then + echo "::error::GitHub could not verify the signature on ${tag} (reason: ${reason})." + exit 1 + fi + + target_type="$(jq -r '.object.type' <<<"${tag_json}")" + target_sha="$(jq -r '.object.sha' <<<"${tag_json}")" + if [ "${target_type}" != "commit" ]; then + echo "::error::${tag} points to ${target_type}, not directly to a commit." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${target_sha}" != "${GITHUB_SHA}" ]; then + echo "::error::${tag} moved after the workflow event; expected ${GITHUB_SHA}, found ${target_sha}." + exit 1 + fi + + ancestry_status="$(gh api \ + "repos/${GITHUB_REPOSITORY}/compare/${target_sha}...main" \ + --jq '.status')" + case "${ancestry_status}" in + ahead|identical) ;; + *) + echo "::error::${tag} points at ${target_sha}, which is not reachable from origin/main." + exit 1 + ;; + esac + + version="${tag#v}" + case "${version}" in + 0.*|*-*) prerelease=true ;; + *) prerelease=false ;; + esac + + dry_run=false + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN}" = "true" ]; then + dry_run=true + fi + if [ "${dry_run}" = "false" ] && + gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "::error::A GitHub Release already exists for ${tag}; refuse to replace published assets." + exit 1 + fi + + { + echo "tag=${tag}" + echo "sha=${target_sha}" + echo "prerelease=${prerelease}" + echo "dry_run=${dry_run}" + } >> "$GITHUB_OUTPUT" + + echo "Verified remote tag ${tag} at ${target_sha} (prerelease=${prerelease}, dry_run=${dry_run})." + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: refs/tags/${{ steps.resolve.outputs.tag }} + # The tag object and its signature are needed, not just the commit it + # points at, so the full history and all tags are fetched. + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 10.34.0 + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .node-version + cache: pnpm + + - id: check + name: Verify tag is annotated, signed, and version-consistent + env: + TAG: ${{ steps.resolve.outputs.tag }} + EXPECTED_SHA: ${{ steps.resolve.outputs.sha }} + PRERELEASE: ${{ steps.resolve.outputs.prerelease }} + # Optional allowed-signers file (SSH tag signing) supplied as a + # secret. GitHub's API verification above is authoritative; when this + # is present, git independently verifies an SSH signature too. + TAG_ALLOWED_SIGNERS: ${{ secrets.TAG_ALLOWED_SIGNERS }} + run: | + set -euo pipefail + + echo "Tag under release: ${TAG}" + + # Re-check the fetched object rather than trusting only the API result. + object_type=$(git cat-file -t "${TAG}") + if [ "${object_type}" != "tag" ]; then + echo "::error::${TAG} is a lightweight tag (${object_type}); a release requires an annotated, signed tag (git tag -s)." + exit 1 + fi + + raw=$(git cat-file tag "${TAG}") + if ! printf '%s\n' "${raw}" | grep -qE 'BEGIN (PGP|SSH) SIGNATURE'; then + echo "::error::${TAG} is annotated but carries no GPG/SSH signature. Create it with 'git tag -s'." + exit 1 + fi + + checked_sha="$(git rev-parse "${TAG}^{commit}")" + if [ "${checked_sha}" != "${EXPECTED_SHA}" ] || [ "$(git rev-parse HEAD)" != "${EXPECTED_SHA}" ]; then + echo "::error::The checked-out tag does not resolve to the API-verified commit ${EXPECTED_SHA}." + exit 1 + fi + + if printf '%s\n' "${raw}" | grep -q 'BEGIN SSH SIGNATURE'; then + if [ -n "${TAG_ALLOWED_SIGNERS:-}" ]; then + printf '%s\n' "${TAG_ALLOWED_SIGNERS}" > "${RUNNER_TEMP}/allowed_signers" + git config gpg.ssh.allowedSignersFile "${RUNNER_TEMP}/allowed_signers" + if ! git verify-tag --verbose "${TAG}"; then + echo "::error::Local SSH verification disagrees with GitHub for ${TAG}; the release is blocked." + exit 1 + fi + echo "Local SSH verification agrees with GitHub." + else + echo "::notice::TAG_ALLOWED_SIGNERS is not configured; relying on GitHub's successful SSH tag verification." + fi + else + echo "::notice::GitHub verified the GPG tag signature; no trusted GPG keyring is configured for an independent local check." + fi + + tag_version="${TAG#v}" + echo "Tag version: ${tag_version}" + + pkg_version=$(node -p "require('./package.json').version") + conf_version=$(node -p "require('./src-tauri/tauri.conf.json').version") + cargo_version=$(awk ' + /^\[package\]/ { in_package = 1; next } + /^\[/ { in_package = 0 } + in_package && /^version[[:space:]]*=/ { + gsub(/^version[[:space:]]*=[[:space:]]*"/, "") + gsub(/".*$/, "") + print + exit + } + ' src-tauri/Cargo.toml) + + echo "package.json: ${pkg_version}" + echo "tauri.conf.json: ${conf_version}" + echo "src-tauri/Cargo.toml: ${cargo_version}" + + fail=0 + for pair in "package.json:${pkg_version}" "tauri.conf.json:${conf_version}" "Cargo.toml:${cargo_version}"; do + name="${pair%%:*}" + value="${pair#*:}" + if [ "${value}" != "${tag_version}" ]; then + echo "::error::${name} version ${value} does not match tag version ${tag_version}." + fail=1 + fi + done + if [ "${fail}" -ne 0 ]; then + exit 1 + fi + echo "All manifest versions match the tag." + + bundle_active=$(node -p "String(require('./src-tauri/tauri.conf.json').bundle?.active === true)") + if [ "${bundle_active}" != "true" ]; then + echo "::error::src-tauri/tauri.conf.json has bundle.active != true; a release build would produce no installers." + exit 1 + fi + echo "Bundling is enabled." + + { + echo "version=${tag_version}" + echo "prerelease=${PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + # The pull-request run tested a temporary merge commit. Re-run the + # release-relevant checks against the exact, API-verified tagged tree. + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm lint + - run: corepack pnpm typecheck + - run: corepack pnpm test:unit:normal + - run: corepack pnpm build + + # --------------------------------------------------------------------------- + # Build real installers on each platform, sign them when the signing secrets + # are present, and smoke-test every artifact on the native OS before it is + # allowed to leave the job. + # --------------------------------------------------------------------------- + build: + name: Build ${{ matrix.platform }} + needs: verify-tag + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + # The signing secrets live in a protected deployment environment. Consuming + # them here (Apple, Windows, and Tauri updater signing) ties every signed + # build to that environment's protection rules. + environment: release-signing + permissions: + contents: read + strategy: + # One platform failing should not cancel the others; a partial matrix is + # still useful diagnostic output, and the publish job depends on all of + # them succeeding anyway. + fail-fast: false + matrix: + include: + # `bundles` lists installer targets only. Tauri v2 has no `updater` + # bundle target: the updater archives (.app.tar.gz / .nsis.zip / + # .AppImage.tar.gz) and their .sig files are generated automatically + # alongside these installers when bundle.createUpdaterArtifacts is + # enabled in tauri.conf.json and TAURI_SIGNING_PRIVATE_KEY is set. + - platform: macos-aarch64 + runner: macos-14 + target: aarch64-apple-darwin + bundles: app,dmg + - platform: macos-x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + bundles: app,dmg + - platform: windows-x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + bundles: msi,nsis + - platform: linux-x86_64 + runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.verify-tag.outputs.sha }} + persist-credentials: false + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 10.34.0 + run_install: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .node-version + cache: pnpm + + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.95.0 + targets: ${{ matrix.target }} + + # Tauri's Linux build needs the GTK and WebKit development headers. This + # list is transcribed from .github/ci-image/Dockerfile (which bakes the + # same packages into the CI image) so a release links against the same + # libraries CI tested. libfuse2 is added on top because AppImage bundling + # needs FUSE at build time, which the CI image did not exercise. + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + libfuse2 \ + file + + - run: corepack pnpm install --frozen-lockfile + + # Whether this build will be signed is decided entirely by which secrets + # are present. The result is surfaced so the artifacts and the job log say + # plainly when a build is unsigned. + - id: signing + name: Determine signing availability + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + set -euo pipefail + macos_signed=false + windows_signed=false + updater_signed=false + [ -n "${APPLE_CERTIFICATE:-}" ] && macos_signed=true + [ -n "${WINDOWS_CERTIFICATE:-}" ] && windows_signed=true + [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && updater_signed=true + echo "macos_signed=${macos_signed}" >> "$GITHUB_OUTPUT" + echo "windows_signed=${windows_signed}" >> "$GITHUB_OUTPUT" + echo "updater_signed=${updater_signed}" >> "$GITHUB_OUTPUT" + if [ "${{ runner.os }}" = "macOS" ] && [ "${macos_signed}" = "false" ]; then + echo "::warning::APPLE_CERTIFICATE is not set; producing an UNSIGNED, un-notarized macOS build." + fi + if [ "${{ runner.os }}" = "Windows" ] && [ "${windows_signed}" = "false" ]; then + echo "::warning::WINDOWS_CERTIFICATE is not set; producing an UNSIGNED Windows build." + fi + if [ "${updater_signed}" = "false" ]; then + echo "::notice::TAURI_SIGNING_PRIVATE_KEY is not set; updater artifacts and latest.json are not produced. Auto-update is opt-in and disabled for v0.0.1 (no plugins.updater / createUpdaterArtifacts:false). See docs/releasing.md." + fi + + # Import Windows signing material before bundling so Tauri signs the + # installers that are placed inside any future updater archive. + - name: Import Windows signing certificate + if: runner.os == 'Windows' && steps.signing.outputs.windows_signed == 'true' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $pfxPath = Join-Path $env:RUNNER_TEMP 'windows-cert.pfx' + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $securePass = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $imported = @( + Import-PfxCertificate ` + -FilePath $pfxPath ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -Password $securePass ` + -Exportable:$false + ) + Remove-Item $pfxPath -Force + + $cert = $imported | Where-Object HasPrivateKey | Select-Object -First 1 + if ($null -eq $cert) { + throw 'The configured Windows certificate did not contain an importable private key.' + } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Configure Tauri Windows signing + if: runner.os == 'Windows' && steps.signing.outputs.windows_signed == 'true' + shell: bash + run: | + set -euo pipefail + node <<'EOF' + const fs = require('node:fs'); + const overlay = { + bundle: { + windows: { + certificateThumbprint: process.env.WINDOWS_CERTIFICATE_THUMBPRINT, + digestAlgorithm: 'sha256', + timestampUrl: 'https://timestamp.digicert.com', + }, + }, + }; + fs.writeFileSync( + 'src-tauri/release-signing.conf.json', + `${JSON.stringify(overlay, null, 2)}\n`, + ); + EOF + echo "TAURI_CONFIG_OVERLAY=src-tauri/release-signing.conf.json" >> "$GITHUB_ENV" + + # Apple signing variables are consumed directly by Tauri. Updater + # artifacts appear only when the updater plugin and private key are both + # configured; their absence is expected for v0.0.1. + - name: Build installers + 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: | + set -euo pipefail + args=( + --target "${{ matrix.target }}" + --bundles "${{ matrix.bundles }}" + --verbose + ) + if [ -n "${TAURI_CONFIG_OVERLAY:-}" ]; then + args+=(--config "${TAURI_CONFIG_OVERLAY}") + fi + corepack pnpm exec tauri build "${args[@]}" + + - name: Remove Windows signing overlay + if: always() && runner.os == 'Windows' + shell: bash + run: rm -f src-tauri/release-signing.conf.json + + # Collect the produced installers into a flat, predictably named staging + # directory and fail loudly if a platform produced nothing. + - name: Stage artifacts + shell: bash + run: | + set -euo pipefail + bundle_root="src-tauri/target/${{ matrix.target }}/release/bundle" + mkdir -p dist + shopt -s nullglob globstar + copied=0 + for f in \ + "${bundle_root}"/dmg/*.dmg \ + "${bundle_root}"/macos/*.app.tar.gz \ + "${bundle_root}"/macos/*.app.tar.gz.sig \ + "${bundle_root}"/msi/*.msi \ + "${bundle_root}"/msi/*.msi.zip \ + "${bundle_root}"/msi/*.msi.zip.sig \ + "${bundle_root}"/nsis/*.exe \ + "${bundle_root}"/nsis/*.nsis.zip \ + "${bundle_root}"/nsis/*.nsis.zip.sig \ + "${bundle_root}"/appimage/*.AppImage \ + "${bundle_root}"/appimage/*.AppImage.tar.gz \ + "${bundle_root}"/appimage/*.AppImage.tar.gz.sig \ + "${bundle_root}"/deb/*.deb ; do + [ -e "${f}" ] || continue + cp -v "${f}" dist/ + copied=$((copied + 1)) + done + if [ "${copied}" -eq 0 ]; then + echo "::error::No installers were produced for ${{ matrix.platform }} under ${bundle_root}." + exit 1 + fi + # The macOS updater archive (.app.tar.gz) and its .sig are named after + # the product only -- no arch -- so the aarch64 and x86_64 builds emit + # the same filename and would clobber each other when both platforms' + # artifacts are merged for publishing. Prefix them with the target so + # they stay distinct and so latest.json can tell the two apart. + if [[ "${{ matrix.platform }}" == macos-* ]]; then + for g in dist/*.app.tar.gz dist/*.app.tar.gz.sig; do + [ -e "${g}" ] || continue + mv "${g}" "dist/${{ matrix.target }}-$(basename "${g}")" + done + fi + # The .app itself is a directory; tar it so it survives artifact upload + # and can have its structure verified by the release smoke test. + for app in "${bundle_root}"/macos/*.app; do + [ -e "${app}" ] || continue + base=$(basename "${app}") + tar -czf "dist/${base%.app}-${{ matrix.target }}.app.tar" -C "$(dirname "${app}")" "${base}" + done + + # Signatures are intentionally small; every actual bundle or updater + # archive must clear a floor that catches truncated bundler output. + minimum_size=$((1024 * 1024)) + for artifact in dist/*; do + [ -f "${artifact}" ] || continue + case "${artifact}" in + *.sig) continue ;; + esac + size=$(wc -c < "${artifact}" | tr -d ' ') + if [ "${size}" -lt "${minimum_size}" ]; then + echo "::error::${artifact} is only ${size} bytes; release artifacts must be at least 1 MiB." + exit 1 + fi + done + + echo "Staged $(ls -1 dist | wc -l | tr -d ' ') files:" + ls -la dist + + # Native-OS smoke test. This runs on the platform that produced the + # artifacts because the checks it performs (the .app structure, codesign, + # spctl) only exist there. It fails if a file is missing, empty, or has + # the wrong extension for its platform. + - name: Smoke-test artifacts + shell: bash + env: + MACOS_SIGNED: ${{ steps.signing.outputs.macos_signed }} + EXPECTED_VERSION: ${{ needs.verify-tag.outputs.version }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd dist + + assert_nonempty() { + if [ ! -s "$1" ]; then + echo "::error::Artifact $1 is missing or empty." + exit 1 + fi + echo " ok: $1 ($(wc -c < "$1" | tr -d ' ') bytes)" + } + + case "${{ matrix.platform }}" in + macos-*) + found=0 + for dmg in *.dmg; do [ -e "$dmg" ] || continue; assert_nonempty "$dmg"; found=1; done + [ "$found" -eq 1 ] || { echo "::error::No .dmg produced."; exit 1; } + # Verify the .app structure from the staged tar. + apptar=$(ls -1 *.app.tar 2>/dev/null | head -n1 || true) + [ -n "${apptar}" ] || { echo "::error::No staged .app tar produced."; exit 1; } + if [ -n "${apptar}" ]; then + work="${RUNNER_TEMP}/release-app-smoke-${TARGET}" + rm -rf "${work}" + mkdir -p "${work}" + tar -xf "${apptar}" -C "${work}" + app=$(find "${work}" -maxdepth 1 -name '*.app' | head -n1) + [ -n "${app}" ] || { echo "::error::Staged .app tar had no .app inside."; exit 1; } + [ -d "${app}/Contents/MacOS" ] || { echo "::error::${app} is missing Contents/MacOS."; exit 1; } + plist="${app}/Contents/Info.plist" + [ -f "${plist}" ] || { echo "::error::${app} is missing Contents/Info.plist."; exit 1; } + + bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${plist}") + bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${plist}") + executable=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${plist}") + [ "${bundle_version}" = "${EXPECTED_VERSION}" ] || { + echo "::error::${app} reports version ${bundle_version}, expected ${EXPECTED_VERSION}." + exit 1 + } + [ "${bundle_id}" = "ai.opencoven.chat" ] || { + echo "::error::${app} reports bundle identifier ${bundle_id}, expected ai.opencoven.chat." + exit 1 + } + + binary="${app}/Contents/MacOS/${executable}" + case "${TARGET}" in + aarch64-apple-darwin) expected_arch=arm64 ;; + x86_64-apple-darwin) expected_arch=x86_64 ;; + *) echo "::error::Unexpected macOS target ${TARGET}."; exit 1 ;; + esac + archs=$(lipo -archs "${binary}") + if ! tr ' ' '\n' <<<"${archs}" | grep -Fxq "${expected_arch}"; then + echo "::error::${binary} has architectures '${archs}', expected ${expected_arch} for ${TARGET}." + exit 1 + fi + echo " ok: .app ${bundle_id} ${bundle_version}, architecture ${expected_arch}" + + if [ "${MACOS_SIGNED}" = "true" ]; then + echo " verifying code signature..." + codesign --verify --deep --strict --verbose=2 "${app}" + spctl --assess --type execute --verbose=2 "${app}" || { + echo "::error::spctl assessment failed for signed ${app}."; exit 1; } + else + echo " (unsigned build: skipping codesign/spctl)" + fi + rm -rf "${work}" + fi + ;; + windows-*) + found=0 + for inst in *.msi *.exe; do [ -e "$inst" ] || continue; assert_nonempty "$inst"; found=1; done + [ "$found" -eq 1 ] || { echo "::error::No .msi or .exe produced."; exit 1; } + ;; + linux-*) + for ext in AppImage deb; do + found=0 + for f in *."$ext"; do [ -e "$f" ] || continue; assert_nonempty "$f"; found=1; done + [ "$found" -eq 1 ] || { echo "::error::No .$ext produced."; exit 1; } + done + for deb in *.deb; do + [ -e "${deb}" ] || continue + package_version=$(dpkg-deb --field "${deb}" Version) + [ "${package_version}" = "${EXPECTED_VERSION}" ] || { + echo "::error::${deb} reports version ${package_version}, expected ${EXPECTED_VERSION}." + exit 1 + } + if ! dpkg-deb --contents "${deb}" | + awk '/\.\/usr\/bin\/[^/[:space:]]+/ { found = 1 } END { exit(found ? 0 : 1) }'; then + echo "::error::${deb} contains no executable payload under /usr/bin." + exit 1 + fi + echo " ok: ${deb} version and /usr/bin payload" + done + ;; + esac + echo "Smoke test passed for ${{ matrix.platform }}." + + - name: Verify Windows Authenticode signatures + if: runner.os == 'Windows' && steps.signing.outputs.windows_signed == 'true' + shell: pwsh + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $installers = @(Get-ChildItem -Path 'dist/*' -File -Include *.msi,*.exe) + if ($installers.Count -eq 0) { + throw 'No Windows installers were staged for signature verification.' + } + foreach ($installer in $installers) { + $signature = Get-AuthenticodeSignature -FilePath $installer.FullName + if ($signature.Status -ne 'Valid') { + throw "$($installer.Name) has invalid Authenticode status: $($signature.Status)" + } + } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: installers-${{ matrix.platform }} + path: dist/** + if-no-files-found: error + retention-days: 7 + + # --------------------------------------------------------------------------- + # Assemble the checksums and the updater manifest, re-verify every artifact + # against its checksum, and publish the GitHub release. + # --------------------------------------------------------------------------- + publish: + name: Publish release + needs: [verify-tag, build] + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release-signing + permissions: + # The only place in the workflow that writes: creating the release and + # uploading its assets. + contents: write + env: + VERSION: ${{ needs.verify-tag.outputs.version }} + PRERELEASE: ${{ needs.verify-tag.outputs.prerelease }} + TAG: ${{ needs.verify-tag.outputs.tag }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.verify-tag.outputs.sha }} + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .node-version + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: installers-* + path: staging + merge-multiple: true + + - name: Flatten and inventory artifacts + shell: bash + run: | + set -euo pipefail + mkdir -p release + # The .app tar was only needed for the smoke test; it is not a release + # asset, so it is excluded here. + while IFS= read -r -d '' artifact; do + name=$(basename "${artifact}") + destination="release/${name}" + if [ -e "${destination}" ]; then + echo "::error::Duplicate release asset name: ${name}" + exit 1 + fi + cp -v "${artifact}" "${destination}" + done < <(find staging -type f ! -name '*.app.tar' -print0) + if [ -z "$(ls -A release)" ]; then + echo "::error::No artifacts were downloaded from the build jobs." + exit 1 + fi + echo "Release assets:" + ls -la release + + # Emit the Tauri updater manifest (latest.json) ONLY when signed updater + # artifacts actually exist. As of v0.0.1 the repo has no `plugins.updater` + # section and ships `bundle.createUpdaterArtifacts: false`, so `tauri + # build` produces no `.sig` files and this step deliberately produces no + # latest.json (auto-update is opt-in; see docs/releasing.md). It becomes + # active automatically once the updater plugin is configured and + # TAURI_SIGNING_PRIVATE_KEY is set. It never hard-fails on absence. + - name: Generate latest.json updater manifest (opt-in) + shell: bash + env: + REPO: ${{ github.repository }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + set -euo pipefail + cd release + shopt -s nullglob + sigs=( *.sig ) + if [ "${#sigs[@]}" -eq 0 ]; then + echo "::notice::No updater .sig artifacts present; skipping latest.json. Auto-update is not enabled for this release (see docs/releasing.md § Enabling auto-update)." + exit 0 + fi + if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + echo "::error::Updater .sig files are present but TAURI_SIGNING_PRIVATE_KEY is unavailable in the release environment." + exit 1 + fi + VERSION="${VERSION}" TAG="${TAG}" REPO="${REPO}" node <<'EOF' + const fs = require('node:fs'); + const version = process.env.VERSION; + const tag = process.env.TAG; + const repo = process.env.REPO; + const base = `https://github.com/${repo}/releases/download/${tag}`; + + // Map an updater artifact filename to a Tauri updater platform key. + function platformFor(name) { + if (name.endsWith('.app.tar.gz')) { + return name.includes('aarch64') ? 'darwin-aarch64' : 'darwin-x86_64'; + } + if (name.endsWith('.msi.zip') || name.endsWith('.nsis.zip') || name.endsWith('.exe.zip')) { + return 'windows-x86_64'; + } + if (name.endsWith('.AppImage.tar.gz')) { + return 'linux-x86_64'; + } + return null; + } + + const files = fs.readdirSync('.'); + const platforms = {}; + for (const sig of files.filter((f) => f.endsWith('.sig'))) { + const artifact = sig.slice(0, -'.sig'.length); + if (!files.includes(artifact)) continue; + const key = platformFor(artifact); + if (!key) continue; + const signature = fs.readFileSync(sig, 'utf8').trim(); + if (!signature) { + throw new Error(`Updater signature is empty: ${sig}`); + } + if (platforms[key]) { + throw new Error(`Multiple updater artifacts map to ${key}`); + } + platforms[key] = { + signature, + url: `${base}/${encodeURIComponent(artifact)}`, + }; + } + + if (Object.keys(platforms).length === 0) { + // .sig files existed but none matched a known updater archive; do + // not write a hollow manifest. + console.log('::warning::Found .sig files but none matched a recognized updater archive; not writing latest.json.'); + process.exit(0); + } + + const manifest = { + version, + notes: `OpenCoven Chat ${version}`, + pub_date: new Date().toISOString(), + platforms, + }; + fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2) + '\n'); + console.log(fs.readFileSync('latest.json', 'utf8')); + EOF + + # Generate this after latest.json so every asset that can be published is + # covered by the checksum manifest, then verify those exact bytes in place. + - name: Generate and verify SHA256SUMS + shell: bash + run: | + set -euo pipefail + cd release + : > SHA256SUMS + shopt -s nullglob + for f in *; do + [ "$f" = "SHA256SUMS" ] && continue + shasum -a 256 "$f" >> SHA256SUMS + done + if [ ! -s SHA256SUMS ]; then + echo "::error::SHA256SUMS is empty; nothing to publish." + exit 1 + fi + echo "SHA256SUMS:" + cat SHA256SUMS + echo "Verifying checksums..." + shasum -a 256 -c SHA256SUMS + + # Publish with the gh CLI (pre-installed on the runner) so no additional + # third-party action needs pinning. --verify-tag makes gh refuse a tag + # that does not exist on the remote; the signature gate already ran in + # verify-tag. + - name: Publish GitHub release + if: needs.verify-tag.outputs.dry_run != 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::error::A GitHub Release already exists for ${TAG}; refuse to replace published assets. Cut a new tag or explicitly remove the existing release first." + exit 1 + fi + + args=( + "${TAG}" + --repo "${{ github.repository }}" + --title "OpenCoven Chat ${VERSION}" + --verify-tag + --draft + ) + if [ "${PRERELEASE}" = "true" ]; then + args+=(--prerelease) + fi + + if [ -f release/latest.json ]; then + updater_note='Auto-update clients consume `latest.json`.' + else + updater_note='This release has no `latest.json`; auto-update is not enabled.' + fi + notes="Automated release of OpenCoven Chat ${VERSION}. + + Verify downloads against \`SHA256SUMS\`: + + shasum -a 256 -c SHA256SUMS + + ${updater_note}" + + args+=(--notes "${notes}") + gh release create "${args[@]}" release/* + gh release edit "${TAG}" --repo "${{ github.repository }}" --draft=false + + - name: Summarize dry run + if: needs.verify-tag.outputs.dry_run == 'true' + shell: bash + run: | + set -euo pipefail + { + echo "### Dry run for \`${TAG}\`" + echo + echo "Tag verification, tagged-tree tests, all platform builds, smoke tests, and checksum verification passed." + echo + echo "No GitHub Release was created." + echo + echo '```' + cat release/SHA256SUMS + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a51f584b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenCoven + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3233cad9..ad0a8f8b 100644 --- a/README.md +++ b/README.md @@ -245,3 +245,14 @@ The Tauri capability schema at `src-tauri/gen/schemas/desktop-schema.json` is intentionally kept outside the ignore rules so the capability `$schema` can ship with fresh checkouts without granting permissions beyond the reviewed app and Cave adapter commands. + +## Releasing + +Releases are cut from signed `v*` tags by +[`.github/workflows/release.yml`](.github/workflows/release.yml), which verifies +the tag signature, checks the tag against every version manifest, builds and +smoke-tests bundles for macOS (Apple silicon and Intel), Windows, and Debian +Linux, publishes SHA-256 checksums, and creates the GitHub Release. + +The full process, the signing secrets, the dry-run rehearsal path, and the +failure playbook are documented in [`docs/releasing.md`](docs/releasing.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f7edea52 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,73 @@ +# Security Policy + +## Supported versions + +OpenCoven Chat is pre-1.0 and ships from a single release line. Only the most +recent published release receives security fixes. Older tags are not patched; +upgrade to the latest release before reporting an issue. + +| Version | Supported | +| ------- | ------------------ | +| Latest release | :white_check_mark: | +| Any earlier tag | :x: | + +## Scope + +Report vulnerabilities in the OpenCoven Chat desktop client, its packaged +release artifacts, or this repository's build and release automation here. +Vulnerabilities in the Coven Cave service, daemon, or server-side data handling +belong in +[`OpenCoven/coven-cave`](https://github.com/OpenCoven/coven-cave/security); +use that repository's private vulnerability reporting channel rather than a +public issue. + +OpenCoven Chat does not persist authenticated conversation bodies in +IndexedDB, `localStorage`, or `sessionStorage`. Canonical reads are held only in +bounded memory caches, while installation identity and credentials stay in the +operating system keyring. The explicit browser demos use canned data and do not +persist it. Reports showing that sensitive conversation data reaches browser +storage are in scope because that would violate the current security boundary. +Any future local read cache must document its encryption and storage guarantees +before shipping. + +## Reporting a vulnerability + +**Please do not open a public issue for security problems.** + +Report vulnerabilities privately through GitHub's private vulnerability +reporting: + +1. Go to the repository's **Security** tab. +2. Choose **Report a vulnerability** to open a private security advisory. + +This creates a private channel visible only to you and the maintainers. If you +cannot use GitHub advisories, contact the maintainers through the security +contact listed on the OpenCoven organization profile. + +Please include: + +- affected version or commit, +- platform (macOS / Windows / Linux), +- a description of the impact, +- reproduction steps or a proof of concept. + +## What to expect + +- We aim to acknowledge a report within **3 business days**. +- We will confirm the issue, assess severity, and keep you updated as we work + on a fix. +- Fixes ship in a new signed release; the advisory is published (crediting the + reporter unless anonymity is requested) once a fix is available. + +## Verifying releases + +Every release is built and published by the tag-triggered release workflow. +Release assets are covered by a `SHA256SUMS` file — verify downloads before +running them: + + shasum -a 256 -c SHA256SUMS + +Installers are code-signed where platform signing secrets are configured, and +auto-updates are verified against the Tauri updater signing key. Treat any +unsigned build, or any download whose checksum does not match `SHA256SUMS`, as +untrusted. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..48c8be12 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,373 @@ +# Releasing OpenCoven Chat + +This document is the runbook for cutting a public release of **OpenCoven Chat** +(`ai.opencoven.chat`). Releases are driven entirely by pushing a **signed, +annotated `v*` tag**. The `.github/workflows/release.yml` pipeline does the +rest: it verifies the tag, builds signed installers for macOS, Windows, and +Linux, checksums them, conditionally generates the updater manifest when +auto-update is configured, and publishes a GitHub Release. + +The first public release is **v0.0.1**. + +--- + +## 1. Release checklist + +Run through this in order. Every step is runnable as written. + +1. **Start from a clean, up-to-date `main`.** + + ```bash + git checkout main + git pull --ff-only + git status # must be clean + ``` + +2. **Bump the version in all three manifests to the same value.** The release + workflow fails if these disagree with the tag. Update: + + - `package.json` → `"version"` + - `src-tauri/tauri.conf.json` → `"version"` + - `src-tauri/Cargo.toml` → `[package] version` + + ```bash + # confirm they match, e.g. for 0.0.1 + node -p "require('./package.json').version" + node -p "require('./src-tauri/tauri.conf.json').version" + grep -m1 '^version' src-tauri/Cargo.toml + ``` + + Also confirm bundling is enabled in `src-tauri/tauri.conf.json` + (`bundle.active: true` with the platform targets), otherwise `tauri build` + produces no installers. + +3. **Land the bump through a pull request** (see branch protection in §7). Do + not tag off an unmerged branch. + +4. **Verify locally that the app builds and tests pass.** + + ```bash + corepack pnpm install --frozen-lockfile + corepack pnpm lint + corepack pnpm typecheck + corepack pnpm test:unit + corepack pnpm app:build # local sanity build + ``` + +5. **Make sure the updater signing keypair exists** and its public key is in + `src-tauri/tauri.conf.json` (see §4). Without it, auto-update cannot be + verified by clients. + +6. **Create a signed, annotated tag** on the merge commit (see §2): + + ```bash + git checkout main && git pull --ff-only + git tag -s v0.0.1 -m "OpenCoven Chat v0.0.1" + git verify-tag v0.0.1 + ``` + +7. **Push the tag.** This is the point of no return — it starts the release. + + ```bash + git push origin v0.0.1 + ``` + +8. **Watch the `Release` workflow.** It will: + - verify the tag is signed and version-consistent, + - rerun lint, typechecking, unit tests, and the web build against the tagged + tree, + - build installers on each platform and smoke-test them, + - generate `SHA256SUMS` and, only when signed updater artifacts exist, + `latest.json`, + - publish the GitHub Release (a **pre-release** if the tag has a suffix such + as `-rc.1` or `-beta`, or if its major version is `0`). + +9. **Verify the published release**: download an installer and check it against + the published checksums. + + ```bash + shasum -a 256 -c SHA256SUMS + ``` + +10. **Announce** the release per the usual OpenCoven channels. + +--- + +## 2. Creating and verifying a signed tag + +Releases require an **annotated** tag (`-a` / `-s`, not a lightweight tag) that +carries a **GPG or SSH signature**. The workflow rejects anything else. + +Create a signed tag: + +```bash +git tag -s v0.0.1 -m "OpenCoven Chat v0.0.1" +``` + +`-s` signs with your configured signing key. This machine signs with SSH: + +```bash +git config --get gpg.format # ssh +git config --get user.signingkey # your signing key +``` + +Verify before pushing: + +```bash +git verify-tag v0.0.1 +``` + +For **SSH-signed** tags, `git verify-tag` needs an allowed-signers file: + +```bash +git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +# each line: " namespaces=\"git\" ssh-ed25519 AAAA..." +``` + +In CI, GitHub's tag API must report the annotated tag signature as verified. +When the optional `TAG_ALLOWED_SIGNERS` secret is configured, the `verify-tag` +job independently runs `git verify-tag` for SSH-signed tags. A disagreement +blocks the release. Without that secret, GitHub's successful verification is +the authority; the workflow never treats an unverifiable tag as releasable. + +Delete a bad *local* tag before it is pushed: + +```bash +git tag -d v0.0.1 +``` + +## Dry-run rehearsal + +The workflow can be run manually against an existing signed tag. In +**Actions → Release → Run workflow**, select the branch containing the workflow +change, enter the tag, and leave `dry_run` at its default value of `true`. + +A dry run verifies the remote tag, checks out its exact commit, reruns the +release-relevant tests, builds and smoke-tests all four native targets, +generates and verifies checksums, and then stops without creating or modifying +a GitHub Release. Use this path to validate workflow changes and signing-secret +wiring before the next real tag push. + +--- + +## 3. Required secrets + +All signing secrets live in the GitHub deployment **environment** +`release-signing`. Configure them under **Settings → Environments → +release-signing**. When a platform signing secret is absent, the workflow still +runs and produces a clearly-marked **unsigned** build for that platform rather +than failing. `TAG_ALLOWED_SIGNERS` is optional because GitHub API verification +is always required; when present, it adds an independent local SSH check. + +| Secret | Purpose | Environment | +| ------ | ------- | ----------- | +| `APPLE_CERTIFICATE` | base64 of the Apple Developer ID `.p12` (macOS code signing) | `release-signing` | +| `APPLE_CERTIFICATE_PASSWORD` | password for the `.p12` | `release-signing` | +| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: OpenCoven (TEAMID)` | `release-signing` | +| `APPLE_ID` | Apple ID used for notarization | `release-signing` | +| `APPLE_PASSWORD` | app-specific password for that Apple ID | `release-signing` | +| `APPLE_TEAM_ID` | Apple Developer Team ID | `release-signing` | +| `WINDOWS_CERTIFICATE` | base64 of the Authenticode code-signing `.pfx` | `release-signing` | +| `WINDOWS_CERTIFICATE_PASSWORD` | password for the `.pfx` | `release-signing` | +| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key (signs updater artifacts) | `release-signing` | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | password for the updater private key | `release-signing` | +| `TAG_ALLOWED_SIGNERS` | allowed-signers file contents for verifying SSH-signed tags | `release-signing` | +| `GITHUB_TOKEN` | provided automatically by Actions; used to publish the release | n/a (built-in) | + +> The workflow references these **by name only**. Never commit any secret value +> to the repository. + +--- + +## 4. Auto-update status and how to enable it + +> **v0.0.1 ships WITHOUT auto-update.** This repository currently has **no +> `plugins.updater`** section and no updater public key, and +> `src-tauri/tauri.conf.json` sets `bundle.createUpdaterArtifacts: false`. +> Setting it to `true` without the plugin configured makes `tauri build` fail +> with: +> +> ``` +> failed to build bundler settings: failed to get updater configuration: +> plugins > updater doesn't exist +> ``` +> +> Because of this, the release workflow produces **no `.sig` files and no +> `latest.json`** today, and it does **not** fail on their absence — the +> updater manifest step is opt-in and simply skips (`::notice::`) when no +> updater artifacts exist. Users of v0.0.1 update by downloading a newer +> release manually. + +### Enabling auto-update (later release) + +When you are ready to ship auto-update, do this once and the release workflow +picks it up automatically: + +1. **Generate the updater keypair:** + + ```bash + corepack pnpm tauri signer generate -w ~/.tauri/opencoven-chat-updater.key + ``` + + This prints a **public key** and writes the **private key** to the path + given (with a passphrase you choose). + +2. **Add the updater plugin to `src-tauri/tauri.conf.json`** with the public + key and the release feed endpoint (this is what makes the config exist so + the bundler stops failing): + + ```jsonc + { + "plugins": { + "updater": { + "pubkey": "", + "endpoints": [ + "https://github.com/OpenCoven/chat/releases/latest/download/latest.json" + ] + } + } + } + ``` + +3. **Flip `bundle.createUpdaterArtifacts` to `true`** in + `src-tauri/tauri.conf.json`. + +4. **Store the private key** as the `TAURI_SIGNING_PRIVATE_KEY` secret and its + passphrase as `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, both in the + `release-signing` environment. + +After that, `tauri build` emits `.app.tar.gz` / `.nsis.zip` / `.AppImage.tar.gz` +archives plus `.sig` files, the release workflow assembles `latest.json` from +them, and clients begin auto-updating. + +Keep the private key offline and backed up. If it is lost, existing installs +can no longer verify updates and must be reinstalled from a fresh release built +with a new key. + +--- + +## 5. Artifacts produced + +The product name contains a space, so installer filenames look like +`OpenCoven Chat_0.0.1_aarch64.dmg`. Always quote artifact paths in scripts. + +Per release, the workflow publishes: + +- **macOS**: `OpenCoven Chat.app` (packaged) + `.dmg` for `aarch64` and + `x86_64`, signed and notarized when Apple secrets are present. The Intel + target runs natively on GitHub's `macos-15-intel` runner rather than + cross-compiling on Apple silicon. +- **Windows**: `.msi` and NSIS `.exe` for `x86_64`, Authenticode-signed when + the Windows secret is present. +- **Linux**: `.AppImage` and `.deb` for `x86_64`. +- **`SHA256SUMS`**: checksums for every asset. Verify with + `shasum -a 256 -c SHA256SUMS`. +- **`latest.json`**: the Tauri updater manifest — **only when auto-update is + enabled** (see §4). Absent for v0.0.1. + +--- + +## 6. Rollback procedure + +A release cannot be un-shipped from users who already downloaded it, but its +**discoverability and auto-update propagation can be stopped quickly**. Act in +this order. + +### 6.1 Stop auto-update propagation first + +This is the most urgent step, because the updater is the only channel that +pushes a bad build to users who did nothing. + +> **Not applicable to v0.0.1**, which ships without auto-update (no +> `latest.json`; see §4). If auto-update is still disabled, skip to §6.2. + +1. **Delete the bad release's `latest.json` asset.** This stops new clients + from fetching that manifest, although caches and clients that already read + it may still retain it. Do not point the manifest at an older version: + updater version checks do not provide a reliable downgrade path. +2. Prepare a **superseding release** (see 6.3) with a higher version. That is + the only reliable way to move auto-updaters forward. + +### 6.2 Mark the bad GitHub Release + +```bash +# Convert the release to a draft so it disappears from the Releases page: +gh release edit v0.0.1 --draft + +# or delete the release (keeps the tag unless you also delete it): +gh release delete v0.0.1 --yes +``` + +If you delete the release but keep the tag, the tag can still be referenced; +prefer marking it clearly: + +```bash +gh release edit v0.0.1 --prerelease --title "OpenCoven Chat v0.0.1 (WITHDRAWN — do not use)" +``` + +### 6.3 Delete or supersede the tag + +**Preferred: supersede.** Do not reuse a version number. Fix the defect, bump +to the next patch (e.g. `v0.0.2`), and cut a fresh signed release. Re-releasing +under the same tag breaks anyone who already has the old artifacts and +checksums. + +**If the tag must be removed** (e.g. it was pushed by mistake and no artifacts +were distributed): + +```bash +# delete the remote tag +git push --delete origin v0.0.1 +# delete it locally +git tag -d v0.0.1 +``` + +Deleting a tag that people may have already fetched is disruptive; only do it +immediately after a mistaken push, before distribution. + +### 6.4 Communicate the rollback + +- Edit the (withdrawn) release notes to state plainly that the version is + withdrawn, why, and which version to use instead. +- Post to the OpenCoven announcement channels used for the original release. +- If the issue is a security vulnerability, open/adjust a **private security + advisory** (see `SECURITY.md`) and publish it once the fix ships. + +### 6.5 Users who already auto-updated + +- They cannot be silently downgraded. Ship a **superseding release** with a + higher version and a fixed build; the updater moves them forward on next + check. +- If the bad build is actively harmful, provide clear manual-remediation + instructions in the release notes and advisory (e.g. download and install the + superseding version from the Releases page, verifying `SHA256SUMS`). +- Keep the last known-good installers available on their original release so + users can manually reinstall if needed. + +--- + +## 7. Branch protection (enable before the public release) + +> **`main` currently has _no_ branch protection.** The rules below are **not** +> applied by any automation in this repository and must be enabled by a +> repository admin in **Settings → Branches → Branch protection rules** before +> the public release. Do not attempt to apply them from a workflow. + +Recommended rules for `main`: + +- **Require a pull request before merging** (no direct pushes). +- **Require status checks to pass** before merging, and require branches to be + up to date. Required checks (job names from `.github/workflows/ci.yml` and + the conformance workflows): + - `Web checks` + - `Rust` + - `E2E` + - `Desktop build` + - `Contract canary` + - `Phase 1 real-authority conformance` +- **Require signed commits.** +- **Do not allow force pushes.** +- **Do not allow deletions.** + +These match the guarantees the release pipeline assumes: that what is tagged on +`main` has passed CI and is composed of signed, non-rewritten history. diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts new file mode 100644 index 00000000..38a41d0b --- /dev/null +++ b/src/release-workflow.test.ts @@ -0,0 +1,87 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const workflow = readFileSync(resolve(projectRoot, '.github/workflows/release.yml'), 'utf8'); +const releasingGuide = readFileSync(resolve(projectRoot, 'docs/releasing.md'), 'utf8'); +const securityPolicy = readFileSync(resolve(projectRoot, 'SECURITY.md'), 'utf8'); + +function job(name: string, nextName?: string): string { + const start = workflow.indexOf(` ${name}:`); + const end = nextName === undefined ? workflow.length : workflow.indexOf(` ${nextName}:`, start); + if (start < 0 || end < 0) { + throw new Error(`Unable to isolate release workflow job ${name}`); + } + return workflow.slice(start, end); +} + +describe('release workflow specification', () => { + test('supports safe rehearsals and verifies the exact remote tag', () => { + const verify = job('verify-tag', 'build'); + const build = job('build', 'publish'); + const publish = job('publish'); + + expect(workflow).toMatch(/workflow_dispatch:[\s\S]*?dry_run:[\s\S]*?default: true/); + expect(workflow).toContain( + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$', + ); + expect(verify).toContain('GitHub could not verify the signature'); + expect(verify).toContain('is annotated but carries no GPG/SSH signature'); + expect(verify).toContain(`git rev-parse "\${TAG}^{commit}"`); + expect(verify).toContain('moved after the workflow event'); + expect(verify).toContain('which is not reachable from origin/main'); + expect(verify.indexOf('actions/setup-node@')).toBeLessThan(verify.indexOf('node -p')); + expect(publish.indexOf('actions/setup-node@')).toBeLessThan(publish.indexOf("node <<'EOF'")); + expect(build).toContain(`ref: \${{ needs.verify-tag.outputs.sha }}`); + expect(publish).toContain(`ref: \${{ needs.verify-tag.outputs.sha }}`); + expect(workflow).toContain("if: needs.verify-tag.outputs.dry_run != 'true'"); + expect(workflow).toContain("if: needs.verify-tag.outputs.dry_run == 'true'"); + }); + + test('keeps release artifact and publication hardening in place', () => { + const verify = job('verify-tag', 'build'); + const build = job('build', 'publish'); + const publish = job('publish'); + + expect(verify).toContain('bundle?.active === true'); + for (const command of ['lint', 'typecheck', 'test:unit:normal', 'build']) { + expect(verify).toContain(`corepack pnpm ${command}`); + } + expect(verify).toContain('0.*|*-*) prerelease=true'); + expect(build).toContain('runner: macos-15-intel'); + expect(build).toContain('lipo -archs'); + expect(build).toContain(`dpkg-deb --field "\${deb}" Version`); + expect(build).toContain('dpkg-deb --contents'); + expect(build).toContain('minimum_size=$((1024 * 1024))'); + expect(build).toContain('certificateThumbprint'); + expect(build).toContain('Get-AuthenticodeSignature'); + expect(build).toContain("if ($signature.Status -ne 'Valid')"); + expect(publish).toMatch( + /runs-on: ubuntu-latest\s+timeout-minutes: 20\s+environment: release-signing/, + ); + expect(publish).toContain(`gh release view "\${TAG}"`); + expect(publish).toContain('--verify-tag'); + expect(publish).toContain('--draft'); + expect(publish).toContain(`gh release edit "\${TAG}"`); + expect(publish).toContain('Duplicate release asset name'); + expect(publish).toContain('Updater signature is empty'); + expect(publish.indexOf('Generate latest.json updater manifest')).toBeLessThan( + publish.indexOf('Generate and verify SHA256SUMS'), + ); + expect(workflow).not.toMatch(/^\s*-\s+run:\s+pnpm\b/m); + expect(workflow).not.toMatch(/^\s*pnpm exec tauri build/m); + }); + + test('documents conditional updates and the current storage boundary', () => { + expect(releasingGuide).toContain('conditionally generates the updater manifest'); + expect(releasingGuide).toContain('leave `dry_run` at its default value of `true`'); + expect(releasingGuide).not.toMatch(/^\s*pnpm\b/m); + expect(securityPolicy).not.toContain('currently `0.0.1`'); + expect(securityPolicy).toContain('## Scope'); + expect(securityPolicy).toContain('does not persist authenticated conversation bodies in'); + expect(securityPolicy).toContain('IndexedDB'); + }); +});