Skip to content

CI

CI #517

Workflow file for this run

name: CI
on:
push:
pull_request:
# Nightly platform sweep. The local gate runs on macOS, so a defect shaped by
# Linux or Windows is invisible until something triggers the matrix — and on
# the 1.9.0 candidate the thing that triggered it was ship night. Three of
# five legs went red with five separate defects, every one in a feature that
# landed after v1.8.9 and had therefore never executed on those platforms.
# A nightly run surfaces that class the day it lands instead of on the eve of
# a release. scripts/platform-portability-guard.cjs catches the greppable
# signatures locally; this catches everything that is only visible by running.
#
# Only `test` (the four-OS matrix) and `ios` execute on this trigger: the
# release jobs require a `refs/tags/v` ref and the unsigned build jobs require
# `github.event_name == 'workflow_dispatch'`, so neither fires on a schedule.
# GitHub runs scheduled workflows on the DEFAULT BRANCH only, and disables the
# schedule after 60 days without repository activity.
schedule:
- cron: '17 3 * * *' # 03:17 UTC daily — off the hour, which is peak queueing
workflow_dispatch:
inputs:
build_unsigned_windows:
description: 'Build SHA/run-labelled unsigned Windows test artifacts. Never publishes a release.'
required: true
type: boolean
default: false
build_unsigned_linux:
description: 'Build SHA/run-labelled unsigned Linux test artifacts. Never publishes a release.'
required: true
type: boolean
default: false
unsigned_build_ref:
description: 'Optional exact source ref for unsigned artifact recovery. Defaults to the dispatched workflow SHA.'
required: false
type: string
default: ''
jobs:
test:
name: Test (${{ matrix.name }})
runs-on: ${{ matrix.os }}
env:
NODE_OPTIONS: --max-old-space-size=4096
strategy:
fail-fast: false
matrix:
include:
- name: Linux
os: ubuntu-latest
- name: macOS Apple Silicon
os: macos-15
- name: macOS Intel
os: macos-15-intel
- name: Windows
os: windows-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
# Configuration-honest breadcrumb for the platform-evidence loop: records
# which matrix leg and runner host executed this job. Does not prove local
# countermeasures; the local platform-evidence guard only checks that this
# step still exists and never claims remote legs ran.
- name: Record runner identity
shell: bash
run: |
set -euo pipefail
echo "matrix.name=${{ matrix.name }}"
echo "matrix.os=${{ matrix.os }}"
echo "runner.os=${{ runner.os }}"
echo "runner.arch=${{ runner.arch }}"
echo "runner.name=${{ runner.name }}"
uname -a || true
node -p "'node ' + process.version + ' ' + process.platform + '/' + process.arch"
- run: npm run ci
# The native macOS bridge daemon gates Screen Watch / creative-app /
# editor actions, so its Swift unit tests must pass before changes land.
# The bridge is macOS-only and needs the Xcode Swift toolchain
# (preinstalled on GitHub macOS runners), so this step is gated to the
# macOS legs of the matrix and skipped on Linux/Windows. `swift test`
# exits non-zero on failure, which fails this job.
# Pin the same Xcode the ios job and the ship toolchain use. The runner
# default is newer, and Swift 6.3 promotes region-isolation sending
# diagnostics to ERRORS that 6.2 only warns on -- so an unpinned lane
# fails on latent concurrency debt in TaskWraithStudioCore rather than on
# a regression, and tests a compiler we do not ship with.
- name: Select Xcode 26.0 (Swift 6.2 - matches ship toolchain)
if: runner.os == 'macOS'
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: '26.0'
- name: Swift bridge tests (macOS only)
if: runner.os == 'macOS'
run: |
swift --version
npm run test:swift:bridge
# iOS companion gate — the Electron matrix can't see iOS regressions, and the
# phone is the monetized launch surface. macOS 26 runner for the iOS 26 SDK
# (TaskWraithUI uses `.glassEffect`), with Xcode PINNED to 26.0 (Swift 6.2) to
# MATCH the shipping toolchain: the runner's default Xcode 26.5 (Swift 6.3.2)
# enforces region-isolation `sending` diagnostics as errors that local
# Swift 6.2.4 does not, so an unpinned lane would fail on latent concurrency
# debt rather than real regressions. (TODO: clear that Swift-6.3 region-
# isolation debt in RemoteSessionModel before bumping the pin.)
ios:
name: iOS (Kit tests)
runs-on: macos-26
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Available Xcodes (pre-pin diagnostic)
run: ls -d /Applications/Xcode*.app
- name: Select Xcode 26.0 (Swift 6.2 — matches ship toolchain)
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: '26.0'
- name: Toolchain versions
run: xcodebuild -version && swift --version
# `swift test` builds TaskWraithKit + TaskWraithUI (all the SwiftUI/Glass
# code) for the host and runs the 73 unit tests — the real regression
# gate. An app-target `xcodebuild` is intentionally NOT run here: the
# pinned Xcode 26.0 lacks the iOS simulator PLATFORM (GitHub pre-installs
# it only for the runner's default Xcode 26.5), and `-downloadPlatform iOS`
# would add minutes + GBs per run just to compile the thin app wrapper.
- name: Swift package tests (TaskWraithKit)
run: swift test --package-path ios/TaskWraithKit
notarized-macos-release:
name: Notarized macOS Release
runs-on: macos-15
needs: [test, ios]
outputs:
artifact-name: ${{ steps.release-artifact-name.outputs.name }}
if: startsWith(github.ref, 'refs/tags/v') && vars.ENABLE_MACOS_NOTARIZED_RELEASE == 'true'
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- name: Verify tag and release metadata
run: npm run verify:release-tag
- run: npm run security:deps
- name: Prepare macOS artifacts without signing credentials
run: |
npm run clean:dist
npm run test:swift:bridge
TASKWRAITH_BRIDGE_ARCH=universal npm run prebuild:bridge-daemon
node scripts/smoke-bridge-daemon-roundtrip.cjs
npm run prepare:mac-universal-deps
npm run prepare:tui-runtime:mac
npm run build
- name: Sign, package, and notarize macOS artifacts
env:
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
CSC_NAME: ${{ secrets.MACOS_CSC_NAME }}
APPLE_KEYCHAIN_PROFILE: ${{ secrets.APPLE_KEYCHAIN_PROFILE }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: >-
node scripts/run-electron-builder.cjs --mac --universal --publish never
-c.npmRebuild=false -c.mac.notarize=true
- name: Finalize exact macOS release artifacts
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run finalize:mac-release-artifacts
- name: Run post-package macOS checks without signing credentials
env:
TASKWRAITH_FORCE_LAUNCH_SMOKE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGED_HOST: '1'
TASKWRAITH_REQUIRE_PACKAGED_SECRET_SCAN: '1'
run: |
node scripts/smoke-packaged-electron.cjs dist
npm run smoke:mac-artifacts
node scripts/guard-no-bundled-secrets.cjs --require-packaged
npm run validate:mac-update-feed
npm run security:sbom
- name: Verify notarized macOS artifacts
run: |
set -euo pipefail
test -d "dist/mac-universal/TaskWraith.app"
codesign --verify --deep --strict --verbose=2 "dist/mac-universal/TaskWraith.app"
spctl --assess --type execute --verbose=4 "dist/mac-universal/TaskWraith.app"
xcrun stapler validate "dist/mac-universal/TaskWraith.app"
for artifact in dist/*.dmg; do
test -f "$artifact"
xcrun stapler validate "$artifact"
done
- name: Record macOS candidate artifact name
id: release-artifact-name
run: echo "name=release-macos-${GITHUB_REF_NAME}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Stage verified macOS release artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.release-artifact-name.outputs.name }}
path: |
dist/*.dmg
dist/*.zip
dist/*-universal-mac.zip.blockmap
dist/*-mac.yml
dist/sbom.cdx.json
if-no-files-found: error
retention-days: 7
signed-windows-release:
name: Signed Windows Release
runs-on: windows-latest
needs: [test, ios]
outputs:
artifact-name: ${{ steps.release-artifact-name.outputs.name }}
if: startsWith(github.ref, 'refs/tags/v') && vars.ENABLE_WINDOWS_SIGNED_RELEASE == 'true'
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- name: Verify tag and release metadata
run: npm run verify:release-tag
- run: npm run security:deps
- name: Compile Windows artifacts without signing credentials
run: npm run build:win:compile
- name: Sign and package Windows artifacts
env:
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
shell: pwsh
run: |
node scripts/require-windows-signing-env.cjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& .\node_modules\.bin\electron-builder.cmd --win --x64 --arm64 --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Run post-package Windows checks without signing credentials
run: npm run build:win:checks
- name: Run strict native Windows package and TUI smoke
shell: pwsh
env:
TASKWRAITH_FORCE_LAUNCH_SMOKE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGED_HOST: '1'
run: |
node scripts/smoke-packaged-electron.cjs dist/win-unpacked
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Scan final Windows package payloads for bundled secrets
run: node scripts/guard-no-bundled-secrets.cjs --require-packaged
- name: Generate Windows SBOM with embedded runtime components
env:
TASKWRAITH_SBOM_PATH: dist/sbom-windows.cdx.json
run: npm run security:sbom
- name: Verify signed Windows artifacts
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$installers = Get-ChildItem -Path dist -Filter "TaskWraith-*-win-*-setup.exe"
if ($installers.Count -lt 2) {
throw "Expected signed x64 and arm64 Windows installers in dist/."
}
foreach ($installer in $installers) {
$signature = Get-AuthenticodeSignature -FilePath $installer.FullName
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($installer.Name): $($signature.Status)"
}
}
$version = (Get-Content package.json | ConvertFrom-Json).version
$feedPrefix = if ($version -match "-") { "beta" } else { "latest" }
foreach ($arch in @("x64", "arm64")) {
$feed = "dist/$feedPrefix-win-$arch.yml"
if (!(Test-Path $feed)) {
throw "Missing $feed"
}
}
- name: Smoke signed Windows installer lifecycle
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$installer = Get-ChildItem -Path dist -Filter "TaskWraith-*-win-x64-setup.exe" |
Select-Object -First 1
if (!$installer) {
throw "Missing signed x64 installer for lifecycle smoke."
}
& .\scripts\smoke-win-installer.ps1 -InstallerPath $installer.FullName
- name: Record Windows candidate artifact name
id: release-artifact-name
shell: pwsh
run: |
$name = "release-windows-$env:GITHUB_REF_NAME-$env:GITHUB_RUN_ATTEMPT"
Add-Content -Path $env:GITHUB_OUTPUT -Value "name=$name"
- name: Stage verified Windows release artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.release-artifact-name.outputs.name }}
path: |
dist/TaskWraith-*-win-*-setup.exe
dist/TaskWraith-*-win-*-setup.exe.blockmap
dist/*-win-*.yml
dist/sbom-windows.cdx.json
dist/win-arm64-unpacked/**
if-no-files-found: error
retention-days: 7
windows-arm64-release-candidate:
name: Windows ARM64 Release Candidate Gate
runs-on: windows-11-arm
needs: [signed-windows-release]
if: startsWith(github.ref, 'refs/tags/v') && vars.ENABLE_WINDOWS_SIGNED_RELEASE == 'true'
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- name: Download signed Windows candidate
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ needs.signed-windows-release.outputs.artifact-name }}
path: candidate
- name: Verify native ARM64 signatures
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$installer = Get-ChildItem candidate -Filter "TaskWraith-*-win-arm64-setup.exe" |
Select-Object -First 1
if (!$installer) {
throw "Missing signed ARM64 installer."
}
foreach ($path in @(
$installer.FullName,
(Join-Path $PWD "candidate/win-arm64-unpacked/TaskWraith.exe")
)) {
$signature = Get-AuthenticodeSignature -FilePath $path
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for ${path}: $($signature.Status)"
}
}
- name: Run strict native ARM64 package and TUI smoke
shell: pwsh
env:
TASKWRAITH_FORCE_LAUNCH_SMOKE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGED_HOST: '1'
run: |
node scripts/smoke-packaged-electron.cjs candidate/win-arm64-unpacked
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Smoke signed ARM64 installer lifecycle
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$installer = Get-ChildItem candidate -Filter "TaskWraith-*-win-arm64-setup.exe" |
Select-Object -First 1
if (!$installer) {
throw "Missing signed ARM64 installer."
}
& .\scripts\smoke-win-installer.ps1 `
-InstallerPath $installer.FullName `
-InstallDir (Join-Path $env:TEMP "TaskWraithArm64SmokeInstall")
publish-coordinated-release:
name: Publish Coordinated Release
runs-on: ubuntu-latest
needs:
- notarized-macos-release
- signed-windows-release
- windows-arm64-release-candidate
# Credentialed CI publication is permanently inoperative. Stable releases
# use the local signed/notarized macOS build plus manually attached unsigned
# Windows/Linux artifacts.
if: ${{ false }}
concurrency:
group: release-publish-${{ github.ref }}
cancel-in-progress: false
permissions:
actions: read
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- name: Download verified macOS artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ needs.notarized-macos-release.outputs.artifact-name }}
path: release-assets/mac
- name: Download verified Windows artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ needs.signed-windows-release.outputs.artifact-name }}
path: release-assets/windows
- name: Atomically publish all verified platform artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
npm run verify:release-tag
version="$(node -p "require('./package.json').version")"
release_title="TaskWraith v${version}"
notes_path="release-assets/RELEASE_NOTES-${version}.md"
node scripts/prepare-release-notes.cjs "$version" "$notes_path"
channel="latest"
prerelease="false"
create_flags=(--draft)
if [[ "$version" == *-* ]]; then
channel="beta"
prerelease="true"
create_flags+=(--prerelease)
fi
shopt -s nullglob
mac_dmg=(release-assets/mac/TaskWraith-"$version"-universal-mac.dmg)
mac_zip=(release-assets/mac/TaskWraith-"$version"-universal-mac.zip)
mac_blockmaps=(release-assets/mac/TaskWraith-"$version"-universal-mac.zip.blockmap)
mac_feed=(release-assets/mac/"$channel"-mac.yml)
mac_sbom=(release-assets/mac/sbom.cdx.json)
win_installers=(
release-assets/windows/TaskWraith-"$version"-win-x64-setup.exe
release-assets/windows/TaskWraith-"$version"-win-arm64-setup.exe
)
win_blockmaps=(
release-assets/windows/TaskWraith-"$version"-win-x64-setup.exe.blockmap
release-assets/windows/TaskWraith-"$version"-win-arm64-setup.exe.blockmap
)
win_feeds=(
release-assets/windows/"$channel"-win-x64.yml
release-assets/windows/"$channel"-win-arm64.yml
)
win_sbom=(release-assets/windows/sbom-windows.cdx.json)
linux_appimage=(release-assets/linux/TaskWraith-"$version".AppImage)
linux_deb=(release-assets/linux/taskwraith_"$version"_amd64.deb)
linux_feed=(release-assets/linux/"$channel"-linux.yml)
linux_sbom=(release-assets/linux/sbom-linux.cdx.json)
for required in \
"${mac_dmg[@]}" \
"${mac_zip[@]}" \
"${mac_blockmaps[@]}" \
"${mac_feed[@]}" \
"${mac_sbom[@]}" \
"${win_installers[@]}" \
"${win_blockmaps[@]}" \
"${win_feeds[@]}" \
"${win_sbom[@]}" \
"${linux_appimage[@]}" \
"${linux_deb[@]}" \
"${linux_feed[@]}" \
"${linux_sbom[@]}"; do
test -f "$required"
done
assets=(
"${mac_dmg[@]}"
"${mac_zip[@]}"
"${mac_blockmaps[@]}"
"${mac_feed[@]}"
"${mac_sbom[@]}"
"${win_installers[@]}"
"${win_blockmaps[@]}"
"${win_feeds[@]}"
"${win_sbom[@]}"
"${linux_appimage[@]}"
"${linux_deb[@]}"
"${linux_feed[@]}"
"${linux_sbom[@]}"
)
node scripts/validate-linux-update-feed.cjs release-assets/linux
checksums_path="release-assets/SHA256SUMS-${version}.txt"
node scripts/write-release-checksums.cjs "$checksums_path" "${assets[@]}"
assets+=("$checksums_path")
declare -A asset_paths=()
for asset in "${assets[@]}"; do
name="$(basename "$asset")"
if [[ -n "${asset_paths[$name]:-}" ]]; then
echo "Duplicate release asset name: $name"
exit 1
fi
asset_paths[$name]="$asset"
done
mapfile -t expected_assets < <(
for asset in "${assets[@]}"; do basename "$asset"; done | sort
)
release_tag="$GITHUB_REF_NAME"
candidate_sha="$(git rev-parse HEAD)"
verify_remote_release_tag() {
local tag_ref="refs/tags/$release_tag"
local tag_type
local remote_tag_sha
git fetch --force --no-tags origin "+$tag_ref:$tag_ref"
tag_type="$(git cat-file -t "$tag_ref")"
if [ "$tag_type" = "tag" ]; then
remote_tag_sha="$(git rev-parse "$tag_ref^{}")"
elif [ "$tag_type" = "commit" ]; then
remote_tag_sha="$(git rev-parse "$tag_ref")"
else
echo "Release tag $tag_ref has unsupported object type $tag_type."
return 1
fi
if [ "$remote_tag_sha" != "$candidate_sha" ]; then
echo "Remote release tag resolves to $remote_tag_sha; expected $candidate_sha."
return 1
fi
}
verify_release_copy() {
local actual_title
local body_json
local body_file
actual_title="$(gh release view "$release_tag" --json name --jq .name)"
if [ "$actual_title" != "$release_title" ]; then
echo "Release title is $actual_title; expected $release_title."
return 1
fi
body_json="$(mktemp)"
body_file="$(mktemp)"
gh release view "$release_tag" --json body > "$body_json"
node -e \
'const fs=require("node:fs");const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));fs.writeFileSync(process.argv[2],data.body||"")' \
"$body_json" "$body_file"
if ! cmp --silent "$notes_path" "$body_file"; then
echo "Release body differs from the prepared CHANGELOG release notes."
return 1
fi
rm -f "$body_json" "$body_file"
}
verify_remote_release_tag
existing_assets=()
if gh release view "$release_tag" >/dev/null 2>&1; then
is_draft="$(gh release view "$release_tag" --json isDraft --jq .isDraft)"
is_prerelease="$(gh release view "$release_tag" --json isPrerelease --jq .isPrerelease)"
if [ "$is_prerelease" != "$prerelease" ]; then
echo "Existing release prerelease state does not match the verified candidate."
exit 1
fi
if [ "$is_draft" != "true" ] && [ "$is_draft" != "false" ]; then
echo "Existing release returned an invalid draft state: $is_draft."
exit 1
fi
verify_release_copy
mapfile -t existing_assets < <(
gh release view "$release_tag" --json assets --jq '.assets[].name' | sort
)
if [ "${#existing_assets[@]}" -gt 0 ]; then
existing_asset_dir="$(mktemp -d)"
trap 'rm -rf "$existing_asset_dir"' EXIT
gh release download "$release_tag" --dir "$existing_asset_dir"
for name in "${existing_assets[@]}"; do
expected_path="${asset_paths[$name]:-}"
if [ -z "$expected_path" ]; then
echo "Existing draft contains unexpected asset $name; refusing mutation."
exit 1
fi
if ! cmp --silent "$expected_path" "$existing_asset_dir/$name"; then
echo "Existing draft asset $name differs from the verified candidate."
exit 1
fi
done
fi
if [ "$is_draft" = "false" ]; then
verify_remote_release_tag
verify_release_copy
mapfile -t fresh_public_assets < <(
gh release view "$release_tag" --json assets --jq '.assets[].name' | sort
)
if [ "${fresh_public_assets[*]}" != "${expected_assets[*]}" ]; then
echo "Existing public release asset set does not match the verified local set."
printf 'Expected: %s\n' "${expected_assets[@]}"
printf 'Remote: %s\n' "${fresh_public_assets[@]}"
exit 1
fi
echo "Existing public release exactly matches the verified candidate; no mutation needed."
exit 0
fi
else
gh release create "$release_tag" \
--verify-tag \
--title "$release_title" \
--notes-file "$notes_path" \
"${create_flags[@]}"
verify_release_copy
fi
verify_remote_release_tag
declare -A existing_names=()
for name in "${existing_assets[@]}"; do
existing_names[$name]=1
done
missing_assets=()
for asset in "${assets[@]}"; do
name="$(basename "$asset")"
if [[ -z "${existing_names[$name]:-}" ]]; then
missing_assets+=("$asset")
fi
done
if [ "${#missing_assets[@]}" -gt 0 ]; then
gh release upload "$release_tag" "${missing_assets[@]}"
fi
mapfile -t remote_assets < <(
gh release view "$release_tag" --json assets --jq '.assets[].name' | sort
)
if [ "${remote_assets[*]}" != "${expected_assets[*]}" ]; then
echo "Remote release asset set does not match the verified local set."
printf 'Expected: %s\n' "${expected_assets[@]}"
printf 'Remote: %s\n' "${remote_assets[@]}"
exit 1
fi
verify_remote_release_tag
verify_release_copy
gh release edit "$release_tag" --draft=false
unsigned-windows-build:
name: Unsigned Windows Build (testing)
runs-on: windows-latest
needs: [test, ios]
if: github.event_name == 'workflow_dispatch' && inputs.build_unsigned_windows == true
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.unsigned_build_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Record unsigned artifact source SHA
id: unsigned-source
shell: bash
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- run: npm run security:deps
# Keep packaging sourced entirely from the selected ref. For recovery of
# an older exact release SHA, overlay only the current CI smoke harness
# after electron-builder has finished creating the installers.
- run: npm run build:win:compile
- run: npx electron-builder --win --x64 --arm64 --publish never
- name: Check out current Windows smoke harness for exact-ref recovery
if: steps.unsigned-source.outputs.sha != github.sha
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.sha }}
path: .ci-release-harness
sparse-checkout: |
scripts/smoke-packaged-tui.cjs
scripts/windows-cmd-invocation.cjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Overlay current Windows smoke harness after packaging
if: steps.unsigned-source.outputs.sha != github.sha
shell: pwsh
run: |
Copy-Item -LiteralPath .ci-release-harness\scripts\smoke-packaged-tui.cjs -Destination scripts\smoke-packaged-tui.cjs -Force
Copy-Item -LiteralPath .ci-release-harness\scripts\windows-cmd-invocation.cjs -Destination scripts\windows-cmd-invocation.cjs -Force
# The packaged-app launch smoke skips the arm64 executable on this x64
# runner because it cannot execute it natively.
- run: npm run build:win:checks
- name: Upload SHA/run-labelled unsigned Windows test artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unsigned-windows-testing-${{ steps.unsigned-source.outputs.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
dist/TaskWraith-*-win-*-setup.exe
dist/TaskWraith-*-win-*-setup.exe.blockmap
dist/*-win-*.yml
if-no-files-found: error
retention-days: 14
unsigned-linux-build:
name: Unsigned Linux Build (testing)
runs-on: ubuntu-latest
needs: [test, ios]
if: github.event_name == 'workflow_dispatch' && inputs.build_unsigned_linux == true
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.unsigned_build_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Record unsigned artifact source SHA
id: unsigned-source
shell: bash
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.23.2'
cache: npm
- run: npm ci
- run: npm run security:deps
# AppImage + deb only — snap needs snapcraft (absent on the default
# runner). `--publish never` keeps this manual testing lane outside the
# GitHub Release publication surface.
- name: Build and strictly smoke unsigned Linux artifacts
env:
TASKWRAITH_FORCE_LAUNCH_SMOKE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGE: '1'
TASKWRAITH_TUI_REQUIRE_PACKAGED_HOST: '1'
TASKWRAITH_REQUIRE_PACKAGED_SECRET_SCAN: '1'
run: xvfb-run -a npm run build:linux:nopublish
- name: Upload SHA/run-labelled unsigned Linux test artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unsigned-linux-testing-${{ steps.unsigned-source.outputs.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
dist/*.AppImage
dist/*.deb
dist/latest-linux*.yml
if-no-files-found: error
retention-days: 14