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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# Exact upstream license bytes are pinned in provenance.json.
scripts/desktop/legal/** -text
53 changes: 50 additions & 3 deletions .github/workflows/desktop-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
inputs:
release_version:
description: 'Optional intended stable numeric version; prepares an unpublished candidate'
required: false
type: string

permissions:
contents: read
Expand Down Expand Up @@ -51,16 +56,29 @@ jobs:
shell: powershell
run: |
./scripts/desktop/acceptance-windows.ps1 -Repository $env:GITHUB_WORKSPACE -Node (Get-Command node.exe).Source -TestsOnly
- name: Validate requested version and packaging policies
env:
RC_VERSION: ${{ inputs.release_version }}
run: |
node -e "if(process.env.RC_VERSION && !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(process.env.RC_VERSION)) process.exit(1)"
node --test tests/desktop/distribution.test.mjs tests/desktop/licenses.test.mjs tests/desktop/supplemental-licenses.test.mjs tests/desktop/portable-policy.test.mjs tests/desktop/archive-policy.test.mjs
- name: Build from clean committed source
run: node scripts/desktop/build.mjs
- name: Package native runtime with pinned Node archive verification
run: node scripts/desktop/package.mjs ci-${{ github.sha }}
- name: Package native runtime from outside the repository (pinned Node verification)
env:
RC_VERSION: ${{ inputs.release_version }}
run: node -e "require('node:child_process').execFileSync(process.execPath,[require('node:path').join(process.env.GITHUB_WORKSPACE,'scripts/desktop/package.mjs'),process.env.RC_VERSION || 'ci-' + process.env.GITHUB_SHA],{cwd:require('node:os').tmpdir(),stdio:'inherit'})"
- name: Independently inspect the actual archive and required notice bytes
run: node scripts/desktop/distribution-policy.mjs check build/desktop/${{ matrix.platform }} ${{ matrix.platform }}
- name: Install acceptance browser on Linux
if: runner.os == 'Linux'
run: pnpm exec playwright install --with-deps chromium
- name: Install acceptance browser on Windows
if: runner.os == 'Windows'
run: pnpm exec playwright install chromium
- name: Linux download-arrangement acceptance
if: runner.os == 'Linux'
run: node tests/desktop/distribution-acceptance.mjs build/desktop/linux-x64
- name: Linux installer and packaged app acceptance
if: runner.os == 'Linux'
run: node tests/desktop/acceptance.mjs build/desktop/linux-x64
Expand All @@ -78,4 +96,33 @@ jobs:
name: examify-solo-${{ matrix.platform }}-${{ github.sha }}
path: build/desktop/${{ matrix.platform }}/
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.release_version && 90 || 7 }}

assemble-candidate:
name: Assemble unpublished matched candidate
if: github.event_name == 'workflow_dispatch' && inputs.release_version != ''
needs: package-and-accept
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22.22.2
- uses: actions/download-artifact@v8
with:
name: examify-solo-linux-x64-${{ github.sha }}
path: build/desktop/linux-x64
- uses: actions/download-artifact@v8
with:
name: examify-solo-win32-x64-${{ github.sha }}
path: build/desktop/win32-x64
- name: Assemble exact tested platform assets (never publish or tag)
run: node scripts/desktop/distribution.mjs assemble build/desktop build/release-candidate
- uses: actions/upload-artifact@v7
with:
name: examify-candidate-${{ inputs.release_version }}-${{ github.sha }}
path: build/release-candidate/
if-no-files-found: error
retention-days: 90
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ test-results
pnpm-lock.yaml
src/lib/db/migrations
content/generated/**/*.json
# Preserve exact upstream license bytes; provenance hashes verify them.
scripts/desktop/legal/**
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1381,3 +1381,16 @@ SHA-256 pins authenticate bytes relative to the trusted installer distribution;
they are not a publisher-signature system. Windows directory durability cannot
be established using these ordinary Node APIs; process-crash acceptance must
not be reported as physical-power-loss proof.

### Portable-only image optimization exclusion

`scripts/desktop/build.mjs` selects `EXAMIFY_PORTABLE_BUILD=1` only for its build
subprocess. The default hosted configuration remains unchanged. Portable builds
serialize `images.unoptimized=true` and exclude Sharp/@img from route and
`next-server` traces. Packaging verifies actual serialized config, package paths,
module links and native binaries; a post-archive gate independently extracts and
rechecks them before CI can upload binaries. A metadata label alone is insufficient.
The current application uses neither Sharp nor Next Image. PDF/text upload storage
and provider/optional pdftoppm source processing are independent. Reintroducing an
image/native dependency requires explicit portable behavior and notice/source
review; do not weaken the gate to make a package build pass.
104 changes: 104 additions & 0 deletions docs/release-candidate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Windows/Linux release candidate

This procedure prepares evidence; it does not approve tagging or publication.
Issue [#119](https://github.com/atk0309/project_Examify/issues/119) stays open until
its release gates are satisfied. Windows/Linux x64 only, with no macOS/ARM claim.

## Portable image policy

The desktop builder sets `EXAMIFY_PORTABLE_BUILD=1` for its build subprocess only.
This disables Next's unused image optimization endpoint and excludes Sharp/@img
from standalone server and route traces. The application has no direct Sharp or
Next Image dependency: uploads retain validated PDF/text bytes, optional PDF page
rasterization uses external `pdftoppm`, and AI source images are read as bytes.
Hosted/default builds retain their existing configuration and image optimizer.
Portable builds do not provide automatic image resizing, compression or format
conversion. Future use of Next Image needs an explicit portable fallback/review.

Packaging must verify the serialized standalone server really has optimization
disabled, and inspect actual files, runtime links and package/native inventories.
The distribution gate independently extracts the completed archive and repeats
those checks, comparing internal identity/policy to its hashed sidecars. It also
checks every retained evidence file against its recorded hash, compares shipped
package manifests with the inventory, and checks exact supplemental notice bytes.
Embedded-only helper dependencies are identified by their retained source manifest,
not an invented runtime directory. CI packages from outside the repository to test
bundle-input path resolution. CI cannot upload the app archive if this gate or native
acceptance fails. There is no environment/input bypass.

The former libvips source/replacement gate is inapplicable only when the archive
inspection proves those unused native image packages are absent. Reintroducing
them or another unreviewed native library fails closed and requires a new notice,
source/replacement review. SQLite's native module and the private Node runtime
remain; their licenses/notices are retained. This is technical evidence, not a
complete SBOM, independent publisher authentication or legal certification.

## Freeze and preserve one candidate

1. Choose a reviewed clean source commit. Record its full SHA and successful full
CI/CodeQL run URLs for that exact commit, not a nearby PR merge-ref badge
2. Manually run **Solo desktop preview** with `release_version` set to the intended
stable numeric version, for example `0.1.0`. Candidate status belongs in the
evidence record: safe upgrades deliberately do not order `ci-*` or prerelease
labels. Never reuse a version previously used for a legacy preview
3. Both native package/browser/upgrade jobs and candidate assembly must pass.
The combined set contains Windows CMD/PowerShell/zip, Linux shell/tar.gz,
matching archive checksums, platform identities/runtime policies, complete
checksum lists and `release-set.json`. Source, lockfile, pinned Node and all
installer/archive bytes are recorded. Never replace tested bytes silently
4. Preserve the complete unmodified bundle in durable maintainer-controlled
storage before expiry. Actions retention is **90 days** for candidates, seven
for previews, and is not permanent hosting. Record run/artifact URLs, expiry
and durable storage privately; do not publish private paths or credentials
5. Verify `SHA256SUMS` after download (`sha256sum --check SHA256SUMS` on Linux;
compare `Get-FileHash -Algorithm SHA256` on Windows). Retain evidence separately
from the download location. Changed bytes require a new candidate/test record

The local integrity tools never tag, publish or overwrite an existing candidate:

```sh
node scripts/desktop/distribution-policy.mjs check build/desktop/linux-x64 linux-x64
node scripts/desktop/distribution-policy.mjs check build/desktop/win32-x64 win32-x64
node scripts/desktop/distribution.mjs assemble build/desktop build/release-candidate
```

Run each native archive policy check on its own platform. Assembly relies on both
already successful native jobs; it checks their shared version/source/lockfile.
It creates an unapproved candidate, not a publication authorization.

## Remaining consumer, security and trust gates

CI targets Ubuntu 22.04 and Windows Server 2022. These are automation environments,
not proven minimum consumer OS/browser requirements. Record actual OS edition,
version/build, architecture, filesystem, desktop and browser version for clean
ordinary-user consumer tests without development tools installed:

- Actual downloaded installer/bootstrap paths, offline archive installation and
corrupt/wrong-version rejection. The automated local mirror changes only its
private installer copies' HTTPS origin; it does not prove GitHub TLS/CDN or
reputation behavior. Production installers retain fixed HTTPS-only URLs
- Real browser launch, Start-menu/Linux application shortcut and shortcut relaunch;
browser callbacks and `--no-shortcut` automation are not substitutes
- Deterministic sample/resume, repeated authoring using approved offline fixtures,
restart/crash recovery and upgrade/full-folder restore. Process interruption
does not establish physical-power-loss durability
- Final private-data/secret inventory, retained license notices and remaining
mapping/review notes in `THIRD-PARTY-LICENSES.json`. The exact missing dotenv BSD
notices are retained using hash-pinned upstream provenance; no source offer or
unrelated native-license obligation is asserted for excluded libraries
- Current locked production audit and native/embedded security triage. Record date,
tool/source identity and findings; a failed Dependabot update alone is not a
vulnerability, and package-manager audit is not complete binary analysis

Artifacts remain unsigned. Checksums establish consistency with the chosen
installer; an attacker replacing both installer and archive can replace hashes.
Before publication, the maintainer must approve unsigned distribution or a
separate signing plan. Record real browser download, Mark-of-the-Web, SmartScreen
and PowerShell behavior. The CMD installer's existing process-local execution
policy option is not a signature or permission to circumvent organizational
policy. Stop at OS/browser security warnings; do not disable system protection.

Publication requires separate approval after exact asset hashes, CI links,
consumer results and trust decision are recorded. Publish the exact tested asset
set under its pinned version, then verify remotely downloaded hashes/bootstrap.
Leave #119 open for any uncompleted manual or published-route checks.
14 changes: 14 additions & 0 deletions docs/solo-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,17 @@ The `.examify-operations` databases are coordination files, outside study state.
Do not remove them while an operation is active. Stale process markers after a
crash do not require manual deletion. The household `examify:data` backup command
does not capture this complete solo layout; use the full-folder backup above.

## Portable image behavior and release evidence

Portable Windows/Linux builds omit Next's unused automatic image optimizer and
its Sharp/libvips dependencies. Existing practice, raw PDF/text uploads and AI
source processing retain their separate paths; optional PDF rasterization still
uses external `pdftoppm`. Portable installs do not offer automatic image resizing,
compression or format conversion. Hosted/default builds are unchanged.

Portable artifacts remain previews until the [release gate](release-candidate.md)
is completed. CI runner coverage does not establish minimum consumer OS/browser
support. Actual shortcut/browser opening and download-warning checks are still
required. Installers are unsigned; checksums do not independently prove publisher
identity. Never disable system security to run an installer.
16 changes: 16 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const portable = process.env.EXAMIFY_PORTABLE_BUILD === '1';
const projectRoot = path.dirname(fileURLToPath(import.meta.url));

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
// The desktop builder alone selects this mode. Hosted/default builds keep
// Next's normal image optimizer and tracing behavior.
...(portable
? {
images: { unoptimized: true },
outputFileTracingExcludes: {
'**/*': [
'node_modules/sharp/**/*',
'node_modules/@img/**/*',
'node_modules/.pnpm/sharp@*/**/*',
'node_modules/.pnpm/@img+*/**/*',
],
},
}
: {}),
outputFileTracingIncludes: {
'/*': ['node_modules/better-sqlite3/**/*'],
},
Expand Down
7 changes: 7 additions & 0 deletions scripts/desktop/acceptance-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ try {
$WorkerTests = Join-Path $Repository 'tests/desktop/worker-lock.test.mjs'
$RelocatedTests = Join-Path $Repository 'tests/desktop/relocated-helpers.test.mjs'
$UpgradeAcceptance = Join-Path $Repository 'tests/desktop/upgrade-acceptance.mjs'
$DistributionAcceptance = Join-Path $Repository 'tests/desktop/distribution-acceptance.mjs'
$InventoryTests = Join-Path $Repository 'tests/desktop/windows-inventory.test.mjs'
$UserTemp = Join-Path $Root 'UserTemp'
$RunAcceptance = if ($TestsOnly) { '$false' } else { '$true' }
Expand All @@ -59,6 +60,12 @@ try {
& $(Quote $Node) --test $(Quote $UnitTests) $(Quote $InventoryTests) $(Quote $UpgradeTests) $(Quote $WorkerTests) $(Quote $RelocatedTests) *> $(Quote $Result)
`$Code = `$LASTEXITCODE
`$ErrorActionPreference = 'Stop'
if (`$Code -eq 0 -and $RunAcceptance) {
`$ErrorActionPreference = 'Continue'
& $(Quote $Node) $(Quote $DistributionAcceptance) $(Quote $Artifact) *>> $(Quote $Result)
`$Code = `$LASTEXITCODE
`$ErrorActionPreference = 'Stop'
}
if (`$Code -eq 0 -and $RunAcceptance) {
`$ErrorActionPreference = 'Continue'
& $(Quote $Node) $(Quote $Accept) $(Quote $Artifact) *>> $(Quote $Result)
Expand Down
15 changes: 13 additions & 2 deletions scripts/desktop/archive.ps1
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
param(
[Parameter(Mandatory = $true)][ValidateSet('extract', 'create')][string]$Operation,
[Parameter(Mandatory = $true)][ValidateSet('extract', 'create', 'verify-extract')][string]$Operation,
[Parameter(Mandatory = $true)][string]$Source,
[Parameter(Mandatory = $true)][string]$Destination
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
if ($Operation -eq 'extract') { [IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination) }
if ($Operation -eq 'verify-extract') {
$Zip = [IO.Compression.ZipFile]::OpenRead($Source)
try {
foreach ($Entry in $Zip.Entries) {
$Name = $Entry.FullName.Replace('\', '/')
$Type = ($Entry.ExternalAttributes -shr 16) -band 0xF000
if ($Name.StartsWith('/') -or $Name -match '[:\x00-\x1f]' -or $Name.Split('/') -contains '..' -or $Name -match '(^|/)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])([./]|$)' -or $Name -match '[ .](/|$)' -or $Type -notin @(0, 0x4000, 0x8000)) { throw 'Unsafe archive member.' }
}
} finally { $Zip.Dispose() }
[IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination)
}
elseif ($Operation -eq 'extract') { [IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination) }
else { [IO.Compression.ZipFile]::CreateFromDirectory($Source, $Destination) }
4 changes: 2 additions & 2 deletions scripts/desktop/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ fs.rmSync(path.join(root, '.next'), { recursive: true, force: true });
execFileSync(process.execPath, [require.resolve('next/dist/bin/next'), 'build'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', EXAMIFY_PORTABLE_BUILD: '1' },
});
if (git('status', '--porcelain') || git('rev-parse', 'HEAD') !== commit)
throw new Error('Source changed during the build.');
fs.writeFileSync(
path.join(root, '.next/desktop-build.json'),
JSON.stringify({ commit, clean: true }),
JSON.stringify({ commit, clean: true, imageOptimization: 'disabled' }),
);
Loading
Loading