diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index dc150637..9b8b11d2 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,10 @@ updates:
directory: /
schedule:
interval: weekly
+ # Let ecosystem fixes settle before routine version PRs. Dependabot
+ # security updates are explicitly not delayed by this setting.
+ cooldown:
+ default-days: 7
groups:
ccusage-runtime:
patterns:
diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 224e1f98..2163a2b1 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -18,9 +18,7 @@ on:
- "apps/desktop/src-tauri/tauri.conf.json"
workflow_dispatch:
-permissions:
- contents: write
- actions: write
+permissions: {}
concurrency:
group: auto-release-${{ github.ref }}
@@ -28,11 +26,16 @@ concurrency:
jobs:
publish:
+ name: Publish release and dispatch build
runs-on: ubuntu-latest
+ permissions:
+ contents: write # Create the versioned GitHub release.
+ actions: write # Dispatch the separate signed-build workflow.
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
+ persist-credentials: false
- name: Read version from tauri.conf.json
id: ver
@@ -50,12 +53,13 @@ jobs:
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
- if gh release view "${{ steps.ver.outputs.tag }}" \
+ if gh release view "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
- echo "Release ${{ steps.ver.outputs.tag }} already exists — skipping."
+ echo "Release $RELEASE_TAG already exists — skipping."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
@@ -64,21 +68,23 @@ jobs:
if: steps.check.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
- gh release create "${{ steps.ver.outputs.tag }}" \
+ gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
- --title "CodeVetter ${{ steps.ver.outputs.tag }}" \
+ --title "CodeVetter $RELEASE_TAG" \
--generate-notes
- name: Dispatch release build workflow
if: steps.check.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
gh workflow run release.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
- -f tag="${{ steps.ver.outputs.tag }}"
+ -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ea6d5de5..3410211a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,19 +4,72 @@ on:
branches: [main]
pull_request:
workflow_dispatch:
+ inputs:
+ native_qualification:
+ description: "Run isolated native macOS qualification"
+ required: true
+ default: false
+ type: boolean
+ native_interaction:
+ description: "Include XCUITest on the isolated hosted desktop"
+ required: true
+ default: false
+ type: boolean
+ native_production_qualification:
+ description: "Run protected signing, notarization, and migration qualification"
+ required: true
+ default: false
+ type: boolean
+permissions: {}
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
+ native-qualification:
+ name: Native macOS qualification
+ if: >-
+ github.event_name == 'pull_request' ||
+ (github.event_name == 'workflow_dispatch' && inputs.native_qualification)
+ uses: ./.github/workflows/native-qualification.yml
+ with:
+ run_interaction: ${{ github.event_name == 'pull_request' || inputs.native_interaction }}
+ permissions:
+ contents: read
+
+ native-production-qualification:
+ name: Native macOS production-candidate qualification
+ if: github.event_name == 'workflow_dispatch' && inputs.native_production_qualification
+ uses: ./.github/workflows/native-production-qualification.yml
+ secrets:
+ 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 }}
+ SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
+ SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
+ permissions:
+ contents: read
+
lint-and-typecheck:
+ name: Lint, test, and build
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 2
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v6
+ persist-credentials: false
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: 'pnpm'
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install Tauri Linux dependencies
run: |
sudo apt-get update
@@ -25,7 +78,7 @@ jobs:
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: apps/desktop/src-tauri
- name: Install Dependencies
@@ -35,7 +88,9 @@ jobs:
run: pnpm run lint
- name: Fetch code-health base
if: github.event_name != 'workflow_dispatch'
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}
+ env:
+ CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}
+ run: git fetch --depth=1 origin "$CODE_HEALTH_BASE"
- name: Code health
env:
CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}
@@ -83,7 +138,8 @@ jobs:
run: |
pnpm run test:ccusage-sidecar
pnpm run prepare:ccusage-sidecar
- src-tauri/binaries/ccusage-$(rustc -vV | sed -n 's/^host: //p') --version
+ CCUSAGE_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ "src-tauri/binaries/ccusage-$CCUSAGE_TARGET" --version
- name: Qualify CLI artifact
working-directory: apps/desktop
run: |
diff --git a/.github/workflows/deploy-landing.yml b/.github/workflows/deploy-landing.yml
index 629d8339..787eff44 100644
--- a/.github/workflows/deploy-landing.yml
+++ b/.github/workflows/deploy-landing.yml
@@ -7,9 +7,8 @@ name: Deploy Landing Page
# apps/landing-page-astro/.
on:
workflow_dispatch:
-permissions:
- contents: read
- deployments: write
+
+permissions: {}
concurrency:
group: deploy-landing-${{ github.ref }}
@@ -17,16 +16,22 @@ concurrency:
jobs:
deploy:
+ name: Build and deploy landing page
runs-on: ubuntu-latest
timeout-minutes: 20
+ permissions:
+ contents: read
+ deployments: write # Record the Cloudflare Pages deployment.
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v6
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "22"
- cache: pnpm
+ package-manager-cache: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -58,7 +63,7 @@ jobs:
- name: Deploy to Cloudflare Pages
if: steps.cloudflare.outputs.deploy_enabled == 'true'
- uses: cloudflare/wrangler-action@v3
+ uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 21d75599..a59fc946 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -5,12 +5,19 @@ on: [push, pull_request]
permissions:
contents: read
+concurrency:
+ group: docs-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
validate:
+ name: Validate documentation
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-node@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "22"
- name: Validate docs (links, frontmatter, structure)
diff --git a/.github/workflows/native-production-qualification.yml b/.github/workflows/native-production-qualification.yml
new file mode 100644
index 00000000..32ff52c4
--- /dev/null
+++ b/.github/workflows/native-production-qualification.yml
@@ -0,0 +1,274 @@
+name: Native macOS production-candidate qualification
+
+on:
+ workflow_call:
+ secrets:
+ APPLE_CERTIFICATE:
+ required: true
+ APPLE_CERTIFICATE_PASSWORD:
+ required: true
+ APPLE_SIGNING_IDENTITY:
+ required: true
+ APPLE_ID:
+ required: true
+ APPLE_PASSWORD:
+ required: true
+ APPLE_TEAM_ID:
+ required: true
+ SPARKLE_EDDSA_PRIVATE_KEY:
+ required: true
+ SPARKLE_EDDSA_PUBLIC_KEY:
+ required: true
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: native-production-qualification-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ qualify:
+ name: Sign, notarize, migrate, and qualify native candidate
+ runs-on: xcode-27
+ timeout-minutes: 180
+ permissions:
+ contents: read
+ env:
+ CODEVETTER_NATIVE_CHANNEL: production
+ CODEVETTER_NATIVE_BUNDLE_IDENTIFIER: com.codevetter.desktop
+ CODEVETTER_NATIVE_SPARKLE_FEED_URL: https://github.com/Codevetter/codevetter/releases/latest/download/appcast.xml
+ CODEVETTER_NATIVE_SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
+
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+
+ - name: Setup Node
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 22
+ package-manager-cache: false
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ no-cache: true
+
+ - name: Install locked dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Require protected production inputs
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
+ SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ missing=()
+ for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID SPARKLE_EDDSA_PRIVATE_KEY SPARKLE_EDDSA_PUBLIC_KEY; do
+ if [[ -z "${!name:-}" ]]; then missing+=("$name"); fi
+ done
+ if (( ${#missing[@]} > 0 )); then
+ echo "Missing protected inputs: ${missing[*]}" >&2
+ exit 1
+ fi
+
+ - name: Import Developer ID certificate into ephemeral keychain
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ KEYCHAIN_PATH="$RUNNER_TEMP/codevetter-signing.keychain-db"
+ CERTIFICATE_PATH="$RUNNER_TEMP/codevetter-developer-id.p12"
+ KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
+ printf '%s' "$APPLE_CERTIFICATE" | /usr/bin/base64 --decode > "$CERTIFICATE_PATH"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security list-keychains -d user -s "$KEYCHAIN_PATH"
+ rm -f "$CERTIFICATE_PATH"
+ echo "CODEVETTER_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
+
+ - name: Build production-identity Release app through XcodeBuildMCP
+ run: pnpm native:build:release
+
+ - name: Build and Developer ID-sign exact native package
+ env:
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ pnpm native:package:qualify -- \
+ --channel production \
+ --identity "$APPLE_SIGNING_IDENTITY" \
+ --out-root artifacts/native-production-ci
+ QUALIFICATION="$(find artifacts/native-production-ci -name qualification.json -print -quit)"
+ test -n "$QUALIFICATION"
+ PACKAGE_DIR="$(dirname "$QUALIFICATION")"
+ echo "NATIVE_QUALIFICATION=$QUALIFICATION" >> "$GITHUB_ENV"
+ echo "NATIVE_PACKAGE_DIR=$PACKAGE_DIR" >> "$GITHUB_ENV"
+
+ - name: Submit signed app to Apple and staple ticket
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ DEVELOPER_DIR="$(xcode-select -p)"
+ NOTARYTOOL="$DEVELOPER_DIR/usr/bin/notarytool"
+ STAPLER="$DEVELOPER_DIR/usr/bin/stapler"
+ ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)"
+ "$NOTARYTOOL" submit "$ZIP_PATH" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait \
+ --output-format json > artifacts/native-production-ci/notary-initial.json
+ jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-initial.json >/dev/null
+ "$STAPLER" staple "$NATIVE_PACKAGE_DIR/CodeVetter.app"
+ "$STAPLER" validate "$NATIVE_PACKAGE_DIR/CodeVetter.app"
+
+ - name: Rebuild archives around the stapled application
+ run: pnpm native:package:finalize -- --qualification "$NATIVE_QUALIFICATION"
+
+ - name: Bind final archive to Apple notarization
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ NOTARYTOOL="$(xcode-select -p)/usr/bin/notarytool"
+ ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)"
+ "$NOTARYTOOL" submit "$ZIP_PATH" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait \
+ --output-format json > artifacts/native-production-ci/notary-final.json
+ jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-final.json >/dev/null
+ pnpm native:notarization:prove -- \
+ --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \
+ --archive "$ZIP_PATH" \
+ --qualification "$NATIVE_QUALIFICATION" \
+ --submission artifacts/native-production-ci/notary-final.json \
+ --out artifacts/native-production-ci/notarization-proof.json
+
+ - name: Generate and cryptographically inspect Sparkle appcast
+ env:
+ SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ APPCAST_DIR="artifacts/native-production-ci/appcast"
+ mkdir -p "$APPCAST_DIR"
+ ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)"
+ cp "$ZIP_PATH" "$APPCAST_DIR/"
+ GENERATE_APPCAST="$(find artifacts/native-build/DerivedData/SourcePackages/artifacts -path '*/Sparkle/bin/generate_appcast' -type f -print -quit)"
+ test -x "$GENERATE_APPCAST"
+ printf '%s' "$SPARKLE_EDDSA_PRIVATE_KEY" | "$GENERATE_APPCAST" \
+ --ed-key-file - \
+ --download-url-prefix "https://github.com/Codevetter/codevetter/releases/latest/download/" \
+ "$APPCAST_DIR"
+ test -f "$APPCAST_DIR/appcast.xml"
+ pnpm native:appcast:inspect -- \
+ --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \
+ --appcast "$APPCAST_DIR/appcast.xml" \
+ --qualification "$NATIVE_QUALIFICATION" \
+ --out artifacts/native-production-ci/appcast-proof.json
+
+ - name: Download retained Tauri release for isolated migration proof
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ INCUMBENT_ROOT="$RUNNER_TEMP/codevetter-incumbent"
+ mkdir -p "$INCUMBENT_ROOT"
+ gh release download v1.11.0 \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern 'CodeVetter_aarch64.app.tar.gz' \
+ --dir "$INCUMBENT_ROOT"
+ tar -xzf "$INCUMBENT_ROOT/CodeVetter_aarch64.app.tar.gz" -C "$INCUMBENT_ROOT"
+ INCUMBENT_APP="$(find "$INCUMBENT_ROOT" -maxdepth 2 -name CodeVetter.app -type d -print -quit)"
+ test -n "$INCUMBENT_APP"
+ echo "NATIVE_INCUMBENT_APP=$INCUMBENT_APP" >> "$GITHUB_ENV"
+
+ - name: Qualify isolated upgrade, relaunch, custom rubric, data, and rollback
+ run: |
+ pnpm native:installed-upgrade:qualify -- \
+ --incumbent-app "$NATIVE_INCUMBENT_APP" \
+ --native-app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \
+ --qualification "$NATIVE_QUALIFICATION" \
+ --run-root "$RUNNER_TEMP/codevetter-native-upgrade" \
+ --out artifacts/native-production-ci/installed-upgrade-proof.json \
+ --foreground \
+ --hosted-ephemeral
+
+ - name: Require every production-readiness check
+ shell: bash
+ run: |
+ set -euo pipefail
+ pnpm native:release:inspect -- \
+ --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \
+ --qualification "$NATIVE_QUALIFICATION" \
+ --appcast-proof artifacts/native-production-ci/appcast-proof.json \
+ --notarization-proof artifacts/native-production-ci/notarization-proof.json \
+ --installed-proof artifacts/native-production-ci/installed-upgrade-proof.json \
+ --out artifacts/native-production-ci/release-readiness.json
+ jq -e '.shipping_ready == true and (.checks | all(.passed == true))' \
+ artifacts/native-production-ci/release-readiness.json >/dev/null
+
+ - name: Test all production qualification contracts
+ run: |
+ pnpm test:native-runner
+ pnpm test:native-package
+ pnpm test:native-package-finalize
+ pnpm test:native-appcast
+ pnpm test:native-notarization
+ pnpm test:native-data-continuity
+ pnpm test:native-installed-upgrade
+ pnpm test:native-release
+
+ - name: Remove ephemeral credential material
+ if: always()
+ shell: bash
+ run: |
+ if [[ -n "${CODEVETTER_SIGNING_KEYCHAIN:-}" ]]; then
+ security delete-keychain "$CODEVETTER_SIGNING_KEYCHAIN" || true
+ fi
+ rm -f "$RUNNER_TEMP/codevetter-developer-id.p12"
+
+ - name: Upload protected production-candidate evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: native-production-qualification-${{ github.run_id }}
+ path: |
+ artifacts/native-production-ci
+ artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM
+ if-no-files-found: warn
+ retention-days: 7
+ compression-level: 0
diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml
new file mode 100644
index 00000000..f5d436df
--- /dev/null
+++ b/.github/workflows/native-qualification.yml
@@ -0,0 +1,126 @@
+name: Native macOS qualification
+
+on:
+ workflow_call:
+ inputs:
+ run_interaction:
+ description: "Run XCUITest on the isolated hosted desktop"
+ required: true
+ type: boolean
+ workflow_dispatch:
+ inputs:
+ run_interaction:
+ description: "Run XCUITest on the isolated hosted desktop"
+ required: true
+ default: false
+ type: boolean
+
+permissions: {}
+
+concurrency:
+ group: native-qualification-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ qualify:
+ name: Build and qualify native preview
+ runs-on: xcode-27
+ timeout-minutes: 120
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+
+ - name: Setup Node
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 22
+ package-manager-cache: false
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ no-cache: true
+
+ - name: Install locked dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Prove desktop-control guard
+ run: pnpm test:native-runner
+
+ - name: Prepare deterministic owner-review outputs
+ shell: bash
+ run: |
+ node scripts/render-native-owner-review.mjs env \
+ --out-root artifacts/native-owner-review-ci >> "$GITHUB_ENV"
+
+ - name: Run background-safe native qualification
+ run: pnpm test:native:background
+
+ - name: Finalize deterministic owner-review packet
+ run: |
+ node scripts/render-native-owner-review.mjs finalize \
+ --out-root artifacts/native-owner-review-ci
+ pnpm test:native-review-render
+
+ - name: Run interaction qualification on isolated desktop
+ if: inputs.run_interaction
+ run: pnpm test:native:ui -- --foreground --desktop-idle
+
+ - name: Build coverage-free Release application
+ run: pnpm native:build:release
+
+ - name: Build and qualify local preview package
+ run: pnpm native:package:qualify -- --out-root artifacts/native-package-ci
+
+ - name: Inspect exact preview release boundaries
+ id: readiness
+ shell: bash
+ run: |
+ set -euo pipefail
+ QUALIFICATION="$(find artifacts/native-package-ci -name qualification.json -print -quit)"
+ test -n "$QUALIFICATION"
+ PACKAGE_DIR="$(dirname "$QUALIFICATION")"
+ node scripts/inspect-native-release-readiness.mjs \
+ --app "$PACKAGE_DIR/CodeVetter.app" \
+ --qualification "$QUALIFICATION" \
+ --out artifacts/native-package-ci/release-readiness.json
+ echo "qualification=$QUALIFICATION" >> "$GITHUB_OUTPUT"
+
+ - name: Test package and readiness inspectors
+ run: |
+ pnpm test:native-package
+ pnpm test:native-release
+
+ - name: Preserve native tool logs
+ if: always()
+ shell: bash
+ run: |
+ mkdir -p artifacts/native-tool-logs
+ find "$HOME/Library/Developer/XcodeBuildMCP" \
+ -path '*/logs/*' -type f -name '*.log' \
+ -exec cp {} artifacts/native-tool-logs/ \;
+
+ - name: Upload unsigned qualification evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: native-qualification-${{ github.run_id }}
+ path: |
+ artifacts/native-package-ci
+ artifacts/native-owner-review-ci
+ artifacts/native-tool-logs
+ artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM
+ if-no-files-found: warn
+ retention-days: 7
+ compression-level: 0
diff --git a/.github/workflows/osv-offline.yml b/.github/workflows/osv-offline.yml
new file mode 100644
index 00000000..26e263d0
--- /dev/null
+++ b/.github/workflows/osv-offline.yml
@@ -0,0 +1,84 @@
+name: OSV Offline Scan
+
+on:
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: osv-offline-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ refresh-databases:
+ name: Refresh OSV databases
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download ecosystem databases
+ env:
+ OSV_CACHE_ROOT: ${{ runner.temp }}/osv-db/osv-scalibr
+ run: |
+ mkdir -p "$OSV_CACHE_ROOT/npm" "$OSV_CACHE_ROOT/crates.io" "$OSV_CACHE_ROOT/Go"
+ curl --fail --silent --show-error --location \
+ https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip \
+ --output "$OSV_CACHE_ROOT/npm/all.zip"
+ curl --fail --silent --show-error --location \
+ https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip \
+ --output "$OSV_CACHE_ROOT/crates.io/all.zip"
+ curl --fail --silent --show-error --location \
+ https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip \
+ --output "$OSV_CACHE_ROOT/Go/all.zip"
+ cd "$OSV_CACHE_ROOT"
+ sha256sum npm/all.zip crates.io/all.zip Go/all.zip > SHA256SUMS
+ - name: Upload database snapshot
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: osv-databases-${{ github.run_id }}
+ path: ${{ runner.temp }}/osv-db
+ retention-days: 7
+
+ offline-scan:
+ name: Scan with network-disabled mode
+ needs: refresh-databases
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Download database snapshot
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
+ with:
+ name: osv-databases-${{ github.run_id }}
+ path: ${{ runner.temp }}/osv-db
+ - name: Install checksum-pinned OSV-Scanner
+ env:
+ OSV_BINARY: osv-scanner_linux_amd64
+ OSV_SHA256: f9f25499a2c8cc367b3af45df2ea7eeca7fbccceab9c35079968f4b3652194be
+ OSV_URL: https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64
+ run: |
+ curl --fail --silent --show-error --location "$OSV_URL" --output "$RUNNER_TEMP/$OSV_BINARY"
+ echo "$OSV_SHA256 $RUNNER_TEMP/$OSV_BINARY" | sha256sum --check --strict
+ chmod 0755 "$RUNNER_TEMP/$OSV_BINARY"
+ mkdir -p "$RUNNER_TEMP/osv-bin"
+ mv "$RUNNER_TEMP/$OSV_BINARY" "$RUNNER_TEMP/osv-bin/osv-scanner"
+ echo "$RUNNER_TEMP/osv-bin" >> "$GITHUB_PATH"
+ - name: Run offline scan
+ id: scan
+ continue-on-error: true
+ env:
+ XDG_CACHE_HOME: ${{ runner.temp }}/osv-db
+ run: node scripts/run-osv-offline.mjs
+ - name: Upload scan evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: osv-offline-evidence-${{ github.run_id }}
+ path: artifacts/tooling/osv
+ if-no-files-found: error
+ retention-days: 30
+ - name: Enforce scan result
+ if: always() && steps.scan.outcome == 'failure'
+ run: exit 1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 235c30d4..fd596879 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,47 +10,51 @@ on:
required: true
type: string
-permissions:
- contents: write
+permissions: {}
+
+concurrency:
+ group: release-${{ github.event.release.tag_name || inputs.tag || github.ref }}
+ cancel-in-progress: false
jobs:
build:
+ name: Build, sign, and upload desktop artifacts
strategy:
matrix:
platform: [macos-latest]
runs-on: ${{ matrix.platform }}
+ permissions:
+ contents: write # Upload signed artifacts and updater metadata.
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
# On workflow_dispatch we want the commit the tag points at, not
# the head of main — checkout the tag explicitly.
ref: ${{ github.event.release.tag_name || inputs.tag }}
+ persist-credentials: false
- name: Setup pnpm
- uses: pnpm/action-setup@v6
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
# Don't specify version here — it reads "packageManager" from
# package.json (pnpm@10.33.2). Specifying both causes
# "Multiple versions of pnpm specified" error.
- name: Setup Node
- uses: actions/setup-node@v6
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22
- cache: pnpm
+ package-manager-cache: false
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: aarch64-apple-darwin
- name: Setup Bun
- uses: oven-sh/setup-bun@v2
-
- - name: Cache Rust
- uses: Swatinem/rust-cache@v2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
- workspaces: apps/desktop/src-tauri
+ no-cache: true
- name: Install dependencies
run: pnpm install --ignore-scripts
@@ -102,7 +106,7 @@ jobs:
- name: Build Tauri app
id: tauri
- uses: tauri-apps/tauri-action@v0
+ uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_ENV_TARGET_TRIPLE: aarch64-apple-darwin
diff --git a/.github/workflows/repository-security.yml b/.github/workflows/repository-security.yml
new file mode 100644
index 00000000..5a40510a
--- /dev/null
+++ b/.github/workflows/repository-security.yml
@@ -0,0 +1,184 @@
+name: Repository Security
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: repository-security-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ actionlint:
+ name: GitHub Actions semantics
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install pinned workflow analyzers
+ env:
+ ACTIONLINT_ARCHIVE: actionlint_1.7.12_linux_amd64.tar.gz
+ ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
+ ACTIONLINT_URL: https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
+ SHELLCHECK_ARCHIVE: shellcheck-v0.11.0.linux.x86_64.tar.gz
+ SHELLCHECK_SHA256: b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6
+ SHELLCHECK_URL: https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.gz
+ run: |
+ mkdir -p "$RUNNER_TEMP/workflow-tools"
+ curl --fail --silent --show-error --location "$ACTIONLINT_URL" --output "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE"
+ echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$ACTIONLINT_ARCHIVE" | sha256sum --check --strict
+ tar -xzf "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE" -C "$RUNNER_TEMP/workflow-tools" actionlint
+ curl --fail --silent --show-error --location "$SHELLCHECK_URL" --output "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE"
+ echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/$SHELLCHECK_ARCHIVE" | sha256sum --check --strict
+ tar -xzf "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE" -C "$RUNNER_TEMP"
+ mv "$RUNNER_TEMP/shellcheck-v0.11.0/shellcheck" "$RUNNER_TEMP/workflow-tools/shellcheck"
+ echo "$RUNNER_TEMP/workflow-tools" >> "$GITHUB_PATH"
+ - name: Validate workflow syntax and shell fragments
+ run: |
+ actionlint -version
+ shellcheck --version
+ actionlint -color
+
+ biome-sarif:
+ name: Biome SARIF
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write # Publish the generated Biome report.
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Set up pnpm
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ - name: Set up Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ cache: pnpm
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ - name: Generate Biome SARIF
+ env:
+ BIOME_SARIF_PATH: artifacts/tooling/biome.sarif
+ run: pnpm run quality:sarif
+ - name: Upload Biome SARIF
+ if: always()
+ uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
+ with:
+ sarif_file: artifacts/tooling/biome.sarif
+ category: biome
+
+ cargo-deny:
+ name: Rust dependency policy
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write # Publish license and source-policy findings.
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install pinned cargo-deny binary
+ env:
+ CARGO_DENY_ARCHIVE: cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz
+ CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
+ CARGO_DENY_URL: https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz
+ run: |
+ curl --fail --silent --show-error --location "$CARGO_DENY_URL" --output "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE"
+ echo "$CARGO_DENY_SHA256 $RUNNER_TEMP/$CARGO_DENY_ARCHIVE" | sha256sum --check --strict
+ tar -xzf "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/cargo-deny-0.20.2-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
+ - name: Seed locked Rust dependencies
+ run: cargo fetch --locked --manifest-path apps/desktop/src-tauri/Cargo.toml
+ - name: Check licenses, sources, and wildcard requirements
+ id: policy
+ continue-on-error: true
+ run: |
+ mkdir -p artifacts/tooling
+ set +e
+ cargo-deny --format sarif --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check licenses sources > artifacts/tooling/cargo-deny.sarif
+ policy_status=$?
+ cargo-deny --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check --hide-inclusion-graph bans
+ bans_status=$?
+ set -e
+ if [ ! -s artifacts/tooling/cargo-deny.sarif ]; then
+ printf '%s\n' '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"cargo-deny","version":"0.20.2","rules":[]}},"results":[]}]}' > artifacts/tooling/cargo-deny.sarif
+ fi
+ if jq empty artifacts/tooling/cargo-deny.sarif; then
+ echo "sarif_valid=true" >> "$GITHUB_OUTPUT"
+ fi
+ if [ "$policy_status" -ne 0 ] || [ "$bans_status" -ne 0 ]; then
+ exit 1
+ fi
+ - name: Upload cargo-deny SARIF
+ if: always() && steps.policy.outputs.sarif_valid == 'true'
+ uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
+ with:
+ sarif_file: artifacts/tooling/cargo-deny.sarif
+ category: cargo-deny
+ - name: Enforce cargo-deny result
+ if: always() && steps.policy.outcome == 'failure'
+ run: exit 1
+
+ gitleaks:
+ name: Gitleaks
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write # Publish the redacted Gitleaks report.
+ steps:
+ - name: Checkout complete history
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Install pinned Gitleaks binary
+ env:
+ GITLEAKS_ARCHIVE: gitleaks_8.30.1_linux_x64.tar.gz
+ GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
+ GITLEAKS_URL: https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
+ run: |
+ curl --fail --silent --show-error --location "$GITLEAKS_URL" --output "$RUNNER_TEMP/$GITLEAKS_ARCHIVE"
+ echo "$GITLEAKS_SHA256 $RUNNER_TEMP/$GITLEAKS_ARCHIVE" | sha256sum --check --strict
+ mkdir -p "$RUNNER_TEMP/gitleaks-bin"
+ tar -xzf "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks
+ echo "$RUNNER_TEMP/gitleaks-bin" >> "$GITHUB_PATH"
+ - name: Scan repository history
+ id: scan
+ continue-on-error: true
+ run: >-
+ gitleaks git --no-banner --redact=100 --report-format sarif
+ --report-path "$RUNNER_TEMP/gitleaks.sarif" .
+ - name: Upload Gitleaks SARIF
+ if: always()
+ uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
+ with:
+ sarif_file: ${{ runner.temp }}/gitleaks.sarif
+ category: gitleaks
+ - name: Enforce Gitleaks result
+ if: always() && steps.scan.outcome == 'failure'
+ run: exit 1
+
+ zizmor:
+ name: zizmor
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write # zizmor-action uploads its SARIF report.
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Audit GitHub Actions
+ uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index c0f7cecb..cb36ed8e 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -4,24 +4,35 @@ on:
- cron: '0 9 * * 1'
workflow_dispatch:
+permissions: {}
+
+concurrency:
+ group: weekly-quality-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
quality:
+ name: Run weekly quality canary
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- name: Record source revision
id: rev
run: |
- echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
+ {
+ echo "sha=$(git rev-parse HEAD)"
+ echo "short=$(git rev-parse --short HEAD)"
+ echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ } >> "$GITHUB_OUTPUT"
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
@@ -72,24 +83,29 @@ jobs:
- name: Emit canary evidence
if: always()
+ env:
+ JOB_STATUS: ${{ job.status }}
+ SOURCE_REVISION: ${{ steps.rev.outputs.sha }}
+ SOURCE_REVISION_SHORT: ${{ steps.rev.outputs.short }}
+ STARTED_AT: ${{ steps.rev.outputs.ts }}
run: |
set -euo pipefail
mkdir -p canary-out
# The conclusion is only known after the quality step; read it
# from the job status env that GitHub sets for `if: always()` steps.
# We treat any non-success quality step as a failure.
- CONCLUSION="${{ job.status }}"
+ CONCLUSION="$JOB_STATUS"
# `job.status` is the *job* status at the point this step starts;
# because this step runs with `if: always()`, the prior step's
# failure has already propagated to the job status.
cat > canary-out/canary-evidence.json <