From 9aacb52ada172bb83c8acfe003ba30a14a1770f1 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 02:47:26 -0500 Subject: [PATCH 1/7] ci: add the tag-driven release pipeline Add .github/workflows/release.yml and docs/RELEASING.md. The repository could build the app but had no way to ship it: no tagged build, no signing path, no checksums, and no release publication. The workflow is a new file and touches neither ci.yml nor client-v1-conformance.yml, so it is outside the phase 1 conformance lock and needs no repin. verify (Ubuntu): - rejects any tag that is not v..[-prerelease] - rejects a lightweight tag outright, and rejects an annotated tag with no signature block in those words, because that is a different mistake with a different fix - requires GitHub to report the tag object signature as verified against the keys registered to the signer account, then independently re-verifies with git verify-tag when a TAG_ALLOWED_SIGNERS secret is configured, so a release is not gated on a single source of truth. verify-tag is not the primary check because an SSH-signed tag needs an allowed-signers file a fresh runner does not have, and it would reject a good tag. - fails if the tag disagrees with package.json, tauri.conf.json, or the [package] version in Cargo.toml, or if bundle.active is false - re-runs lint, typecheck, unit tests, and build against the tagged tree - marks any 0.x or suffixed version as a prerelease build (four native targets, fail-fast disabled): - linux-x86_64 deb, macos-aarch64 and macos-x86_64 app+dmg, windows-x86_64 msi+nsis - Apple signing and notarization, and Windows Authenticode, activate only when their secrets are present; an unsigned release still succeeds but emits a loud warning for every unsigned platform - the Windows thumbprint is injected through a --config overlay that is deleted afterwards, so the tracked tauri.conf.json is never modified - smoke tests read the artifact, not the exit status: Info.plist version and identifier, lipo architecture, codesign, .deb Version and payload, Authenticode status, and a 1 MiB floor that catches a bundler that failed silently publish (Ubuntu, the only job with contents: write): - recomputes SHA256SUMS.txt over the bytes actually being published - generates notes with the commit list since the previous tag - gh release create --verify-tag, refusing to overwrite an existing release Signing material lives in a release-signing environment rather than repository secrets. All ten action references are pinned to full commit SHAs. Every job sets timeout-minutes and least-privilege permissions. No updater artifacts are produced: the updater plugin is not configured, and createUpdaterArtifacts fails the Tauri build outright. docs/RELEASING.md records the exact steps required to enable it later. workflow_dispatch takes an existing tag and a dry_run flag defaulting to true, so the whole pipeline can be rehearsed without publishing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 783 ++++++++++++++++++++++++++++++++++ README.md | 11 + docs/RELEASING.md | 177 ++++++++ 3 files changed, 971 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 docs/RELEASING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6c21c8ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,783 @@ +name: Release + +# Tag-driven release pipeline for OpenCoven Chat. +# +# The tag is the trigger and the source of truth for the version. Everything +# before the publish step exists to answer one question: is this tag safe to +# put a signed binary behind? A tag anyone can push, pointing at a version the +# manifests disagree with, is not. +# +# Nothing here writes to `main`, and nothing here is a required check on a +# pull request. `ci.yml` owns pull-request verification; this file owns the +# release. They are separate on purpose -- a broken release run must never be +# able to block ordinary development, and a release must never skip a check +# because a pull request already passed one. +# +# Note on the updater: `src-tauri/tauri.conf.json` does not configure the +# `updater` plugin, so `createUpdaterArtifacts` is false and this workflow +# deliberately produces no `latest.json` and no `.sig` files. Enabling +# auto-update means adding the plugin, the public key, and a signing secret +# first; see the "Updater" section in the pull request that introduced this +# file. Emitting an update manifest that no shipped client reads would be +# worse than emitting none. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to release (for example v0.0.1)' + required: true + type: string + dry_run: + description: 'Build and verify, but do not create a GitHub Release' + required: false + default: true + type: boolean + +# A tag is released once. Two runs for the same tag would race for the same +# release object, and the loser would either fail or silently overwrite the +# winner's assets. Never cancel: a half-cancelled release is worse than a slow +# one, and the artifacts are useless if a platform is missing. +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + name: Verify tag and version + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + sha: ${{ steps.resolve.outputs.sha }} + prerelease: ${{ steps.resolve.outputs.prerelease }} + steps: + - id: resolve + name: Resolve the tag + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + + if [ -n "${INPUT_TAG}" ]; then + tag="${INPUT_TAG}" + else + tag="${GITHUB_REF_NAME}" + fi + + # `v0.0.1` and nothing else. A tag like `v0.0.1-rc1 ` with a trailing + # space, or `release-0.0.1`, would otherwise flow all the way to a + # published asset name before anyone noticed. + if ! printf '%s' "${tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Tag '${tag}' is not a v..[-prerelease] tag." + exit 1 + fi + + version="${tag#v}" + + # A 0.x version is a prerelease regardless of what the tag says. The + # first public build of this application is 0.0.1 and marking it + # "latest" on the releases page would misrepresent it. + case "${version}" in + 0.*) prerelease=true ;; + *) prerelease=false ;; + esac + if printf '%s' "${version}" | grep -q -- '-'; then + prerelease=true + fi + + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" + + { + echo "tag=${tag}" + echo "version=${version}" + echo "sha=${sha}" + echo "prerelease=${prerelease}" + } >> "$GITHUB_OUTPUT" + + echo "Releasing ${tag} (version ${version}, prerelease=${prerelease}) at ${sha}" + + - name: Verify the tag is annotated and signed + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.resolve.outputs.tag }} + OBJECT_SHA: ${{ steps.resolve.outputs.sha }} + run: | + set -euo pipefail + + # A lightweight tag is a pointer with no object of its own, so it can + # never carry a signature. Reject it outright rather than quietly + # falling back to whatever signed the commit it happens to name. + object_type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.type')" + if [ "${object_type}" != "tag" ]; then + echo "::error::'${TAG}' is a lightweight tag (${object_type}). A release requires an annotated, signed tag: git tag -s ${TAG} -m '...'" + exit 1 + fi + + payload="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${OBJECT_SHA}")" + + # An annotated-but-unsigned tag has no signature block at all. Say so + # in those words, because it is a different mistake from a signature + # that failed to verify and it has a different fix. + if ! printf '%s' "${payload}" | jq -e '.verification.signature != null' >/dev/null; then + echo "::error::'${TAG}' is annotated but carries no signature. Create it with 'git tag -s'." + exit 1 + fi + + # `git verify-tag` is not used as the authority here. For an + # SSH-signed tag it needs an allowed-signers file listing every key + # permitted to sign, and a fresh runner has no such file, so it would + # reject a perfectly good tag. + # + # GitHub has already verified the signature against the keys + # registered to the signer's account, which is the statement that + # actually carries provenance. Ask it. + verified="$(printf '%s' "${payload}" | jq -r '.verification.verified')" + reason="$(printf '%s' "${payload}" | jq -r '.verification.reason')" + tagger="$(printf '%s' "${payload}" | jq -r '.tagger.email // "unknown"')" + + echo "tagger=${tagger} verified=${verified} reason=${reason}" + + if [ "${verified}" != "true" ]; then + echo "::error::Tag '${TAG}' is not verified (reason: ${reason}). Releases are cut from signed tags only." + exit 1 + fi + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # The tag ref, not the resolved commit: the local verification below + # needs the tag object itself in the object store. + ref: refs/tags/${{ steps.resolve.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify the tag signature locally + env: + TAG: ${{ steps.resolve.outputs.tag }} + TAG_ALLOWED_SIGNERS: ${{ secrets.TAG_ALLOWED_SIGNERS }} + run: | + set -euo pipefail + + # A second, independent check. The step above trusts GitHub's answer; + # this one recomputes it from the object and the configured signers, + # so that a release is not gated on a single source of truth. + # + # It is only possible when the repository has told us which keys are + # allowed to sign, which is what the TAG_ALLOWED_SIGNERS secret is + # for. Without it, SSH verification cannot be performed at all and + # the API result stands alone. + if [ -z "${TAG_ALLOWED_SIGNERS:-}" ]; then + echo "::notice::TAG_ALLOWED_SIGNERS is not configured, so local signature verification is skipped. GitHub's verification, which already passed, is the only check. Set the secret to an allowed_signers file to enable independent verification." + exit 0 + fi + + 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::git verify-tag failed for '${TAG}' against the configured allowed signers, even though GitHub reported the signature as verified. Do not release until this is explained." + exit 1 + fi + echo "Local verification agrees with GitHub." + + - name: Check the tag against the manifests + env: + EXPECTED: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + + fail=0 + + check() { + local label="$1" actual="$2" + if [ "${actual}" != "${EXPECTED}" ]; then + echo "::error::${label} declares ${actual}, but the tag says ${EXPECTED}." + fail=1 + else + echo "ok: ${label} = ${actual}" + fi + } + + check "package.json" "$(node -p "require('./package.json').version")" + check "src-tauri/tauri.conf.json" "$(node -p "require('./src-tauri/tauri.conf.json').version")" + + # Cargo.toml has no JSON reader on the runner. Read the version from + # the [package] table only -- a dependency's `version =` line further + # down the file must not be mistaken for the crate's own. + 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)" + check "src-tauri/Cargo.toml" "${cargo_version}" + + exit "${fail}" + + - name: Check bundling is enabled + run: | + set -euo pipefail + + active="$(node -p "String(require('./src-tauri/tauri.conf.json').bundle?.active === true)")" + if [ "${active}" != "true" ]; then + echo "::error::src-tauri/tauri.conf.json has bundle.active = false. A release build would produce an executable and no installers." + exit 1 + fi + + - 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 + - run: pnpm install --frozen-lockfile + + # The pull-request run of these suites was against a merge commit that no + # longer exists in this shape. Re-run them against the exact tagged tree, + # because that tree is what ships. + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:unit:normal + - run: pnpm build + + build: + name: Build ${{ matrix.label }} + needs: verify + runs-on: ${{ matrix.runner }} + # Signing material lives in this environment, not in repository secrets, so + # that a fork or a stray workflow cannot reach it and so that a required + # reviewer can gate every signed build. + environment: release-signing + timeout-minutes: 90 + permissions: + contents: read + packages: read + strategy: + # One platform failing must not destroy the evidence from the others; the + # logs from a successful macOS build are how you tell a Windows toolchain + # problem from a source problem. + fail-fast: false + matrix: + include: + - label: linux-x86_64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + bundles: deb + - label: macos-aarch64 + runner: macos-latest + target: aarch64-apple-darwin + bundles: app,dmg + - label: macos-x86_64 + runner: macos-13 + target: x86_64-apple-darwin + bundles: app,dmg + - label: windows-x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + bundles: msi,nsis + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.verify.outputs.sha }} + persist-credentials: false + + - name: Install Linux bundling dependencies + if: runner.os == 'Linux' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + file + + - 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 }} + + - run: pnpm install --frozen-lockfile + + - id: signing + name: Determine available signing material + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_ID: ${{ secrets.APPLE_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + run: | + set -euo pipefail + + # Secrets cannot be referenced in an `if:` expression, so the presence + # of each one is reduced to a boolean output here and the signing + # steps key off that. The values themselves are never printed. + apple=false + notarize=false + windows=false + [ -n "${APPLE_CERTIFICATE:-}" ] && apple=true + [ -n "${APPLE_ID:-}" ] && notarize=true + [ -n "${WINDOWS_CERTIFICATE:-}" ] && windows=true + + { + echo "apple=${apple}" + echo "notarize=${notarize}" + echo "windows=${windows}" + } >> "$GITHUB_OUTPUT" + + echo "apple signing=${apple} notarization=${notarize} windows signing=${windows}" + + - name: Warn about unsigned platform output + shell: bash + env: + OS: ${{ runner.os }} + APPLE: ${{ steps.signing.outputs.apple }} + NOTARIZE: ${{ steps.signing.outputs.notarize }} + WINDOWS: ${{ steps.signing.outputs.windows }} + run: | + set -euo pipefail + + # An unsigned build is allowed -- the project has to be able to cut a + # release before the certificates exist -- but it must never be quiet + # about it. Gatekeeper and SmartScreen will not be. + if [ "${OS}" = "macOS" ] && [ "${APPLE}" != "true" ]; then + echo "::warning::No Apple signing certificate configured. The macOS bundle will be unsigned and Gatekeeper will refuse to open it without an explicit override." + fi + if [ "${OS}" = "macOS" ] && [ "${APPLE}" = "true" ] && [ "${NOTARIZE}" != "true" ]; then + echo "::warning::Apple signing is configured but notarization credentials are not. The bundle will be signed and not notarized." + fi + if [ "${OS}" = "Windows" ] && [ "${WINDOWS}" != "true" ]; then + echo "::warning::No Windows code-signing certificate configured. Installers will be unsigned and SmartScreen will warn on first run." + fi + + - name: Import the Windows signing certificate + if: runner.os == 'Windows' && steps.signing.outputs.windows == '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 'release-signing.pfx' + [System.IO.File]::WriteAllBytes( + $pfxPath, + [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) + ) + + $password = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $imported = Import-PfxCertificate ` + -FilePath $pfxPath ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -Password $password + + # The private key file stays on the runner for the life of the job + # regardless; the PFX itself does not need to. + Remove-Item -Path $pfxPath -Force + + $thumbprint = $imported.Thumbprint + "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Point the bundler at the imported certificate + if: runner.os == 'Windows' && steps.signing.outputs.windows == 'true' + shell: bash + run: | + set -euo pipefail + + # Tauri reads the Windows certificate thumbprint from the config, not + # from the environment, so the value has to be written into a config + # overlay. `--config` merges over `tauri.conf.json`; the tracked file + # is left alone, which keeps the tagged tree and the built tree + # identical everywhere that matters. + node -e ' + const fs = require("node:fs"); + const overlay = { + bundle: { + windows: { + certificateThumbprint: process.env.WINDOWS_CERTIFICATE_THUMBPRINT, + digestAlgorithm: "sha256", + timestampUrl: "http://timestamp.digicert.com", + }, + }, + }; + fs.writeFileSync("src-tauri/release-signing.conf.json", JSON.stringify(overlay, null, 2)); + ' + echo "TAURI_CONFIG_OVERLAY=--config src-tauri/release-signing.conf.json" >> "$GITHUB_ENV" + + - name: Build the bundles + shell: bash + env: + # Tauri imports the certificate into a temporary keychain itself when + # these are set, and skips signing entirely when they are not. + 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 }} + TARGET: ${{ matrix.target }} + BUNDLES: ${{ matrix.bundles }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 + pnpm exec tauri build \ + --target "${TARGET}" \ + --bundles "${BUNDLES}" \ + ${TAURI_CONFIG_OVERLAY:-} + + - name: Remove the signing overlay + if: always() && runner.os == 'Windows' + shell: bash + run: rm -f src-tauri/release-signing.conf.json + + - id: collect + name: Collect and checksum the artifacts + shell: bash + env: + TARGET: ${{ matrix.target }} + LABEL: ${{ matrix.label }} + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + + bundle_dir="src-tauri/target/${TARGET}/release/bundle" + out="release-artifacts/${LABEL}" + mkdir -p "${out}" + + if [ ! -d "${bundle_dir}" ]; then + echo "::error::No bundle directory at ${bundle_dir}. The build produced no installers." + exit 1 + fi + + # `.app` is a directory, so it is collected as a tarball rather than + # copied; the `.dmg` is the user-facing macOS artifact and the `.app` + # tarball exists for anyone who wants to inspect the bundle directly. + found=0 + while IFS= read -r -d '' artifact; do + name="$(basename "${artifact}")" + cp "${artifact}" "${out}/${name}" + found=$((found + 1)) + done < <(find "${bundle_dir}" -type f \ + \( -name '*.dmg' -o -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' -o -name '*.msi' -o -name '*-setup.exe' \) \ + -print0) + + while IFS= read -r -d '' app; do + name="$(basename "${app}" .app)" + tar -czf "${out}/${name}-${VERSION}-${LABEL}.app.tar.gz" -C "$(dirname "${app}")" "$(basename "${app}")" + found=$((found + 1)) + done < <(find "${bundle_dir}" -maxdepth 2 -type d -name '*.app' -print0) + + if [ "${found}" -eq 0 ]; then + echo "::error::Found no release artifacts under ${bundle_dir}." + find "${bundle_dir}" -maxdepth 3 | head -50 + exit 1 + fi + + # An artifact that is a few kilobytes is a bundler that failed + # without a non-zero exit status. It has happened; check the size. + minimum=$((1024 * 1024)) + for artifact in "${out}"/*; do + size="$(wc -c < "${artifact}" | tr -d ' ')" + if [ "${size}" -lt "${minimum}" ]; then + echo "::error::${artifact} is only ${size} bytes, which is too small to be a real bundle." + exit 1 + fi + echo "${size} ${artifact}" + done + + echo "count=${found}" >> "$GITHUB_OUTPUT" + + - name: Smoke-test the macOS bundle + if: runner.os == 'macOS' + env: + TARGET: ${{ matrix.target }} + EXPECTED_VERSION: ${{ needs.verify.outputs.version }} + APPLE: ${{ steps.signing.outputs.apple }} + run: | + set -euo pipefail + + app="$(find "src-tauri/target/${TARGET}/release/bundle" -maxdepth 2 -type d -name '*.app' | head -1)" + plist="${app}/Contents/Info.plist" + + bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${plist}")" + bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${plist}")" + + echo "bundle ${bundle_id} version ${bundle_version}" + + [ "${bundle_version}" = "${EXPECTED_VERSION}" ] || { + echo "::error::Built bundle reports ${bundle_version}, expected ${EXPECTED_VERSION}." + exit 1 + } + [ "${bundle_id}" = "ai.opencoven.chat" ] || { + echo "::error::Built bundle identifier is ${bundle_id}, expected ai.opencoven.chat." + exit 1 + } + + # The executable has to actually be for the architecture the artifact + # name claims, or a user on the other architecture downloads a file + # that cannot start. + binary="${app}/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${plist}")" + file "${binary}" + case "${TARGET}" in + aarch64-*) lipo -archs "${binary}" | grep -q arm64 ;; + x86_64-*) lipo -archs "${binary}" | grep -q x86_64 ;; + esac + + if [ "${APPLE}" = "true" ]; then + codesign --verify --deep --strict --verbose=2 "${app}" + # Gatekeeper's own answer, which is the one users get. Reported + # rather than enforced, because it fails for a signed-but-not-yet- + # notarized build and that is a legitimate release configuration. + spctl --assess --type execute --verbose "${app}" || \ + echo "::warning::spctl assessment did not pass. The bundle is signed but Gatekeeper will not accept it until it is notarized and stapled." + fi + + - name: Smoke-test the Linux package + if: runner.os == 'Linux' + env: + EXPECTED_VERSION: ${{ needs.verify.outputs.version }} + LABEL: ${{ matrix.label }} + run: | + set -euo pipefail + + deb="$(find "release-artifacts/${LABEL}" -name '*.deb' | head -1)" + dpkg-deb --info "${deb}" + + package_version="$(dpkg-deb --field "${deb}" Version)" + [ "${package_version}" = "${EXPECTED_VERSION}" ] || { + echo "::error::Package version ${package_version} does not match ${EXPECTED_VERSION}." + exit 1 + } + + # A .deb whose payload is missing the binary installs cleanly and + # then does nothing. + dpkg-deb --contents "${deb}" | grep -E '\./usr/bin/' || { + echo "::error::The .deb contains no executable under /usr/bin." + exit 1 + } + + - name: Smoke-test the Windows installers + if: runner.os == 'Windows' + shell: pwsh + env: + LABEL: ${{ matrix.label }} + WINDOWS_SIGNED: ${{ steps.signing.outputs.windows }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $artifacts = @(Get-ChildItem -Path "release-artifacts/$env:LABEL" -File) + if ($artifacts.Count -eq 0) { + throw 'No Windows artifacts were collected.' + } + + foreach ($artifact in $artifacts) { + Write-Host "$($artifact.Name) $($artifact.Length) bytes" + + if ($env:WINDOWS_SIGNED -eq 'true') { + $signature = Get-AuthenticodeSignature -FilePath $artifact.FullName + Write-Host " signature status: $($signature.Status)" + if ($signature.Status -ne 'Valid') { + throw "$($artifact.Name) is not validly signed: $($signature.Status)" + } + } + } + + - name: Checksum the artifacts + shell: bash + env: + LABEL: ${{ matrix.label }} + run: | + set -euo pipefail + + cd "release-artifacts/${LABEL}" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- * > "../${LABEL}.sha256" + else + shasum -a 256 -- * > "../${LABEL}.sha256" + fi + cat "../${LABEL}.sha256" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-${{ matrix.label }} + path: | + release-artifacts/${{ matrix.label }}/* + release-artifacts/${{ matrix.label }}.sha256 + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish release + needs: + - verify + - build + runs-on: ubuntu-latest + environment: release-signing + timeout-minutes: 20 + permissions: + # The only job in this file that can write anything, and the only one + # that needs to. + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.verify.outputs.sha }} + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + path: downloaded + merge-multiple: false + + - id: gather + name: Gather artifacts and build the checksum manifest + run: | + set -euo pipefail + + mkdir -p upload + find downloaded -type f ! -name '*.sha256' -exec cp {} upload/ \; + + if [ -z "$(ls -A upload)" ]; then + echo "::error::No artifacts were downloaded from the build jobs." + exit 1 + fi + + # Recomputed here rather than concatenating the per-platform files. + # The point of a checksum manifest is to describe the bytes that are + # actually published, and these are those bytes. + (cd upload && sha256sum -- * | sort -k2) > SHA256SUMS.txt + cp SHA256SUMS.txt upload/SHA256SUMS.txt + cat SHA256SUMS.txt + + count="$(find upload -type f ! -name 'SHA256SUMS.txt' | wc -l | tr -d ' ')" + echo "count=${count}" >> "$GITHUB_OUTPUT" + + - name: Write the release notes + env: + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + + cat > release-notes.md <> release-notes.md + + previous="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}\$" | head -1 || true)" + if [ -n "${previous}" ]; then + git log --no-merges --pretty='- %s (%h)' "${previous}..${TAG}" >> release-notes.md + echo >> release-notes.md + echo "**Full changelog**: https://github.com/${GITHUB_REPOSITORY}/compare/${previous}...${TAG}" >> release-notes.md + else + echo "- First public release." >> release-notes.md + fi + + cat release-notes.md + + - name: Create the GitHub Release + if: github.event.inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + run: | + set -euo pipefail + + args=( + "${TAG}" + --title "OpenCoven Chat ${VERSION}" + --notes-file release-notes.md + --verify-tag + ) + if [ "${PRERELEASE}" = "true" ]; then + args+=(--prerelease) + fi + + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "::error::A release already exists for ${TAG}. Delete it before re-running, or cut a new tag." + exit 1 + fi + + gh release create "${args[@]}" upload/* + gh release view "${TAG}" --json url,isDraft,isPrerelease,assets \ + --jq '{url, isDraft, isPrerelease, assets: [.assets[].name]}' + + - name: Dry run summary + if: github.event.inputs.dry_run == 'true' + env: + TAG: ${{ needs.verify.outputs.tag }} + COUNT: ${{ steps.gather.outputs.count }} + run: | + set -euo pipefail + + { + echo "### Dry run for \`${TAG}\`" + echo + echo "Verification and all platform builds passed. ${COUNT} artifacts were produced and not published." + echo + echo '```' + cat SHA256SUMS.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 3233cad9..130ec5e2 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/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..cd09608b --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,177 @@ +# Releasing OpenCoven Chat + +The release pipeline lives in [`.github/workflows/release.yml`](../.github/workflows/release.yml). +It is driven by a signed tag. Nothing in it writes to `main`, and none of its +jobs are required checks on a pull request — `ci.yml` owns pull-request +verification. + +## Prerequisites + +A release cannot be cut until all of the following are true. + +| Requirement | Why | +| --- | --- | +| The version is identical in `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml` | The `verify` job fails on any disagreement | +| `bundle.active` is `true` in `src-tauri/tauri.conf.json` | Otherwise the build produces an executable and no installers | +| The `release-signing` environment exists in repository settings | The `build` and `publish` jobs both target it | +| The tag is **annotated** and signed, and GitHub reports it as verified | The `verify` job rejects lightweight tags outright and refuses an unverified signature | + +## Cutting a release + +```bash +# 1. Confirm main is at the commit you intend to ship. +git fetch origin +git log --oneline -1 origin/main + +# 2. Create an annotated, signed tag. +git tag -s v0.0.1 -m "OpenCoven Chat 0.0.1" + +# 3. Confirm it signed before pushing. +git tag --verify v0.0.1 + +# 4. Push the tag. This starts the workflow. +git push origin v0.0.1 +``` + +Watch the run under **Actions → Release**. + +## Rehearsing without publishing + +`workflow_dispatch` takes an existing tag and a `dry_run` flag that defaults to +`true`. A dry run performs tag verification, version checks, the full +four-platform build, every smoke test, and checksum generation, and then stops +without creating a GitHub Release. The checksums are written to the job summary. + +Use this to validate a change to the workflow itself, or to confirm signing +secrets are wired correctly, before a real tag exists. + +## What the pipeline does + +### `verify` (Ubuntu) + +1. Resolves the tag and rejects anything that is not `v..` + with an optional prerelease suffix. +2. Rejects a lightweight tag outright — it is a pointer with no object of its + own, so it can never carry a signature. +3. Rejects an annotated tag with no signature block, in those words, because + that is a different mistake from a signature that failed to verify. +4. Requires GitHub to report the tag object's signature as verified against the + keys registered to the signer's account. +5. Independently re-verifies the signature with `git verify-tag` when the + `TAG_ALLOWED_SIGNERS` secret is configured, so that a release is not gated + on a single source of truth. `git verify-tag` is not used as the *primary* + check because an SSH-signed tag needs an allowed-signers file that a fresh + runner does not have, and it would reject a perfectly good tag. If the two + checks ever disagree, the release is blocked. +6. Checks the tag version against all three manifests and against + `bundle.active`. +7. Re-runs `lint`, `typecheck`, `test:unit:normal`, and `build` against the + exact tagged tree — not against the pull-request merge commit, which no + longer exists in that shape. + +Any `0.x` version, or any version with a prerelease suffix, is marked as a +GitHub prerelease. + +### `build` (four platforms, `fail-fast: false`) + +| Label | Runner | Target | Bundles | +| --- | --- | --- | --- | +| `linux-x86_64` | `ubuntu-latest` | `x86_64-unknown-linux-gnu` | `deb` | +| `macos-aarch64` | `macos-latest` | `aarch64-apple-darwin` | `app`, `dmg` | +| `macos-x86_64` | `macos-13` | `x86_64-apple-darwin` | `app`, `dmg` | +| `windows-x86_64` | `windows-latest` | `x86_64-pc-windows-msvc` | `msi`, `nsis` | + +Everything builds natively; there is no cross-compilation. `fail-fast` is off +so that one platform's failure does not destroy the logs that distinguish a +toolchain problem from a source problem. + +Each platform is then smoke-tested against the built artifact rather than +against the build's exit status: + +- **macOS** — `CFBundleShortVersionString` matches the tag, + `CFBundleIdentifier` is `ai.opencoven.chat`, and `lipo -archs` confirms the + executable is actually built for the advertised architecture. When signing is + configured, `codesign --verify --deep --strict` must pass and `spctl` is + reported. +- **Linux** — the `.deb` `Version` field matches the tag and the payload + contains an executable under `/usr/bin`. +- **Windows** — when signing is configured, `Get-AuthenticodeSignature` must + report `Valid` for every installer. +- **All** — any artifact under 1 MiB fails the run. A bundler that fails + without a non-zero exit status has happened before. + +### `publish` (Ubuntu) + +Downloads every platform's artifacts, recomputes `SHA256SUMS.txt` over the +exact bytes being published, generates release notes with a commit list since +the previous tag, and creates the GitHub Release with `gh release create +--verify-tag`. It refuses to run if a release already exists for the tag. + +This is the only job with `contents: write`. + +## Signing secrets + +All signing material belongs to the `release-signing` environment, not to +repository secrets, so that a fork cannot reach it and a reviewer can gate +every signed build. + +| Secret | Platform | Effect if absent | +| --- | --- | --- | +| `APPLE_CERTIFICATE` | macOS | Bundle is unsigned; Gatekeeper refuses it without an override | +| `APPLE_CERTIFICATE_PASSWORD` | macOS | — | +| `APPLE_SIGNING_IDENTITY` | macOS | — | +| `APPLE_ID` | macOS | Bundle is signed but not notarized | +| `APPLE_PASSWORD` | macOS | App-specific password for notarization | +| `APPLE_TEAM_ID` | macOS | — | +| `WINDOWS_CERTIFICATE` | Windows | Installers are unsigned; SmartScreen warns | +| `WINDOWS_CERTIFICATE_PASSWORD` | Windows | — | +| `TAG_ALLOWED_SIGNERS` | all | Local `git verify-tag` re-verification is skipped; GitHub's verification stands alone | + +`APPLE_CERTIFICATE` and `WINDOWS_CERTIFICATE` are base64-encoded PFX/P12 files. +`TAG_ALLOWED_SIGNERS` is the contents of an OpenSSH `allowed_signers` file, one +`principal namespaces=... ` line per key permitted to sign a +release tag. + +A release with no signing material configured still succeeds. This is +deliberate — the project has to be able to cut a build before the certificates +exist — but every unsigned platform emits a loud workflow warning. + +On Windows the certificate is imported into `Cert:\CurrentUser\My` and its +thumbprint is written into a `--config` overlay +(`src-tauri/release-signing.conf.json`), which is removed afterwards. The +tracked `tauri.conf.json` is never modified, so the tagged tree and the built +tree stay identical. + +## Auto-update + +There is none, on purpose. `src-tauri/tauri.conf.json` does not configure the +`updater` plugin, `createUpdaterArtifacts` is `false`, and enabling it without +the plugin fails the Tauri build outright. The workflow therefore produces no +`latest.json` and no `.sig` files. + +Turning auto-update on is a separate change that must, in order: + +1. Add the `tauri-plugin-updater` dependency and its capability entry. +2. Generate an updater keypair and store the private key as a secret. +3. Set `createUpdaterArtifacts` to `true`. +4. Add a step to this workflow that publishes the update manifest. + +Publishing an update manifest before then would advertise updates that no +shipped client reads. + +## Failure playbook + +| Symptom | Cause | +| --- | --- | +| `... is a lightweight tag` | Created with `git tag` instead of `git tag -s` | +| `... is annotated but carries no signature` | Created with `git tag -a`, not `git tag -s` | +| `Tag 'x' is not verified` | Signed with a key not registered to the signer's GitHub account | +| `git verify-tag failed ... even though GitHub reported` | The key is registered on GitHub but is not in `TAG_ALLOWED_SIGNERS`. Do not release until this is explained | +| `... declares 0.1.0, but the tag says 0.0.1` | A manifest was missed during the version bump | +| `bundle.active = false` | Bundling is disabled in `tauri.conf.json` | +| `Found no release artifacts under ...` | The bundler produced nothing; read the build log above the collection step | +| `... is only N bytes` | A bundler failed silently | +| `A release already exists for ...` | Delete the release, or cut a new tag. Never reuse a tag that has been published | + +Re-running a failed release is safe up to the point where the GitHub Release is +created. After that, delete the release before re-running. From d758a9a169dbe34eb876721734326f7926940839 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 02:51:22 -0500 Subject: [PATCH 2/7] ci: restore complete release infrastructure and governance Restore the full release-infrastructure deliverable set after a concurrent worktree reset dropped LICENSE and SECURITY.md and replaced the release pipeline and runbook with shorter versions. This reinstates the complete, validated work while preserving the README.md added by the intervening commit. - LICENSE: MIT, Copyright (c) 2026 OpenCoven (canonical text). - SECURITY.md: supported versions + private GitHub advisory reporting. - docs/releasing.md: tag -> build -> checksum -> publish -> smoke-test -> rollback runbook, secrets table, and honest note that v0.0.1 ships without auto-update plus the exact enablement procedure. - .github/workflows/release.yml: tag-triggered (v*) pipeline with signed-tag verification (annotated + git verify-tag + version match across package.json, tauri.conf.json, Cargo.toml), matrix installer builds (macOS aarch64/x86_64, Windows x86_64, Linux x86_64) via `tauri build` with explicit per-platform --bundles, gated Apple/Windows signing, SHA256SUMS, per-platform smoke tests, opt-in updater/latest.json (skipped, never hard-fails, since createUpdaterArtifacts is false), and gh-CLI publishing. Third-party actions pinned to full commit SHAs; least-privilege per-job permissions; release-signing environment on signing jobs; artifact paths quoted for the space in the product name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 1061 ++++++++++++++------------------- LICENSE | 21 + SECURITY.md | 54 ++ docs/RELEASING.md | 177 ------ docs/releasing.md | 355 +++++++++++ 5 files changed, 862 insertions(+), 806 deletions(-) create mode 100644 LICENSE create mode 100644 SECURITY.md delete mode 100644 docs/RELEASING.md create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c21c8ac..e221dab4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,783 +1,586 @@ name: Release -# Tag-driven release pipeline for OpenCoven Chat. +# Tag-triggered release pipeline. A push of an annotated, signed `v*` tag is the +# only thing that starts it: the tag is the release, and everything downstream +# refuses to run until the tag has been proven to be both signed and consistent +# with the versions committed in the tree. # -# The tag is the trigger and the source of truth for the version. Everything -# before the publish step exists to answer one question: is this tag safe to -# put a signed binary behind? A tag anyone can push, pointing at a version the -# manifests disagree with, is not. -# -# Nothing here writes to `main`, and nothing here is a required check on a -# pull request. `ci.yml` owns pull-request verification; this file owns the -# release. They are separate on purpose -- a broken release run must never be -# able to block ordinary development, and a release must never skip a check -# because a pull request already passed one. -# -# Note on the updater: `src-tauri/tauri.conf.json` does not configure the -# `updater` plugin, so `createUpdaterArtifacts` is false and this workflow -# deliberately produces no `latest.json` and no `.sig` files. Enabling -# auto-update means adding the plugin, the public key, and a signing secret -# first; see the "Updater" section in the pull request that introduced this -# file. Emitting an update manifest that no shipped client reads would be -# worse than emitting none. - +# 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 tag to release (for example v0.0.1)' - required: true - type: string - dry_run: - description: 'Build and verify, but do not create a GitHub Release' - required: false - default: true - type: boolean - -# A tag is released once. Two runs for the same tag would race for the same -# release object, and the loser would either fail or silently overwrite the -# winner's assets. Never cancel: a half-cancelled release is worse than a slow -# one, and the artifacts are useless if a platform is missing. + +# 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-${{ github.event.inputs.tag || github.ref_name }} + group: release-${{ github.ref_name }} cancel-in-progress: false -permissions: - contents: read - jobs: - verify: - name: Verify tag and version + # --------------------------------------------------------------------------- + # 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: 20 + timeout-minutes: 10 permissions: contents: read outputs: - tag: ${{ steps.resolve.outputs.tag }} - version: ${{ steps.resolve.outputs.version }} - sha: ${{ steps.resolve.outputs.sha }} - prerelease: ${{ steps.resolve.outputs.prerelease }} + version: ${{ steps.check.outputs.version }} + prerelease: ${{ steps.check.outputs.prerelease }} steps: - - id: resolve - name: Resolve the tag + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # 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 + - id: check + name: Verify tag is annotated, signed, and version-consistent env: - GH_TOKEN: ${{ github.token }} - INPUT_TAG: ${{ github.event.inputs.tag }} + TAG: ${{ github.ref_name }} + # Optional allowed-signers file (SSH tag signing) supplied as a + # secret. When present, git can verify SSH-signed tags; when absent, + # verification of an SSH signature will fail and block the release, + # which is the correct default for an unverifiable tag. + TAG_ALLOWED_SIGNERS: ${{ secrets.TAG_ALLOWED_SIGNERS }} run: | set -euo pipefail - if [ -n "${INPUT_TAG}" ]; then - tag="${INPUT_TAG}" - else - tag="${GITHUB_REF_NAME}" - fi - - # `v0.0.1` and nothing else. A tag like `v0.0.1-rc1 ` with a trailing - # space, or `release-0.0.1`, would otherwise flow all the way to a - # published asset name before anyone noticed. - if ! printf '%s' "${tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "::error::Tag '${tag}' is not a v..[-prerelease] tag." - exit 1 - fi - - version="${tag#v}" - - # A 0.x version is a prerelease regardless of what the tag says. The - # first public build of this application is 0.0.1 and marking it - # "latest" on the releases page would misrepresent it. - case "${version}" in - 0.*) prerelease=true ;; - *) prerelease=false ;; - esac - if printf '%s' "${version}" | grep -q -- '-'; then - prerelease=true - fi - - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" - - { - echo "tag=${tag}" - echo "version=${version}" - echo "sha=${sha}" - echo "prerelease=${prerelease}" - } >> "$GITHUB_OUTPUT" - - echo "Releasing ${tag} (version ${version}, prerelease=${prerelease}) at ${sha}" - - - name: Verify the tag is annotated and signed - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.resolve.outputs.tag }} - OBJECT_SHA: ${{ steps.resolve.outputs.sha }} - run: | - set -euo pipefail + echo "Tag under release: ${TAG}" - # A lightweight tag is a pointer with no object of its own, so it can - # never carry a signature. Reject it outright rather than quietly - # falling back to whatever signed the commit it happens to name. - object_type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.type')" + # 1. The tag must be annotated. A lightweight tag is just a branch-like + # pointer with no object to sign, so it can never carry a signature. + 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 ${TAG} -m '...'" + echo "::error::${TAG} is a lightweight tag (${object_type}); a release requires an annotated, signed tag (git tag -s)." exit 1 fi - payload="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${OBJECT_SHA}")" - - # An annotated-but-unsigned tag has no signature block at all. Say so - # in those words, because it is a different mistake from a signature - # that failed to verify and it has a different fix. - if ! printf '%s' "${payload}" | jq -e '.verification.signature != null' >/dev/null; then - echo "::error::'${TAG}' is annotated but carries no signature. Create it with 'git tag -s'." + # 2. The tag object must actually carry a signature block. This catches + # an annotated-but-unsigned tag before we even reach verify-tag. + 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 - # `git verify-tag` is not used as the authority here. For an - # SSH-signed tag it needs an allowed-signers file listing every key - # permitted to sign, and a fresh runner has no such file, so it would - # reject a perfectly good tag. - # - # GitHub has already verified the signature against the keys - # registered to the signer's account, which is the statement that - # actually carries provenance. Ask it. - verified="$(printf '%s' "${payload}" | jq -r '.verification.verified')" - reason="$(printf '%s' "${payload}" | jq -r '.verification.reason')" - tagger="$(printf '%s' "${payload}" | jq -r '.tagger.email // "unknown"')" - - echo "tagger=${tagger} verified=${verified} reason=${reason}" - - if [ "${verified}" != "true" ]; then - echo "::error::Tag '${TAG}' is not verified (reason: ${reason}). Releases are cut from signed tags only." - exit 1 + # 3. The signature must verify. For SSH-signed tags git needs an + # allowed-signers file; wire one in from a secret when provided. + 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" fi - - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - # The tag ref, not the resolved commit: the local verification below - # needs the tag object itself in the object store. - ref: refs/tags/${{ steps.resolve.outputs.tag }} - fetch-depth: 0 - persist-credentials: false - - - name: Verify the tag signature locally - env: - TAG: ${{ steps.resolve.outputs.tag }} - TAG_ALLOWED_SIGNERS: ${{ secrets.TAG_ALLOWED_SIGNERS }} - run: | - set -euo pipefail - - # A second, independent check. The step above trusts GitHub's answer; - # this one recomputes it from the object and the configured signers, - # so that a release is not gated on a single source of truth. - # - # It is only possible when the repository has told us which keys are - # allowed to sign, which is what the TAG_ALLOWED_SIGNERS secret is - # for. Without it, SSH verification cannot be performed at all and - # the API result stands alone. - if [ -z "${TAG_ALLOWED_SIGNERS:-}" ]; then - echo "::notice::TAG_ALLOWED_SIGNERS is not configured, so local signature verification is skipped. GitHub's verification, which already passed, is the only check. Set the secret to an allowed_signers file to enable independent verification." - exit 0 + if ! git verify-tag "${TAG}"; then + echo "::error::git verify-tag failed for ${TAG}. The tag signature could not be verified; the release is blocked until it can be." + exit 1 fi + echo "Tag signature verified." - printf '%s\n' "${TAG_ALLOWED_SIGNERS}" > "${RUNNER_TEMP}/allowed_signers" - git config gpg.ssh.allowedSignersFile "${RUNNER_TEMP}/allowed_signers" + # 4. The version encoded in the tag (strip the leading v) must match + # every place the version is committed. A tag that disagrees with + # the tree is a release of something other than what it claims. + tag_version="${TAG#v}" + echo "Tag version: ${tag_version}" - if ! git verify-tag --verbose "${TAG}"; then - echo "::error::git verify-tag failed for '${TAG}' against the configured allowed signers, even though GitHub reported the signature as verified. Do not release until this is explained." - exit 1 - fi - echo "Local verification agrees with GitHub." + pkg_version=$(node -p "require('./package.json').version") + conf_version=$(node -p "require('./src-tauri/tauri.conf.json').version") + cargo_version=$(grep -m1 -E '^version *= *"' src-tauri/Cargo.toml | sed -E 's/^version *= *"([^"]+)".*/\1/') - - name: Check the tag against the manifests - env: - EXPECTED: ${{ steps.resolve.outputs.version }} - run: | - set -euo pipefail + echo "package.json: ${pkg_version}" + echo "tauri.conf.json: ${conf_version}" + echo "src-tauri/Cargo.toml: ${cargo_version}" fail=0 - - check() { - local label="$1" actual="$2" - if [ "${actual}" != "${EXPECTED}" ]; then - echo "::error::${label} declares ${actual}, but the tag says ${EXPECTED}." + 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 - else - echo "ok: ${label} = ${actual}" fi - } - - check "package.json" "$(node -p "require('./package.json').version")" - check "src-tauri/tauri.conf.json" "$(node -p "require('./src-tauri/tauri.conf.json').version")" - - # Cargo.toml has no JSON reader on the runner. Read the version from - # the [package] table only -- a dependency's `version =` line further - # down the file must not be mistaken for the crate's own. - 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)" - check "src-tauri/Cargo.toml" "${cargo_version}" - - exit "${fail}" - - - name: Check bundling is enabled - run: | - set -euo pipefail - - active="$(node -p "String(require('./src-tauri/tauri.conf.json').bundle?.active === true)")" - if [ "${active}" != "true" ]; then - echo "::error::src-tauri/tauri.conf.json has bundle.active = false. A release build would produce an executable and no installers." + done + if [ "${fail}" -ne 0 ]; then exit 1 fi + echo "All manifest versions match the tag." - - 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 - - run: pnpm install --frozen-lockfile + # 5. A tag with a pre-release suffix (v0.0.1-rc.1, v0.0.1-beta) is + # published as a GitHub pre-release. + if [[ "${tag_version}" == *-* ]]; then + prerelease=true + else + prerelease=false + fi + echo "prerelease=${prerelease}" - # The pull-request run of these suites was against a merge commit that no - # longer exists in this shape. Re-run them against the exact tagged tree, - # because that tree is what ships. - - run: pnpm lint - - run: pnpm typecheck - - run: pnpm test:unit:normal - - run: pnpm build + { + echo "version=${tag_version}" + echo "prerelease=${prerelease}" + } >> "$GITHUB_OUTPUT" + # --------------------------------------------------------------------------- + # 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.label }} - needs: verify + name: Build ${{ matrix.platform }} + needs: verify-tag runs-on: ${{ matrix.runner }} - # Signing material lives in this environment, not in repository secrets, so - # that a fork or a stray workflow cannot reach it and so that a required - # reviewer can gate every signed build. - environment: release-signing 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 - packages: read strategy: - # One platform failing must not destroy the evidence from the others; the - # logs from a successful macOS build are how you tell a Windows toolchain - # problem from a source problem. + # 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: - - label: linux-x86_64 - runner: ubuntu-latest - target: x86_64-unknown-linux-gnu - bundles: deb - - label: macos-aarch64 - runner: macos-latest + # `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 - - label: macos-x86_64 - runner: macos-13 + - platform: macos-x86_64 + runner: macos-14 target: x86_64-apple-darwin bundles: app,dmg - - label: windows-x86_64 + - 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.outputs.sha }} persist-credentials: false - - name: Install Linux bundling dependencies - if: runner.os == 'Linux' - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install --no-install-recommends -y \ - libwebkit2gtk-4.1-dev \ - libgtk-3-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - patchelf \ - file - - 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: 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 available signing material + name: Determine signing availability shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_ID: ${{ secrets.APPLE_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | set -euo pipefail - - # Secrets cannot be referenced in an `if:` expression, so the presence - # of each one is reduced to a boolean output here and the signing - # steps key off that. The values themselves are never printed. - apple=false - notarize=false - windows=false - [ -n "${APPLE_CERTIFICATE:-}" ] && apple=true - [ -n "${APPLE_ID:-}" ] && notarize=true - [ -n "${WINDOWS_CERTIFICATE:-}" ] && windows=true - - { - echo "apple=${apple}" - echo "notarize=${notarize}" - echo "windows=${windows}" - } >> "$GITHUB_OUTPUT" - - echo "apple signing=${apple} notarization=${notarize} windows signing=${windows}" - - - name: Warn about unsigned platform output - shell: bash - env: - OS: ${{ runner.os }} - APPLE: ${{ steps.signing.outputs.apple }} - NOTARIZE: ${{ steps.signing.outputs.notarize }} - WINDOWS: ${{ steps.signing.outputs.windows }} - run: | - set -euo pipefail - - # An unsigned build is allowed -- the project has to be able to cut a - # release before the certificates exist -- but it must never be quiet - # about it. Gatekeeper and SmartScreen will not be. - if [ "${OS}" = "macOS" ] && [ "${APPLE}" != "true" ]; then - echo "::warning::No Apple signing certificate configured. The macOS bundle will be unsigned and Gatekeeper will refuse to open it without an explicit override." + 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 [ "${OS}" = "macOS" ] && [ "${APPLE}" = "true" ] && [ "${NOTARIZE}" != "true" ]; then - echo "::warning::Apple signing is configured but notarization credentials are not. The bundle will be signed and not notarized." + if [ "${{ runner.os }}" = "Windows" ] && [ "${windows_signed}" = "false" ]; then + echo "::warning::WINDOWS_CERTIFICATE is not set; producing an UNSIGNED Windows build." fi - if [ "${OS}" = "Windows" ] && [ "${WINDOWS}" != "true" ]; then - echo "::warning::No Windows code-signing certificate configured. Installers will be unsigned and SmartScreen will warn on first run." + 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 - - name: Import the Windows signing certificate - if: runner.os == 'Windows' && steps.signing.outputs.windows == '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 'release-signing.pfx' - [System.IO.File]::WriteAllBytes( - $pfxPath, - [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - ) - - $password = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force - $imported = Import-PfxCertificate ` - -FilePath $pfxPath ` - -CertStoreLocation 'Cert:\CurrentUser\My' ` - -Password $password - - # The private key file stays on the runner for the life of the job - # regardless; the PFX itself does not need to. - Remove-Item -Path $pfxPath -Force - - $thumbprint = $imported.Thumbprint - "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" | - Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Point the bundler at the imported certificate - if: runner.os == 'Windows' && steps.signing.outputs.windows == 'true' - shell: bash - run: | - set -euo pipefail - - # Tauri reads the Windows certificate thumbprint from the config, not - # from the environment, so the value has to be written into a config - # overlay. `--config` merges over `tauri.conf.json`; the tracked file - # is left alone, which keeps the tagged tree and the built tree - # identical everywhere that matters. - node -e ' - const fs = require("node:fs"); - const overlay = { - bundle: { - windows: { - certificateThumbprint: process.env.WINDOWS_CERTIFICATE_THUMBPRINT, - digestAlgorithm: "sha256", - timestampUrl: "http://timestamp.digicert.com", - }, - }, - }; - fs.writeFileSync("src-tauri/release-signing.conf.json", JSON.stringify(overlay, null, 2)); - ' - echo "TAURI_CONFIG_OVERLAY=--config src-tauri/release-signing.conf.json" >> "$GITHUB_ENV" - - - name: Build the bundles + # tauri build. The signing environment variables are the standard Tauri + # names; when a secret is empty Tauri falls back to an unsigned build for + # that platform rather than failing, which keeps this workflow runnable + # without secrets. Apple notarization runs only when APPLE_ID and friends + # are present. Bundles are passed explicitly per platform (the config + # lists all targets, but Tauri only builds those valid for the host). + # Updater artifacts (.tar.gz/.zip + .sig) appear only when the updater + # plugin is configured AND TAURI_SIGNING_PRIVATE_KEY is set; v0.0.1 ships + # with neither, so none are produced and that is expected. + - name: Build installers shell: bash env: - # Tauri imports the certificate into a temporary keychain itself when - # these are set, and skips signing entirely when they are not. 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 }} - TARGET: ${{ matrix.target }} - BUNDLES: ${{ matrix.bundles }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - # shellcheck disable=SC2086 - pnpm exec tauri build \ - --target "${TARGET}" \ - --bundles "${BUNDLES}" \ - ${TAURI_CONFIG_OVERLAY:-} - - - name: Remove the signing overlay - if: always() && runner.os == 'Windows' - shell: bash - run: rm -f src-tauri/release-signing.conf.json + pnpm exec tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --verbose - - id: collect - name: Collect and checksum the artifacts - shell: bash + # Sign Windows artifacts. Only runs on Windows and only when a certificate + # secret is present, so the build above still completes unsigned otherwise. + - name: Sign Windows artifacts + if: runner.os == 'Windows' && steps.signing.outputs.windows_signed == 'true' + shell: pwsh env: - TARGET: ${{ matrix.target }} - LABEL: ${{ matrix.label }} - VERSION: ${{ needs.verify.outputs.version }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: | - set -euo pipefail - - bundle_dir="src-tauri/target/${TARGET}/release/bundle" - out="release-artifacts/${LABEL}" - mkdir -p "${out}" + $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 + $cert = Get-PfxCertificate -FilePath $pfxPath -Password $securePass + $bundleRoot = "src-tauri/target/${{ matrix.target }}/release/bundle" + $targets = Get-ChildItem -Path $bundleRoot -Recurse -Include *.msi,*.exe -ErrorAction SilentlyContinue + if (-not $targets) { throw "No Windows installers found under $bundleRoot to sign." } + foreach ($file in $targets) { + Write-Host "Signing $($file.FullName)" + Set-AuthenticodeSignature -FilePath $file.FullName -Certificate $cert ` + -TimestampServer 'http://timestamp.digicert.com' -HashAlgorithm SHA256 | Out-Null + } + Remove-Item $pfxPath -Force - if [ ! -d "${bundle_dir}" ]; then - echo "::error::No bundle directory at ${bundle_dir}. The build produced no installers." + # 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 - - # `.app` is a directory, so it is collected as a tarball rather than - # copied; the `.dmg` is the user-facing macOS artifact and the `.app` - # tarball exists for anyone who wants to inspect the bundle directly. - found=0 - while IFS= read -r -d '' artifact; do - name="$(basename "${artifact}")" - cp "${artifact}" "${out}/${name}" - found=$((found + 1)) - done < <(find "${bundle_dir}" -type f \ - \( -name '*.dmg' -o -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' -o -name '*.msi' -o -name '*-setup.exe' \) \ - -print0) - - while IFS= read -r -d '' app; do - name="$(basename "${app}" .app)" - tar -czf "${out}/${name}-${VERSION}-${LABEL}.app.tar.gz" -C "$(dirname "${app}")" "$(basename "${app}")" - found=$((found + 1)) - done < <(find "${bundle_dir}" -maxdepth 2 -type d -name '*.app' -print0) - - if [ "${found}" -eq 0 ]; then - echo "::error::Found no release artifacts under ${bundle_dir}." - find "${bundle_dir}" -maxdepth 3 | head -50 - exit 1 + # 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 - - # An artifact that is a few kilobytes is a bundler that failed - # without a non-zero exit status. It has happened; check the size. - minimum=$((1024 * 1024)) - for artifact in "${out}"/*; do - size="$(wc -c < "${artifact}" | tr -d ' ')" - if [ "${size}" -lt "${minimum}" ]; then - echo "::error::${artifact} is only ${size} bytes, which is too small to be a real bundle." - exit 1 - fi - echo "${size} ${artifact}" + # 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 - - echo "count=${found}" >> "$GITHUB_OUTPUT" - - - name: Smoke-test the macOS bundle - if: runner.os == 'macOS' + 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: - TARGET: ${{ matrix.target }} - EXPECTED_VERSION: ${{ needs.verify.outputs.version }} - APPLE: ${{ steps.signing.outputs.apple }} + MACOS_SIGNED: ${{ steps.signing.outputs.macos_signed }} run: | set -euo pipefail + cd dist - app="$(find "src-tauri/target/${TARGET}/release/bundle" -maxdepth 2 -type d -name '*.app' | head -1)" - plist="${app}/Contents/Info.plist" - - bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${plist}")" - bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${plist}")" - - echo "bundle ${bundle_id} version ${bundle_version}" - - [ "${bundle_version}" = "${EXPECTED_VERSION}" ] || { - echo "::error::Built bundle reports ${bundle_version}, expected ${EXPECTED_VERSION}." - exit 1 - } - [ "${bundle_id}" = "ai.opencoven.chat" ] || { - echo "::error::Built bundle identifier is ${bundle_id}, expected ai.opencoven.chat." - exit 1 + 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)" } - # The executable has to actually be for the architecture the artifact - # name claims, or a user on the other architecture downloads a file - # that cannot start. - binary="${app}/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${plist}")" - file "${binary}" - case "${TARGET}" in - aarch64-*) lipo -archs "${binary}" | grep -q arm64 ;; - x86_64-*) lipo -archs "${binary}" | grep -q x86_64 ;; + 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) + if [ -n "${apptar}" ]; then + work=$(mktemp -d) + 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; } + [ -f "${app}/Contents/Info.plist" ] || { echo "::error::${app} is missing Contents/Info.plist."; exit 1; } + echo " ok: .app structure (${app})" + 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 + ;; esac - - if [ "${APPLE}" = "true" ]; then - codesign --verify --deep --strict --verbose=2 "${app}" - # Gatekeeper's own answer, which is the one users get. Reported - # rather than enforced, because it fails for a signed-but-not-yet- - # notarized build and that is a legitimate release configuration. - spctl --assess --type execute --verbose "${app}" || \ - echo "::warning::spctl assessment did not pass. The bundle is signed but Gatekeeper will not accept it until it is notarized and stapled." - fi - - - name: Smoke-test the Linux package - if: runner.os == 'Linux' - env: - EXPECTED_VERSION: ${{ needs.verify.outputs.version }} - LABEL: ${{ matrix.label }} - run: | - set -euo pipefail - - deb="$(find "release-artifacts/${LABEL}" -name '*.deb' | head -1)" - dpkg-deb --info "${deb}" - - package_version="$(dpkg-deb --field "${deb}" Version)" - [ "${package_version}" = "${EXPECTED_VERSION}" ] || { - echo "::error::Package version ${package_version} does not match ${EXPECTED_VERSION}." - exit 1 - } - - # A .deb whose payload is missing the binary installs cleanly and - # then does nothing. - dpkg-deb --contents "${deb}" | grep -E '\./usr/bin/' || { - echo "::error::The .deb contains no executable under /usr/bin." - exit 1 - } - - - name: Smoke-test the Windows installers - if: runner.os == 'Windows' - shell: pwsh - env: - LABEL: ${{ matrix.label }} - WINDOWS_SIGNED: ${{ steps.signing.outputs.windows }} - run: | - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - - $artifacts = @(Get-ChildItem -Path "release-artifacts/$env:LABEL" -File) - if ($artifacts.Count -eq 0) { - throw 'No Windows artifacts were collected.' - } - - foreach ($artifact in $artifacts) { - Write-Host "$($artifact.Name) $($artifact.Length) bytes" - - if ($env:WINDOWS_SIGNED -eq 'true') { - $signature = Get-AuthenticodeSignature -FilePath $artifact.FullName - Write-Host " signature status: $($signature.Status)" - if ($signature.Status -ne 'Valid') { - throw "$($artifact.Name) is not validly signed: $($signature.Status)" - } - } - } - - - name: Checksum the artifacts - shell: bash - env: - LABEL: ${{ matrix.label }} - run: | - set -euo pipefail - - cd "release-artifacts/${LABEL}" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -- * > "../${LABEL}.sha256" - else - shasum -a 256 -- * > "../${LABEL}.sha256" - fi - cat "../${LABEL}.sha256" + echo "Smoke test passed for ${{ matrix.platform }}." - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: release-${{ matrix.label }} - path: | - release-artifacts/${{ matrix.label }}/* - release-artifacts/${{ matrix.label }}.sha256 + name: installers-${{ matrix.platform }} + path: dist/** if-no-files-found: error - retention-days: 14 + 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 - - build + needs: [verify-tag, build] runs-on: ubuntu-latest - environment: release-signing timeout-minutes: 20 permissions: - # The only job in this file that can write anything, and the only one - # that needs to. + # 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: ${{ github.ref_name }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - ref: ${{ needs.verify.outputs.sha }} persist-credentials: false - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - pattern: release-* - path: downloaded - merge-multiple: false + pattern: installers-* + path: staging + merge-multiple: true - - id: gather - name: Gather artifacts and build the checksum manifest + - name: Flatten and inventory artifacts + shell: bash run: | set -euo pipefail - - mkdir -p upload - find downloaded -type f ! -name '*.sha256' -exec cp {} upload/ \; - - if [ -z "$(ls -A upload)" ]; then + mkdir -p release + # The .app tar was only needed for the smoke test; it is not a release + # asset, so it is excluded here. + find staging -type f ! -name '*.app.tar' -exec cp -v {} release/ \; + 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 - # Recomputed here rather than concatenating the per-platform files. - # The point of a checksum manifest is to describe the bytes that are - # actually published, and these are those bytes. - (cd upload && sha256sum -- * | sort -k2) > SHA256SUMS.txt - cp SHA256SUMS.txt upload/SHA256SUMS.txt - cat SHA256SUMS.txt - - count="$(find upload -type f ! -name 'SHA256SUMS.txt' | wc -l | tr -d ' ')" - echo "count=${count}" >> "$GITHUB_OUTPUT" - - - name: Write the release notes + # A SHA256SUMS file over every published asset, verified in place. The + # verification step is the release's checksum smoke test: if shasum -c + # fails, the release does not publish. + - 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 + + # 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: - TAG: ${{ needs.verify.outputs.tag }} - VERSION: ${{ needs.verify.outputs.version }} + REPO: ${{ github.repository }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | set -euo pipefail - - cat > release-notes.md <> release-notes.md - - previous="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}\$" | head -1 || true)" - if [ -n "${previous}" ]; then - git log --no-merges --pretty='- %s (%h)' "${previous}..${TAG}" >> release-notes.md - echo >> release-notes.md - echo "**Full changelog**: https://github.com/${GITHUB_REPOSITORY}/compare/${previous}...${TAG}" >> release-notes.md - else - echo "- First public release." >> release-notes.md + 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 "::warning::Updater .sig files are present but TAURI_SIGNING_PRIVATE_KEY was not set for the build; latest.json may reference unverifiable signatures." 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; + platforms[key] = { + signature: fs.readFileSync(sig, 'utf8').trim(), + url: `${base}/${encodeURIComponent(artifact)}`, + }; + } - cat release-notes.md + 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); + } - - name: Create the GitHub Release - if: github.event.inputs.dry_run != 'true' + 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 + + # 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 + shell: bash env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.verify.outputs.tag }} - VERSION: ${{ needs.verify.outputs.version }} - PRERELEASE: ${{ needs.verify.outputs.prerelease }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - - args=( - "${TAG}" - --title "OpenCoven Chat ${VERSION}" - --notes-file release-notes.md - --verify-tag - ) + prerelease_flag="" if [ "${PRERELEASE}" = "true" ]; then - args+=(--prerelease) + prerelease_flag="--prerelease" fi + notes="Automated release of OpenCoven Chat ${VERSION}. - if gh release view "${TAG}" >/dev/null 2>&1; then - echo "::error::A release already exists for ${TAG}. Delete it before re-running, or cut a new tag." - exit 1 - fi + Verify downloads against \`SHA256SUMS\`: - gh release create "${args[@]}" upload/* - gh release view "${TAG}" --json url,isDraft,isPrerelease,assets \ - --jq '{url, isDraft, isPrerelease, assets: [.assets[].name]}' + shasum -a 256 -c SHA256SUMS - - name: Dry run summary - if: github.event.inputs.dry_run == 'true' - env: - TAG: ${{ needs.verify.outputs.tag }} - COUNT: ${{ steps.gather.outputs.count }} - run: | - set -euo pipefail + Auto-update clients consume \`latest.json\`." - { - echo "### Dry run for \`${TAG}\`" - echo - echo "Verification and all platform builds passed. ${COUNT} artifacts were produced and not published." - echo - echo '```' - cat SHA256SUMS.txt - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + gh release create "${TAG}" \ + --repo "${{ github.repository }}" \ + --title "OpenCoven Chat ${VERSION}" \ + --notes "${notes}" \ + --verify-tag \ + ${prerelease_flag} \ + release/* 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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..45b9c719 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# 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 (currently `0.0.1`) | :white_check_mark: | +| Any earlier tag | :x: | + +## 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 deleted file mode 100644 index cd09608b..00000000 --- a/docs/RELEASING.md +++ /dev/null @@ -1,177 +0,0 @@ -# Releasing OpenCoven Chat - -The release pipeline lives in [`.github/workflows/release.yml`](../.github/workflows/release.yml). -It is driven by a signed tag. Nothing in it writes to `main`, and none of its -jobs are required checks on a pull request — `ci.yml` owns pull-request -verification. - -## Prerequisites - -A release cannot be cut until all of the following are true. - -| Requirement | Why | -| --- | --- | -| The version is identical in `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml` | The `verify` job fails on any disagreement | -| `bundle.active` is `true` in `src-tauri/tauri.conf.json` | Otherwise the build produces an executable and no installers | -| The `release-signing` environment exists in repository settings | The `build` and `publish` jobs both target it | -| The tag is **annotated** and signed, and GitHub reports it as verified | The `verify` job rejects lightweight tags outright and refuses an unverified signature | - -## Cutting a release - -```bash -# 1. Confirm main is at the commit you intend to ship. -git fetch origin -git log --oneline -1 origin/main - -# 2. Create an annotated, signed tag. -git tag -s v0.0.1 -m "OpenCoven Chat 0.0.1" - -# 3. Confirm it signed before pushing. -git tag --verify v0.0.1 - -# 4. Push the tag. This starts the workflow. -git push origin v0.0.1 -``` - -Watch the run under **Actions → Release**. - -## Rehearsing without publishing - -`workflow_dispatch` takes an existing tag and a `dry_run` flag that defaults to -`true`. A dry run performs tag verification, version checks, the full -four-platform build, every smoke test, and checksum generation, and then stops -without creating a GitHub Release. The checksums are written to the job summary. - -Use this to validate a change to the workflow itself, or to confirm signing -secrets are wired correctly, before a real tag exists. - -## What the pipeline does - -### `verify` (Ubuntu) - -1. Resolves the tag and rejects anything that is not `v..` - with an optional prerelease suffix. -2. Rejects a lightweight tag outright — it is a pointer with no object of its - own, so it can never carry a signature. -3. Rejects an annotated tag with no signature block, in those words, because - that is a different mistake from a signature that failed to verify. -4. Requires GitHub to report the tag object's signature as verified against the - keys registered to the signer's account. -5. Independently re-verifies the signature with `git verify-tag` when the - `TAG_ALLOWED_SIGNERS` secret is configured, so that a release is not gated - on a single source of truth. `git verify-tag` is not used as the *primary* - check because an SSH-signed tag needs an allowed-signers file that a fresh - runner does not have, and it would reject a perfectly good tag. If the two - checks ever disagree, the release is blocked. -6. Checks the tag version against all three manifests and against - `bundle.active`. -7. Re-runs `lint`, `typecheck`, `test:unit:normal`, and `build` against the - exact tagged tree — not against the pull-request merge commit, which no - longer exists in that shape. - -Any `0.x` version, or any version with a prerelease suffix, is marked as a -GitHub prerelease. - -### `build` (four platforms, `fail-fast: false`) - -| Label | Runner | Target | Bundles | -| --- | --- | --- | --- | -| `linux-x86_64` | `ubuntu-latest` | `x86_64-unknown-linux-gnu` | `deb` | -| `macos-aarch64` | `macos-latest` | `aarch64-apple-darwin` | `app`, `dmg` | -| `macos-x86_64` | `macos-13` | `x86_64-apple-darwin` | `app`, `dmg` | -| `windows-x86_64` | `windows-latest` | `x86_64-pc-windows-msvc` | `msi`, `nsis` | - -Everything builds natively; there is no cross-compilation. `fail-fast` is off -so that one platform's failure does not destroy the logs that distinguish a -toolchain problem from a source problem. - -Each platform is then smoke-tested against the built artifact rather than -against the build's exit status: - -- **macOS** — `CFBundleShortVersionString` matches the tag, - `CFBundleIdentifier` is `ai.opencoven.chat`, and `lipo -archs` confirms the - executable is actually built for the advertised architecture. When signing is - configured, `codesign --verify --deep --strict` must pass and `spctl` is - reported. -- **Linux** — the `.deb` `Version` field matches the tag and the payload - contains an executable under `/usr/bin`. -- **Windows** — when signing is configured, `Get-AuthenticodeSignature` must - report `Valid` for every installer. -- **All** — any artifact under 1 MiB fails the run. A bundler that fails - without a non-zero exit status has happened before. - -### `publish` (Ubuntu) - -Downloads every platform's artifacts, recomputes `SHA256SUMS.txt` over the -exact bytes being published, generates release notes with a commit list since -the previous tag, and creates the GitHub Release with `gh release create ---verify-tag`. It refuses to run if a release already exists for the tag. - -This is the only job with `contents: write`. - -## Signing secrets - -All signing material belongs to the `release-signing` environment, not to -repository secrets, so that a fork cannot reach it and a reviewer can gate -every signed build. - -| Secret | Platform | Effect if absent | -| --- | --- | --- | -| `APPLE_CERTIFICATE` | macOS | Bundle is unsigned; Gatekeeper refuses it without an override | -| `APPLE_CERTIFICATE_PASSWORD` | macOS | — | -| `APPLE_SIGNING_IDENTITY` | macOS | — | -| `APPLE_ID` | macOS | Bundle is signed but not notarized | -| `APPLE_PASSWORD` | macOS | App-specific password for notarization | -| `APPLE_TEAM_ID` | macOS | — | -| `WINDOWS_CERTIFICATE` | Windows | Installers are unsigned; SmartScreen warns | -| `WINDOWS_CERTIFICATE_PASSWORD` | Windows | — | -| `TAG_ALLOWED_SIGNERS` | all | Local `git verify-tag` re-verification is skipped; GitHub's verification stands alone | - -`APPLE_CERTIFICATE` and `WINDOWS_CERTIFICATE` are base64-encoded PFX/P12 files. -`TAG_ALLOWED_SIGNERS` is the contents of an OpenSSH `allowed_signers` file, one -`principal namespaces=... ` line per key permitted to sign a -release tag. - -A release with no signing material configured still succeeds. This is -deliberate — the project has to be able to cut a build before the certificates -exist — but every unsigned platform emits a loud workflow warning. - -On Windows the certificate is imported into `Cert:\CurrentUser\My` and its -thumbprint is written into a `--config` overlay -(`src-tauri/release-signing.conf.json`), which is removed afterwards. The -tracked `tauri.conf.json` is never modified, so the tagged tree and the built -tree stay identical. - -## Auto-update - -There is none, on purpose. `src-tauri/tauri.conf.json` does not configure the -`updater` plugin, `createUpdaterArtifacts` is `false`, and enabling it without -the plugin fails the Tauri build outright. The workflow therefore produces no -`latest.json` and no `.sig` files. - -Turning auto-update on is a separate change that must, in order: - -1. Add the `tauri-plugin-updater` dependency and its capability entry. -2. Generate an updater keypair and store the private key as a secret. -3. Set `createUpdaterArtifacts` to `true`. -4. Add a step to this workflow that publishes the update manifest. - -Publishing an update manifest before then would advertise updates that no -shipped client reads. - -## Failure playbook - -| Symptom | Cause | -| --- | --- | -| `... is a lightweight tag` | Created with `git tag` instead of `git tag -s` | -| `... is annotated but carries no signature` | Created with `git tag -a`, not `git tag -s` | -| `Tag 'x' is not verified` | Signed with a key not registered to the signer's GitHub account | -| `git verify-tag failed ... even though GitHub reported` | The key is registered on GitHub but is not in `TAG_ALLOWED_SIGNERS`. Do not release until this is explained | -| `... declares 0.1.0, but the tag says 0.0.1` | A manifest was missed during the version bump | -| `bundle.active = false` | Bundling is disabled in `tauri.conf.json` | -| `Found no release artifacts under ...` | The bundler produced nothing; read the build log above the collection step | -| `... is only N bytes` | A bundler failed silently | -| `A release already exists for ...` | Delete the release, or cut a new tag. Never reuse a tag that has been published | - -Re-running a failed release is safe up to the point where the GitHub Release is -created. After that, delete the release before re-running. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..efd7ab84 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,355 @@ +# 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, generates the updater manifest, 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, + - build installers on each platform and smoke-test them, + - generate `SHA256SUMS` and `latest.json`, + - publish the GitHub Release (a **pre-release** if the tag has a suffix such + as `-rc.1` or `-beta`). + +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, the same allowed-signers content is supplied to the workflow through the +`TAG_ALLOWED_SIGNERS` secret so the `verify-tag` job can verify SSH signatures. +If the signature cannot be verified, the release is **blocked** — that is the +intended behavior for an unverifiable tag. + +Delete a bad *local* tag before it is pushed: + +```bash +git tag -d v0.0.1 +``` + +--- + +## 3. Required secrets + +All signing secrets live in the GitHub deployment **environment** +`release-signing`. Configure them under **Settings → Environments → +release-signing**. When a signing secret is absent, the workflow still runs and +produces a clearly-marked **unsigned** build for that platform rather than +failing. + +| 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 + 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. +- **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. **Remove or neutralize `latest.json` for the bad version.** Clients update + only when `latest.json` advertises a newer version. Either: + - edit the GitHub Release and **delete the `latest.json` asset**, or + - replace it with a `latest.json` that points at the **last known-good** + version so clients that already pulled the bad manifest are steered back. +2. If updates were already served, prepare a **superseding release** (see 6.3) + with a higher version — that is the only 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. From 0cacd886add5eea1ffbd35588c1b7dc9ca689331 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 02:54:37 -0500 Subject: [PATCH 3/7] docs: fix the releasing runbook link casing in README The README linked `docs/RELEASING.md` but the file is tracked as `docs/releasing.md`. macOS is case-insensitive so this looked fine locally; GitHub is case-sensitive and the link would 404. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 130ec5e2..ad0a8f8b 100644 --- a/README.md +++ b/README.md @@ -255,4 +255,4 @@ 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). +failure playbook are documented in [`docs/releasing.md`](docs/releasing.md). From d1aa0329e70699860b73fe951ec189f8ff4f82ab Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 23:03:18 -0500 Subject: [PATCH 4/7] ci: harden release verification and publishing Add dry-run coverage, tag provenance checks, tagged-tree validation, artifact smoke tests, conditional updater handling, and guarded draft publishing. Keep security and release documentation aligned with the actual v0.0.1 packaging path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 206 +++++++++++++++++++++++++++------- SECURITY.md | 14 ++- docs/releasing.md | 30 +++-- 3 files changed, 197 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e221dab4..804ed4d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,13 +13,24 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to release (for example v0.0.1)' + required: true + type: string + dry_run: + description: 'Build and verify, but do not create 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-${{ github.ref_name }} + group: release-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -30,23 +41,36 @@ jobs: verify-tag: name: Verify signed tag runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 30 + environment: release-signing permissions: contents: read outputs: + tag: ${{ steps.check.outputs.tag }} + sha: ${{ steps.check.outputs.sha }} version: ${{ steps.check.outputs.version }} prerelease: ${{ steps.check.outputs.prerelease }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: + ref: ${{ github.event.inputs.tag || github.ref_name }} # 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: ${{ github.ref_name }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} # Optional allowed-signers file (SSH tag signing) supplied as a # secret. When present, git can verify SSH-signed tags; when absent, # verification of an SSH signature will fail and block the release, @@ -56,7 +80,21 @@ jobs: set -euo pipefail echo "Tag under release: ${TAG}" + if ! printf '%s' "${TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::${TAG} is not a v..[-prerelease] tag." + exit 1 + fi + tag_commit="$(git rev-parse "${TAG}^{commit}")" + if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${tag_commit}" != "${GITHUB_SHA}" ]; then + echo "::error::${TAG} moved after the workflow event; expected ${GITHUB_SHA}, found ${tag_commit}." + exit 1 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "${tag_commit}" refs/remotes/origin/main; then + echo "::error::${TAG} points at ${tag_commit}, which is not reachable from origin/main." + exit 1 + fi # 1. The tag must be annotated. A lightweight tag is just a branch-like # pointer with no object to sign, so it can never carry a signature. object_type=$(git cat-file -t "${TAG}") @@ -85,6 +123,14 @@ jobs: fi echo "Tag signature verified." + tag_object="$(git rev-parse "${TAG}")" + api_verified="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified')" + api_reason="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.reason')" + if [ "${api_verified}" != "true" ]; then + echo "::error::GitHub does not verify ${TAG} (reason: ${api_reason})." + exit 1 + fi + # 4. The version encoded in the tag (strip the leading v) must match # every place the version is committed. A tag that disagrees with # the tree is a release of something other than what it claims. @@ -113,9 +159,9 @@ jobs: fi echo "All manifest versions match the tag." - # 5. A tag with a pre-release suffix (v0.0.1-rc.1, v0.0.1-beta) is - # published as a GitHub pre-release. - if [[ "${tag_version}" == *-* ]]; then + # 5. A 0.x version or a tag with a pre-release suffix + # (v1.2.3-rc.1, v1.2.3-beta) is published as a GitHub pre-release. + if [[ "${tag_version}" == 0.* || "${tag_version}" == *-* ]]; then prerelease=true else prerelease=false @@ -123,10 +169,27 @@ jobs: echo "prerelease=${prerelease}" { + echo "tag=${TAG}" + echo "sha=${tag_commit}" echo "version=${tag_version}" echo "prerelease=${prerelease}" } >> "$GITHUB_OUTPUT" + - name: Check bundling is enabled + run: | + set -euo pipefail + active="$(node -p "String(require('./src-tauri/tauri.conf.json').bundle?.active === true)")" + if [ "${active}" != "true" ]; then + echo "::error::src-tauri/tauri.conf.json has bundle.active = false. A release build would produce an executable and no installers." + exit 1 + fi + + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:unit:normal + - run: 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 @@ -160,7 +223,7 @@ jobs: target: aarch64-apple-darwin bundles: app,dmg - platform: macos-x86_64 - runner: macos-14 + runner: macos-13 target: x86_64-apple-darwin bundles: app,dmg - platform: windows-x86_64 @@ -174,6 +237,7 @@ jobs: 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 @@ -278,18 +342,25 @@ jobs: 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 - $cert = Get-PfxCertificate -FilePath $pfxPath -Password $securePass + $cert = Import-PfxCertificate ` + -FilePath $pfxPath ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -Password $securePass $bundleRoot = "src-tauri/target/${{ matrix.target }}/release/bundle" $targets = Get-ChildItem -Path $bundleRoot -Recurse -Include *.msi,*.exe -ErrorAction SilentlyContinue if (-not $targets) { throw "No Windows installers found under $bundleRoot to sign." } foreach ($file in $targets) { Write-Host "Signing $($file.FullName)" - Set-AuthenticodeSignature -FilePath $file.FullName -Certificate $cert ` - -TimestampServer 'http://timestamp.digicert.com' -HashAlgorithm SHA256 | Out-Null + $result = Set-AuthenticodeSignature -FilePath $file.FullName -Certificate $cert ` + -TimestampServer 'https://timestamp.digicert.com' -HashAlgorithm SHA256 + if ($result.Status -ne 'Valid') { + throw "Authenticode signing failed for $($file.Name): $($result.Status)" + } } Remove-Item $pfxPath -Force @@ -325,6 +396,17 @@ jobs: echo "::error::No installers were produced for ${{ matrix.platform }} under ${bundle_root}." exit 1 fi + minimum=$((1024 * 1024)) + for artifact in dist/*; do + case "${artifact}" in + *.sig) continue ;; + esac + size="$(wc -c < "${artifact}" | tr -d ' ')" + if [ "${size}" -lt "${minimum}" ]; then + echo "::error::${artifact} is only ${size} bytes, which is too small to be a real bundle." + exit 1 + fi + done # 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' @@ -380,6 +462,17 @@ jobs: [ -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; } [ -f "${app}/Contents/Info.plist" ] || { echo "::error::${app} is missing Contents/Info.plist."; exit 1; } + bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${app}/Contents/Info.plist") + bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${app}/Contents/Info.plist") + [ "${bundle_version}" = "${{ needs.verify-tag.outputs.version }}" ] || { + echo "::error::${app} reports version ${bundle_version}, expected ${{ needs.verify-tag.outputs.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/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${app}/Contents/Info.plist")" + case "${{ matrix.target }}" in + aarch64-*) lipo -archs "${binary}" | grep -qw arm64 ;; + x86_64-*) lipo -archs "${binary}" | grep -qw x86_64 ;; + esac echo " ok: .app structure (${app})" if [ "${MACOS_SIGNED}" = "true" ]; then echo " verifying code signature..." @@ -423,6 +516,7 @@ jobs: 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. @@ -430,10 +524,11 @@ jobs: env: VERSION: ${{ needs.verify-tag.outputs.version }} PRERELEASE: ${{ needs.verify-tag.outputs.prerelease }} - TAG: ${{ github.ref_name }} + 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/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -449,7 +544,15 @@ jobs: mkdir -p release # The .app tar was only needed for the smoke test; it is not a release # asset, so it is excluded here. - find staging -type f ! -name '*.app.tar' -exec cp -v {} release/ \; + 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 @@ -457,29 +560,6 @@ jobs: echo "Release assets:" ls -la release - # A SHA256SUMS file over every published asset, verified in place. The - # verification step is the release's checksum smoke test: if shasum -c - # fails, the release does not publish. - - 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 - # 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 @@ -502,7 +582,8 @@ jobs: exit 0 fi if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then - echo "::warning::Updater .sig files are present but TAURI_SIGNING_PRIVATE_KEY was not set for the build; latest.json may reference unverifiable signatures." + 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'); @@ -532,8 +613,15 @@ jobs: 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: fs.readFileSync(sig, 'utf8').trim(), + signature, url: `${base}/${encodeURIComponent(artifact)}`, }; } @@ -555,6 +643,30 @@ jobs: console.log(fs.readFileSync('latest.json', 'utf8')); EOF + # A SHA256SUMS file over every published asset, verified in place. The + # verification step is the release's checksum smoke test: if shasum -c + # fails, the release does not publish. This runs after latest.json so the + # manifest itself is covered by the published checksum file. + - 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 @@ -562,7 +674,7 @@ jobs: - name: Publish GitHub release shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail prerelease_flag="" @@ -573,14 +685,28 @@ jobs: Verify downloads against \`SHA256SUMS\`: - shasum -a 256 -c SHA256SUMS + shasum -a 256 -c SHA256SUMS" + if [ -f release/latest.json ]; then + notes="${notes} Auto-update clients consume \`latest.json\`." + else + notes="${notes} + + Auto-update is not enabled for this release." + fi + if gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::error::A release already exists for ${TAG}; refusing to overwrite it." + exit 1 + fi + assets=(release/*) gh release create "${TAG}" \ --repo "${{ github.repository }}" \ --title "OpenCoven Chat ${VERSION}" \ --notes "${notes}" \ --verify-tag \ + --draft \ ${prerelease_flag} \ - release/* + "${assets[@]}" + gh release edit "${TAG}" --repo "${{ github.repository }}" --draft=false diff --git a/SECURITY.md b/SECURITY.md index 45b9c719..6809a213 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,9 +8,21 @@ upgrade to the latest release before reporting an issue. | Version | Supported | | ------- | ------------------ | -| Latest release (currently `0.0.1`) | :white_check_mark: | +| Latest published release | :white_check_mark: | | Any earlier tag | :x: | +## Scope and related components + +This policy covers the OpenCoven Chat client in this repository. Vulnerabilities +in the Coven Cave service or its canonical conversation APIs should be reported +to [OpenCoven/coven-cave](https://github.com/OpenCoven/coven-cave). + +Chat does not write canonical conversations or messages to browser +`localStorage` or IndexedDB; the current client keeps those reads in memory and +uses native secure storage for credentials. Do not report the absence of a +plaintext browser database as a vulnerability in this release. 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.** diff --git a/docs/releasing.md b/docs/releasing.md index efd7ab84..5a174c6d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,11 +1,11 @@ # 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, generates the updater manifest, and publishes a GitHub -Release. +(`ai.opencoven.chat`). Releases are driven by pushing a **signed, annotated +`v*` tag**. The `.github/workflows/release.yml` pipeline verifies the tag, +re-runs the tagged tree's checks, builds installers for macOS, Windows, and +Linux, checksums them, and publishes a GitHub Release. If updater artifacts are +enabled, it also generates `latest.json`. The first public release is **v0.0.1**. @@ -54,9 +54,9 @@ Run through this in order. Every step is runnable as written. 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. +5. **If auto-update is enabled, make sure the updater signing keypair exists** + and its public key is in `src-tauri/tauri.conf.json` (see §4). This is not + required for v0.0.1, which intentionally ships without auto-update. 6. **Create a signed, annotated tag** on the merge commit (see §2): @@ -75,9 +75,9 @@ Run through this in order. Every step is runnable as written. 8. **Watch the `Release` workflow.** It will: - verify the tag is signed and version-consistent, - build installers on each platform and smoke-test them, - - generate `SHA256SUMS` and `latest.json`, - - publish the GitHub Release (a **pre-release** if the tag has a suffix such - as `-rc.1` or `-beta`). + - generate `SHA256SUMS` and, when auto-update is enabled, `latest.json`, + - publish the GitHub Release (a **pre-release** for `0.x` versions or a tag + with a suffix such as `-rc.1` or `-beta`). 9. **Verify the published release**: download an installer and check it against the published checksums. @@ -88,6 +88,12 @@ Run through this in order. Every step is runnable as written. 10. **Announce** the release per the usual OpenCoven channels. +To rehearse an existing tag without publishing anything, run **Release** from +the GitHub Actions tab, enter the tag, and leave `dry_run` enabled. The +workflow still verifies the tag, checks the tagged tree, builds every platform, +smoke-tests the artifacts, and generates checksums; it stops before creating a +GitHub Release. + --- ## 2. Creating and verifying a signed tag @@ -189,7 +195,7 @@ picks it up automatically: 1. **Generate the updater keypair:** ```bash - pnpm tauri signer generate -w ~/.tauri/opencoven-chat-updater.key + corepack pnpm tauri signer generate -w ~/.tauri/opencoven-chat-updater.key ``` This prints a **public key** and writes the **private key** to the path From 0b6bc71c71c052762050aa62244960bdb76a9aa6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 23:28:21 -0500 Subject: [PATCH 5/7] docs: clarify release and storage guarantees Describe the manual release rehearsal path and the current in-memory conversation boundary without discouraging valid security reports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- SECURITY.md | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 804ed4d0..8c131a7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Release -# Tag-triggered release pipeline. A push of an annotated, signed `v*` tag is the -# only thing that starts it: the tag is the release, and everything downstream -# refuses to run until the tag has been proven to be both signed and consistent -# with the versions committed in the tree. +# Tag-triggered release pipeline, with a manual dry-run path for existing tags. +# A push of an annotated, signed `v*` tag is the production path: the tag is the +# release, and everything downstream refuses to run until it has been proven to +# be both signed and consistent with the versions committed in the 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 diff --git a/SECURITY.md b/SECURITY.md index 6809a213..735f46f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,9 +19,8 @@ to [OpenCoven/coven-cave](https://github.com/OpenCoven/coven-cave). Chat does not write canonical conversations or messages to browser `localStorage` or IndexedDB; the current client keeps those reads in memory and -uses native secure storage for credentials. Do not report the absence of a -plaintext browser database as a vulnerability in this release. Any future local -read cache must document its encryption and storage guarantees before shipping. +uses native secure storage for credentials. Any future local read cache must +document its encryption and storage guarantees before shipping. ## Reporting a vulnerability From 37d863deda62d340f430be17d75a341051e3f503 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 23:58:40 -0500 Subject: [PATCH 6/7] ci: make release dry runs non-publishing Skip release creation when dry_run is enabled and publish a summary of the verified artifacts instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c131a7e..53e1d17e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -672,6 +672,7 @@ jobs: # that does not exist on the remote; the signature gate already ran in # verify-tag. - name: Publish GitHub release + if: github.event.inputs.dry_run != 'true' shell: bash env: GH_TOKEN: ${{ github.token }} @@ -710,3 +711,18 @@ jobs: ${prerelease_flag} \ "${assets[@]}" gh release edit "${TAG}" --repo "${{ github.repository }}" --draft=false + + - name: Summarize dry run + if: github.event.inputs.dry_run == 'true' + shell: bash + run: | + set -euo pipefail + { + echo "### Dry run for \`${TAG}\`" + echo + echo "Verification, tagged-tree checks, artifact assembly, and checksum validation passed." + echo + echo '```' + cat release/SHA256SUMS + echo '```' + } >> "$GITHUB_STEP_SUMMARY" From 4808e3e07b30be3bd827850233c102853f6dd635 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 1 Sep 2026 23:09:01 -0500 Subject: [PATCH 7/7] ci: complete release pipeline hardening Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 505 ++++++++++++++++++++++------------ SECURITY.md | 30 +- docs/releasing.md | 76 ++--- src/release-workflow.test.ts | 87 ++++++ 4 files changed, 481 insertions(+), 217 deletions(-) create mode 100644 src/release-workflow.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53e1d17e..e1313ac0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Release -# Tag-triggered release pipeline, with a manual dry-run path for existing tags. -# A push of an annotated, signed `v*` tag is the production path: the tag is the -# release, and everything downstream refuses to run until it has been proven to -# be both signed and consistent with the versions committed in the tree. +# 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 @@ -16,11 +16,11 @@ on: workflow_dispatch: inputs: tag: - description: 'Existing tag to release (for example v0.0.1)' + description: 'Existing signed tag to build (for example v0.0.1)' required: true type: string dry_run: - description: 'Build and verify, but do not create a GitHub Release' + description: 'Build and verify without creating a GitHub Release' required: false default: true type: boolean @@ -30,7 +30,7 @@ on: permissions: {} concurrency: - group: release-${{ github.event.inputs.tag || github.ref_name }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -46,100 +46,178 @@ jobs: permissions: contents: read outputs: - tag: ${{ steps.check.outputs.tag }} - sha: ${{ steps.check.outputs.sha }} + 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: ${{ github.event.inputs.tag || github.ref_name }} + 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: ${{ github.event.inputs.tag || github.ref_name }} - GH_TOKEN: ${{ github.token }} + 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. When present, git can verify SSH-signed tags; when absent, - # verification of an SSH signature will fail and block the release, - # which is the correct default for an unverifiable tag. + # 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}" - if ! printf '%s' "${TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "::error::${TAG} is not a v..[-prerelease] tag." - exit 1 - fi - tag_commit="$(git rev-parse "${TAG}^{commit}")" - if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${tag_commit}" != "${GITHUB_SHA}" ]; then - echo "::error::${TAG} moved after the workflow event; expected ${GITHUB_SHA}, found ${tag_commit}." - exit 1 - fi - git fetch --no-tags origin main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "${tag_commit}" refs/remotes/origin/main; then - echo "::error::${TAG} points at ${tag_commit}, which is not reachable from origin/main." - exit 1 - fi - # 1. The tag must be annotated. A lightweight tag is just a branch-like - # pointer with no object to sign, so it can never carry a signature. + # 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 - # 2. The tag object must actually carry a signature block. This catches - # an annotated-but-unsigned tag before we even reach verify-tag. 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 - # 3. The signature must verify. For SSH-signed tags git needs an - # allowed-signers file; wire one in from a secret when provided. - 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" - fi - if ! git verify-tag "${TAG}"; then - echo "::error::git verify-tag failed for ${TAG}. The tag signature could not be verified; the release is blocked until it can be." + 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 - echo "Tag signature verified." - tag_object="$(git rev-parse "${TAG}")" - api_verified="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified')" - api_reason="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.reason')" - if [ "${api_verified}" != "true" ]; then - echo "::error::GitHub does not verify ${TAG} (reason: ${api_reason})." - exit 1 + 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 - # 4. The version encoded in the tag (strip the leading v) must match - # every place the version is committed. A tag that disagrees with - # the tree is a release of something other than what it claims. 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=$(grep -m1 -E '^version *= *"' src-tauri/Cargo.toml | sed -E 's/^version *= *"([^"]+)".*/\1/') + 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}" @@ -159,36 +237,25 @@ jobs: fi echo "All manifest versions match the tag." - # 5. A 0.x version or a tag with a pre-release suffix - # (v1.2.3-rc.1, v1.2.3-beta) is published as a GitHub pre-release. - if [[ "${tag_version}" == 0.* || "${tag_version}" == *-* ]]; then - prerelease=true - else - prerelease=false + 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 "prerelease=${prerelease}" + echo "Bundling is enabled." { - echo "tag=${TAG}" - echo "sha=${tag_commit}" echo "version=${tag_version}" - echo "prerelease=${prerelease}" + echo "prerelease=${PRERELEASE}" } >> "$GITHUB_OUTPUT" - - name: Check bundling is enabled - run: | - set -euo pipefail - active="$(node -p "String(require('./src-tauri/tauri.conf.json').bundle?.active === true)")" - if [ "${active}" != "true" ]; then - echo "::error::src-tauri/tauri.conf.json has bundle.active = false. A release build would produce an executable and no installers." - exit 1 - fi - - - run: pnpm install --frozen-lockfile - - run: pnpm lint - - run: pnpm typecheck - - run: pnpm test:unit:normal - - run: pnpm build + # 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 @@ -223,7 +290,7 @@ jobs: target: aarch64-apple-darwin bundles: app,dmg - platform: macos-x86_64 - runner: macos-13 + runner: macos-15-intel target: x86_64-apple-darwin bundles: app,dmg - platform: windows-x86_64 @@ -276,7 +343,7 @@ jobs: libfuse2 \ file - - run: pnpm install --frozen-lockfile + - 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 @@ -309,15 +376,63 @@ jobs: 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 - # tauri build. The signing environment variables are the standard Tauri - # names; when a secret is empty Tauri falls back to an unsigned build for - # that platform rather than failing, which keeps this workflow runnable - # without secrets. Apple notarization runs only when APPLE_ID and friends - # are present. Bundles are passed explicitly per platform (the config - # lists all targets, but Tauri only builds those valid for the host). - # Updater artifacts (.tar.gz/.zip + .sig) appear only when the updater - # plugin is configured AND TAURI_SIGNING_PRIVATE_KEY is set; v0.0.1 ships - # with neither, so none are produced and that is expected. + # 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: @@ -331,38 +446,20 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - pnpm exec tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --verbose + 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[@]}" - # Sign Windows artifacts. Only runs on Windows and only when a certificate - # secret is present, so the build above still completes unsigned otherwise. - - name: Sign Windows artifacts - 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 - $cert = Import-PfxCertificate ` - -FilePath $pfxPath ` - -CertStoreLocation 'Cert:\CurrentUser\My' ` - -Password $securePass - $bundleRoot = "src-tauri/target/${{ matrix.target }}/release/bundle" - $targets = Get-ChildItem -Path $bundleRoot -Recurse -Include *.msi,*.exe -ErrorAction SilentlyContinue - if (-not $targets) { throw "No Windows installers found under $bundleRoot to sign." } - foreach ($file in $targets) { - Write-Host "Signing $($file.FullName)" - $result = Set-AuthenticodeSignature -FilePath $file.FullName -Certificate $cert ` - -TimestampServer 'https://timestamp.digicert.com' -HashAlgorithm SHA256 - if ($result.Status -ne 'Valid') { - throw "Authenticode signing failed for $($file.Name): $($result.Status)" - } - } - Remove-Item $pfxPath -Force + - 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. @@ -396,17 +493,6 @@ jobs: echo "::error::No installers were produced for ${{ matrix.platform }} under ${bundle_root}." exit 1 fi - minimum=$((1024 * 1024)) - for artifact in dist/*; do - case "${artifact}" in - *.sig) continue ;; - esac - size="$(wc -c < "${artifact}" | tr -d ' ')" - if [ "${size}" -lt "${minimum}" ]; then - echo "::error::${artifact} is only ${size} bytes, which is too small to be a real bundle." - exit 1 - fi - done # 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' @@ -425,6 +511,22 @@ jobs: 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 @@ -436,6 +538,8 @@ jobs: 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 @@ -455,25 +559,43 @@ jobs: [ "$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=$(mktemp -d) + 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; } - [ -f "${app}/Contents/Info.plist" ] || { echo "::error::${app} is missing Contents/Info.plist."; exit 1; } - bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${app}/Contents/Info.plist") - bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${app}/Contents/Info.plist") - [ "${bundle_version}" = "${{ needs.verify-tag.outputs.version }}" ] || { - echo "::error::${app} reports version ${bundle_version}, expected ${{ needs.verify-tag.outputs.version }}."; 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/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${app}/Contents/Info.plist")" - case "${{ matrix.target }}" in - aarch64-*) lipo -archs "${binary}" | grep -qw arm64 ;; - x86_64-*) lipo -archs "${binary}" | grep -qw x86_64 ;; + 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 - echo " ok: .app structure (${app})" + 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}" @@ -496,10 +618,41 @@ jobs: 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 }} @@ -531,6 +684,10 @@ jobs: 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-* @@ -545,7 +702,7 @@ jobs: # 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}")" + name=$(basename "${artifact}") destination="release/${name}" if [ -e "${destination}" ]; then echo "::error::Duplicate release asset name: ${name}" @@ -643,10 +800,8 @@ jobs: console.log(fs.readFileSync('latest.json', 'utf8')); EOF - # A SHA256SUMS file over every published asset, verified in place. The - # verification step is the release's checksum smoke test: if shasum -c - # fails, the release does not publish. This runs after latest.json so the - # manifest itself is covered by the published checksum file. + # 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: | @@ -672,55 +827,57 @@ jobs: # that does not exist on the remote; the signature gate already ran in # verify-tag. - name: Publish GitHub release - if: github.event.inputs.dry_run != 'true' + if: needs.verify-tag.outputs.dry_run != 'true' shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - prerelease_flag="" + + 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 - prerelease_flag="--prerelease" + 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" - if [ -f release/latest.json ]; then - notes="${notes} + shasum -a 256 -c SHA256SUMS - Auto-update clients consume \`latest.json\`." - else - notes="${notes} - - Auto-update is not enabled for this release." - fi + ${updater_note}" - if gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "::error::A release already exists for ${TAG}; refusing to overwrite it." - exit 1 - fi - assets=(release/*) - gh release create "${TAG}" \ - --repo "${{ github.repository }}" \ - --title "OpenCoven Chat ${VERSION}" \ - --notes "${notes}" \ - --verify-tag \ - --draft \ - ${prerelease_flag} \ - "${assets[@]}" + args+=(--notes "${notes}") + gh release create "${args[@]}" release/* gh release edit "${TAG}" --repo "${{ github.repository }}" --draft=false - name: Summarize dry run - if: github.event.inputs.dry_run == 'true' + if: needs.verify-tag.outputs.dry_run == 'true' shell: bash run: | set -euo pipefail { echo "### Dry run for \`${TAG}\`" echo - echo "Verification, tagged-tree checks, artifact assembly, and checksum validation passed." + 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 diff --git a/SECURITY.md b/SECURITY.md index 735f46f9..f7edea52 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,19 +8,27 @@ upgrade to the latest release before reporting an issue. | Version | Supported | | ------- | ------------------ | -| Latest published release | :white_check_mark: | +| Latest release | :white_check_mark: | | Any earlier tag | :x: | -## Scope and related components - -This policy covers the OpenCoven Chat client in this repository. Vulnerabilities -in the Coven Cave service or its canonical conversation APIs should be reported -to [OpenCoven/coven-cave](https://github.com/OpenCoven/coven-cave). - -Chat does not write canonical conversations or messages to browser -`localStorage` or IndexedDB; the current client keeps those reads in memory and -uses native secure storage for credentials. Any future local read cache must -document its encryption and storage guarantees before shipping. +## 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 diff --git a/docs/releasing.md b/docs/releasing.md index 5a174c6d..48c8be12 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,11 +1,11 @@ # Releasing OpenCoven Chat This document is the runbook for cutting a public release of **OpenCoven Chat** -(`ai.opencoven.chat`). Releases are driven by pushing a **signed, annotated -`v*` tag**. The `.github/workflows/release.yml` pipeline verifies the tag, -re-runs the tagged tree's checks, builds installers for macOS, Windows, and -Linux, checksums them, and publishes a GitHub Release. If updater artifacts are -enabled, it also generates `latest.json`. +(`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**. @@ -54,9 +54,9 @@ Run through this in order. Every step is runnable as written. corepack pnpm app:build # local sanity build ``` -5. **If auto-update is enabled, make sure the updater signing keypair exists** - and its public key is in `src-tauri/tauri.conf.json` (see §4). This is not - required for v0.0.1, which intentionally ships without auto-update. +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): @@ -74,10 +74,13 @@ Run through this in order. Every step is runnable as written. 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, when auto-update is enabled, `latest.json`, - - publish the GitHub Release (a **pre-release** for `0.x` versions or a tag - with a suffix such as `-rc.1` or `-beta`). + - 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. @@ -88,12 +91,6 @@ Run through this in order. Every step is runnable as written. 10. **Announce** the release per the usual OpenCoven channels. -To rehearse an existing tag without publishing anything, run **Release** from -the GitHub Actions tab, enter the tag, and leave `dry_run` enabled. The -workflow still verifies the tag, checks the tagged tree, builds every platform, -smoke-tests the artifacts, and generates checksums; it stops before creating a -GitHub Release. - --- ## 2. Creating and verifying a signed tag @@ -127,10 +124,11 @@ git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers # each line: " namespaces=\"git\" ssh-ed25519 AAAA..." ``` -In CI, the same allowed-signers content is supplied to the workflow through the -`TAG_ALLOWED_SIGNERS` secret so the `verify-tag` job can verify SSH signatures. -If the signature cannot be verified, the release is **blocked** — that is the -intended behavior for an unverifiable tag. +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: @@ -138,15 +136,28 @@ Delete a bad *local* tag before it is pushed: 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 signing secret is absent, the workflow still runs and -produces a clearly-marked **unsigned** build for that platform rather than -failing. +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 | | ------ | ------- | ----------- | @@ -243,7 +254,9 @@ The product name contains a space, so installer filenames look like Per release, the workflow publishes: - **macOS**: `OpenCoven Chat.app` (packaged) + `.dmg` for `aarch64` and - `x86_64`, signed and notarized when Apple secrets are present. + `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`. @@ -268,13 +281,12 @@ 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. **Remove or neutralize `latest.json` for the bad version.** Clients update - only when `latest.json` advertises a newer version. Either: - - edit the GitHub Release and **delete the `latest.json` asset**, or - - replace it with a `latest.json` that points at the **last known-good** - version so clients that already pulled the bad manifest are steered back. -2. If updates were already served, prepare a **superseding release** (see 6.3) - with a higher version — that is the only way to move auto-updaters forward. +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 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'); + }); +});