* Add Docker support for self-hosting the sync server
Provide an official Docker image for self-hosting the Hammer sync server,
alongside the existing Java executable distribution.
- docker/Dockerfile: slim, non-root runtime image on a glibc base
(eclipse-temurin:21-jre-jammy). Runs unprivileged because the embedded
PostgreSQL binaries refuse to run as root, and stays on glibc because those
binaries are not musl-compatible. Pins user.home to /data so a single volume
holds the database, caches, keyring, and config. The image packages the
pre-built application distribution rather than compiling from source, since
:server depends on :base and a source build would need the Android SDK.
- docker/docker-compose.yml, config.example.toml, README.md: turnkey
self-hosting with a data volume and optional host-managed config.
- .github/workflows/publish-docker.yml: builds the distribution and publishes a
multi-arch (amd64/arm64) image to GHCR on release. One build serves both
arches because the distribution is pure JVM bytecode with the embedded
PostgreSQL binaries for every OS/arch bundled inside the jars.
- .dockerignore: trims the build context to just the built distribution.
- docs/HOW-TO-RUN-A-SERVER.md: document the Docker path and drop the stale
"Eventually we'll add Docker images" note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ
* Docker: pre-create data dir so config bind mount works unprivileged
Bind-mounting a config file at /data/hammer_data/config.toml would make Docker
create the parent dir as root when it doesn't already exist, leaving the
non-root server unable to write pgdata. Pre-create and chown /data/hammer_data
in the image so a named volume initializes with it hammer-owned, and clarify the
config provisioning paths in the Docker README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ
* Docker: configurable host port and a BOM caveat for config.toml
Both found while testing the image end to end on Windows.
Compose merges `ports` lists by appending, so the hardcoded 8080 mapping could
not be overridden from an override file. Drive it from HAMMER_HTTP_PORT instead,
which is the usual escape hatch when the host port is already taken.
A config.toml saved as UTF-8 with a BOM fails with an UnexpectedTokenException
pointing at line 1, which reads like a syntax error in the file. Notepad and
PowerShell's Out-File -Encoding utf8 both write one, so call it out.
* Docker: note the one-time GHCR package visibility step
The package is created automatically on first publish, but may land private,
which would break the anonymous docker pull the docs point self-hosters at.
Left as a manual step because making a package public cannot be undone.
* Docker: note why the packages:write permission block is required
The repo default workflow token is read-only, so the explicit block is what
makes the GHCR push work rather than 403.
* Docker: document external PostgreSQL, add init for orphan reaping
Remote storage already worked (the DI picks RemotePostgresDatabase and the
schema initializer runs on first connect), but nothing in the Docker setup
showed how to use it. Add a commented-out postgres service with the matching
depends_on, and a README section covering it.
The service mounts its volume at /var/lib/postgresql: postgres 18+ images abort
startup when the mount is at the older /var/lib/postgresql/data path.
Call out that storage.remote.useSsl defaults to true, which fails against a
plain postgres container that serves no TLS.
init: true reaps orphans from the embedded PostgreSQL process tree. Shutdown was
already graceful without it - the start script execs, so the JVM is PID 1 and
runs its shutdown hooks on SIGTERM - but the JVM does not reap orphans.
* Docker: split out a dedicated hosting doc and fix review findings
Move the Docker hosting guide to docs/HOW-TO-RUN-A-SERVER-DOCKER.md and reduce
the inline section in the main guide, plus docker/README.md, to pointers so the
three cannot drift apart.
Fixes:
- Pin user.home through SERVER_OPTS rather than JAVA_OPTS. The start script
appends both, so an operator setting JAVA_OPTS for heap was silently moving the
data directory to /home/hammer, stranding the volume and starting an empty
database.
- Publish the plain HTTP port on 127.0.0.1 by default, overridable with
HAMMER_HTTP_BIND. Published ports bypass host firewall rules, so the previous
0.0.0.0 default could put cleartext credentials on the internet.
- Warn that bindHosts must not be set under Docker; it binds the container
loopback, leaving the server unreachable but still reporting healthy.
- Gate the release trigger on the +server tag convention so client-store-only
releases no longer republish and move latest.
- Add a ref input so a dispatch that names a version builds that ref.
- Correct the claim that cert paths resolve relative to the data directory, and
document that renewals need a container restart.
- generate-keyring example now writes to the volume with --out.
- storage.remote host is postgres, matching the compose service name.
Also trims the narrating comments across the Dockerfile, compose file, and
workflow.
* Docker: document running the admin CLI subcommands in a container
The embedded PostgreSQL holds an exclusive lock on pgdata, so subcommands that
read the database (prune-key --role content, --converge-dry-run) cannot run
while the server container is up. It fails safely rather than corrupting
anything, but the operator has to stop the server first, and nothing said so.
Adds a table of which commands need the database, the stop/run/start sequence,
and a key-rotation walkthrough noting that a rotated keyring only takes effect
on restart. The lock is embedded-specific; remote storage has no such
constraint.
* Docs: drop em dashes from the Docker hosting guide
* Docs: drop em dashes from the server hosting guide
* Remove PageSpeed PDFs accidentally added to this branch
These were untracked working-tree files swept in by a `git add -A`; they are
unrelated to the Docker work and stay on disk.
* Pin the docker/* actions to commit SHAs
Matches how every other third-party action in this repo is referenced, and
clears the supply-chain findings Codacy raised on the PR. Kept within the major
versions the workflow was written against rather than moving to the newer
majors, since the workflow has not run yet.
* Clarify why +server releases publish the Docker image
---------
Co-authored-by: Claude <noreply@anthropic.com>
Fix Mac App Store publish hanging forever on build processing
The Mac App Store release job hung indefinitely on "Publish to App Store",
spinning until Actions' 6h default killed the (10x-billed) macOS runner:
Waiting for App Store Connect to finish processing the new build
(3.7.0 - 24) for MAC_OS
Root cause: the release lane ran upload_to_testflight with the build-processing
wait enabled. That wait lives in FastlaneCore::BuildWatcher, which polls each
build's processingState until it reads complete. For macOS builds the App Store
Connect API never reliably reports processingState as VALID, so it polls
forever even though the upload succeeded and the build is usable. iOS is
unaffected because its processingState does flip. deliver's select_build only
avoids the same hang when handed an explicit build number (direct lookup); a
nil/"latest" number routes it back into BuildWatcher.
- Fastfile (mac): skip_waiting_for_build_processing on the upload, and always
submit by an explicitly resolved build number so deliver takes the
direct-lookup path and never re-enters BuildWatcher. Widen the submit retry
budget for the release flow. iOS keeps its working wait-based flow.
- Workflows: add timeout-minutes: 120 to both App Store jobs as a hard cost
backstop, and a desktop_submit dispatch lane on the Mac workflow (mirrors
iOS) to submit an already-uploaded build without a rebuild.
- Add an Announce Release workflow plus a shared discord-release-message.sh
script, so a release's Discord message can be posted for a given tag when the
automated notify job was skipped (e.g. a cancelled run). notify now builds
its message from the same script, so the two can't drift.
The linux, macos, server, android, and set-release-body jobs in the
release workflow were missing a `permissions: contents: write` block.
With the repository's default read-only GITHUB_TOKEN, the
ncipollo/release-action step in those jobs failed with
"Error 403: Resource not accessible by integration" when trying to
create/update the release.
The windows, snap, and flatpak jobs already declared this permission
and succeeded, which is why only some jobs failed. Add the same block
to the remaining jobs so every release-creating job can write to the
release.
Adds a static-analysis job to Build CI running two self-contained scanners
as a gate, replacing reliance on out-of-band Codacy:
- Semgrep against hand-written rules in .semgrep/ (raw SQL to
prepareStatement), with --error; honors // nosemgrep.
- gitleaks for committed secrets, pinned binary; fixtures allowlisted in
.gitleaks.toml.
Both tool versions are pinned for reproducible gating. Also fixes and
extends the migrator's SQL suppression (correct placement, both rule
ids), and suppresses the parity-check COUNT false positive.
Supersedes #746.
* Filter bounces from story reader stats with a dwell-gated beacon
Reader counts for published/shared stories were recorded on the initial
page GET, so every drive-by click counted as a reader — including people
who opened a story and closed it a second later (and non-JS bots).
Move recording off page load onto a best-effort dwell filter: the public
story page now loads a small script (story-reader.js) that fires a beacon
to POST /a/{penName}/{projectName}/read only once the visitor has actually
spent ~10 seconds on the page. The dwell timer only accrues while the tab
is visible, so a story opened in a background tab and never looked at
doesn't count. The beacon endpoint re-runs the exact same pen-name/project
resolution, access, and author-skip checks the GET did, so it can only
record a read the visitor could actually load.
This is a heuristic, not a guarantee — it just drops the obvious bounces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GApiphvSAKeYLeejenqjTK
* Harden reader beacon: carry dwell across pages, cap collector memory
Two follow-ups from red-teaming the dwell-gated reader beacon:
Multi-page stories were undercounted. Pagination is a full-page
navigation, so every page turn reloaded story-reader.js and reset the
10-second dwell timer — a reader spending a few seconds on each of
several pages never crossed the threshold on any single page and so was
never counted, penalizing exactly the engaged serialized-fiction readers
the metric wants. Accumulate dwell across page loads in sessionStorage
(per-tab, never sent to the server), keyed by the story path, and fire
the beacon once the cumulative total crosses the threshold. Backend dedup
already prevents any double count across pages.
Bound the collector's in-memory key set. The set only drains once a
minute, so a flood of reads with varied user-agents (each a distinct
visitor hash) could grow it without bound between drains. Cap it well
above any legitimate per-minute unique-reader volume and shed excess
reads past the cap: a best-effort metric may undercount under abuse, but
must not exhaust memory. The cap is injectable so a test can exercise the
shedding with a small value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GApiphvSAKeYLeejenqjTK
* Extract dwell logic to a tested module; reuse project resolution
Split the pure dwell-tracking math out of story-reader.js into a DOM-free
story-reader-logic.js (createDwellTracker), loaded as page_pre_script and
covered by story-reader-logic.test.js under the existing Node jsTest gate.
Also guards against a tab refocus re-firing the beacon after it has been sent.
Resolve the project via the existing findProjectByUrlSegment extension in
both the GET and the /read beacon instead of duplicating the
getProjectsWithSyncDate(...).find { shortId } lookup inline.
* Upload server test report artifact on CI failure
* Fix Koin boot: StoryReaderCollector's Int param broke constructor injection
singleOf(::StoryReaderCollector) makes Koin resolve every constructor
parameter by type, including the maxPendingKeys: Int default, so the graph
failed to boot with "No definition found for type java.lang.Integer".
Register it with an explicit constructor call that honors the default.
* Add e2e coverage for the reader beacon endpoint
Drives POST /a/{penName}/{projectName}/read against the booted server and
asserts what the collector actually records: a published story records one
reader, a story with no public access records nothing, a private share only
records with the correct password, and an unknown pen name records nothing.
---------
Co-authored-by: Claude <noreply@anthropic.com>
deliver exits 1 even though App Store Connect's eventually-consistent
build relationship accepts the submission, so the release job went red
while the build shipped. After a deliver error, verify the real version
state and treat an already-submitted version (with the intended build
attached) as success. Add an ios_submit-only workflow path to re-submit
without a rebuild.
* Add iOS UI smoke tests (XCUITest)
Adds an iOS UI smoke-test suite that drives the real app on a simulator via
XCUITest, the iOS analogue of the android/src/androidTest Compose UI tests.
The whole iOS UI is Compose Multiplatform, and CMP (1.8+) maps Compose testTags
to iOS accessibilityIdentifiers automatically, so the tests target the same
testTags the Android suite uses.
Workflows covered (all green on simulator):
- LaunchSmoke: app boots through the SwiftUI entry point, Koin + data migration
run, project selection renders.
- ProjectWorkflow: create + open a project (exercises the create dialog text
entry and navigation into the editor).
- SceneEditorWorkflow: create a scene and confirm it opens in the editor.
- NotesWorkflow: navigate to Notes and open the create-note screen.
Setup:
- ios/scripts/add_ui_test_target.rb idempotently creates the iosUITests
UI-testing target + shared scheme via the xcodeproj gem (the folder previously
had source files but no actual target).
- ios/scripts/disable_sim_hardware_keyboard.sh forces the software keyboard so
XCUITest text entry lands (Compose fields need it).
- ios-ui-tests CI job runs the suite on a simulator and uploads the xcresult on
failure.
- composeUi ProjectCreateDialog: tag the name field so the create flow is
targetable (also benefits Android).
Known limitation: the app's custom rich-text editors (scene body, note body via
MarkdownEditField) do not report keyboard focus to XCUITest, so their text entry
can't be driven (only standard Compose text fields can). The scene and notes
tests therefore stop at "editor opens" / "creation screen opens"; the full
edit/create-with-body paths remain covered by the Android suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* iOS UI tests: type into note body via composetexteditor 2.3.0
composetexteditor 2.3.0 publishes text-editing accessibility semantics on its
editor, so XCUITest can now drive text entry into it. Bump the dependency and
promote the Notes UI test to the full Android-parity flow: create a note by
typing into the body, then assert its card appears.
The scene edit+save flow stays scoped to "scene opens" for now — text entry
works, but the scene editor's initial-buffer gating + dirty-driven save make
the save affordance unreliable to assert from an IME-driven edit; promoting it
is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the artificial needs: publish-google-play from the fdroid, snap, and
ms-store jobs. None consume google-play output (fdroid pushes a git tag,
snap and ms-store pull their own artifacts), so they now fan out in
parallel gated only by the platform filter.
Wrap all Store/blob network calls in an Invoke-RestWithRetry helper that
retries transient failures (network errors, 5xx, 429, 408) up to 3 times
with exponential backoff. Client errors and the final attempt rethrow.
Linking :common:iosSimulatorArm64Test pulls in Compose's ui-uikit, which
references iOS 26 SDK symbols (e.g. UIViewLayoutRegion). macos-latest
still defaults to Xcode 16.4 (iOS 18.5 SDK), so the link failed
non-deterministically - it passed on the PR runner but broke develop on
merge. Select the newest installed Xcode 26.x before building, mirroring
the publish-ios-app-store job.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the Keychain-backed AuthTokenStore for iOS, completing the
client-side token-at-rest encryption left as a TODO when Android and
desktop were done. The account-keyed token map is stored as a single
kSecClassGenericPassword item (encrypted by the OS) instead of the
plaintext file store. Backed by multiplatform-settings' KeychainSettings.
Matches the other stores' post-refactor shape: no plaintext-file
migration (that FileAuthTokenStore was never shipped); the genuine legacy
case - inline tokens in server.json - is handled by migrateInlineTokens
in the common ServerSettingsFilesystemDatasource.
Also unblocks and enables the iOS unit test suite in CI. iOS test
linking failed because okio-fakefilesystem references the deprecated
kotlinx.datetime.Clock typealias, which double-binds during Kotlin/Native
klib caching. Since commonTest declared okio-fakefilesystem, it polluted
the Native test classpath even though only JVM tests use FakeFileSystem.
Moved that dependency to jvmTest (still reaches desktopTest). The
ios-compile job now also runs :common:iosSimulatorArm64Test (110+ tests
across 11 suites that previously never ran).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a SERVER platform (tag token `server`) and a third release
scope alongside All / Targeted. A server-only release produces a
`vX.Y.Z+server` tag, which matches none of the per-store publish jobs in
publish-release.yml, so no client app store upload runs while the server
distribution still builds and deploys out of band.
isPlatformReleaseTag now recognizes `+server`, so backout/revert clean it
up like any other release tag.
Introduces review-logic.js: DOM-free suggestion logic (segment
splitting, overlap guards, smart-spacing, applying accepted edits) plus
deterministic per-suggestion pen-ink strike styling, loadable both as a
browser script and a Node module. Unit-tested with Node's built-in
runner via a new :server:jsTest Gradle task, wired into CI as an
explicit gate with Node set up in the build job.
Restores the "store projects in public storage" feature, gated to F-Droid builds (the required MANAGE_EXTERNAL_STORAGE permission is disallowed on Google Play).
- Expose the build channel at runtime via BuildConfig.FDROID in the common module.
- Declare the storage permissions only in src/fdroid/AndroidManifest.xml, swapped in for F-Droid builds.
- Restore the storage-location toggle + file-access UI, gated on BuildConfig.FDROID; reconcile the toggle with the real location on open.
- Build the GitHub release APK as the F-Droid flavor.
- Extract the directory move into a tested FileSystem.moveDirectory() helper (fixes the same-path data-loss crash; runs off the UI thread).
- Read the fdroid flag consistently across settings.gradle.kts and module scripts.
- Document the F-Droid build flag in DEVELOPMENT.md.
ProjectLifecycleTest launches the real ProjectSelectActivity, creates a project,
waits for it to appear in the list, and confirms opening it launches
ProjectRootActivity (via ActivityMonitor). Cleans up in @After.
- Wire jetbrains-compose ui-test-junit4 into the androidTest source set.
- Tag the create-project affordance (CreateProjectButtonTestTag); "Create
Project" otherwise appears as three separate on-screen texts.
- Run the instrumented suite on an emulator in CI (android-emulator-runner) with
AVD snapshot caching.
- Convert HashTest from JUnit Jupiter to JUnit4 so the AndroidJUnit4 runner can
execute it on-device (it had "no runnable methods" otherwise); use assertEquals
since assert() is a no-op when assertions are disabled on a device. It now
verifies EntityHasher's golden vector on Android ART.
- Fix a scope-close crash: getSceneBufferDirectory used a non-recursive
createDirectory, so closing a project whose scenes/ dir is absent threw on the
teardown path and crashed the process. Use createDirectories (matching its
siblings) and order the test teardown so it doesn't delete the project mid-close.
The dry run served its purpose (verified the pipeline and surfaced the
two-cert signing bug, now fixed in publish-mac-app-store.yml). Drop the
dry-run workflow and its desktop_build_only lane; keep only the fix.
* ci: add Mac App Store dry-run lane and workflow
Adds a way to exercise the Mac App Store build/sign/package pipeline
without releasing anything to App Store Connect.
- fastlane: new `mac desktop_build_only` lane builds, signs, and verifies
the .pkg (via build-appstore.sh) but skips the App Store Connect build
number lookup and the upload — no API key needed.
- workflow: new "Dry Run — Mac App Store" (workflow_dispatch) mirrors the
real publish workflow's keychain + provisioning-profile setup, runs the
build-only lane, and saves the .pkg as an artifact instead of uploading.
Verified locally: lane builds + passes codesign/pkgutil checks.
* ci: temporarily trigger mac dry-run on push to its branch
workflow_dispatch requires the workflow to exist on the default branch
before it can be dispatched. Add a branch-scoped push trigger so the dry
run can be exercised now; remove before merging.
* ci: import Mac signing certs from two separate p12 files
macOS `security import` only ingests one identity from a combined
multi-key .p12 (which one wins is non-deterministic), so a single secret
holding both the Application and Installer certs left one of them missing
from the CI keychain — caught by the dry run, which failed with "Mac
Installer Distribution certificate not found".
Import the Application and Installer certs from their own single-identity
.p12 files instead. Adds a second secret, MAC_INSTALLER_CERT_P12_BASE64.
Applied to both the dry-run and the real publish-mac-app-store workflows.
* ci: drop temporary push trigger from mac dry-run workflow
The dry run was triggered via a branch-scoped push trigger because
workflow_dispatch only works once the workflow exists on the default
branch. Now that it's merging to develop, revert to workflow_dispatch
only.
Pass -u gh-releases-zsync to appimagetool so the Linux AppImage carries
update metadata and emits a .zsync file, and upload that file to the
release. Closes#478
buildDebug never touched the androidTest source set, so a stale instrumented
test could - and did - rot unnoticed: HashTest stopped compiling after `tags`
was added to EntityHasher.hashNote.
- Add a :android:assembleDebugAndroidTest step: a fast, emulator-free gate that
compiles + packages the androidTest source set on every PR.
- Fix the stale HashTest to match the current hashNote signature.
- Exclude the duplicate META-INF/LICENSE files the test deps ship so the
androidTest APK packages.
* Harden EntityHasher field-coverage test against nested and new-type drift
The descriptor-driven sensitivity test only inspected top-level fields, so a
new field on a nested DTO (ProjectTheme, WordCountGoal, Image) - or a whole new
synced type - could be added without the hasher, and no test failed.
- Recurse into owned nested @Serializable DTOs so e.g. theme.tertiary is a
tracked field path, not an invisible sub-field of `theme`.
- Cover ProjectData / ProjectDataHasher, which had no structural guard at all.
- Assert every ApiProjectEntity.Type has a sensitivity spec, so a new entity
subtype can't ship without one (reflection-free; server has no kotlin-reflect).
* Run server unit tests explicitly in CI
The server unit tests (including EntityHashSensitivityTest) previously ran only
as a side-effect of koverXmlReport gathering coverage - a kover config change
would silently stop running them. Add an explicit :server:test gate.
We've run into many problems where everything publishes fine, except 1 store. This provides a way to unblock that one store without pushing to the rest.
- Grant contents: write to the publish-fdroid-tag caller job so the
reusable workflow's git push tag actually has the token scope it
needs (reusable workflows can't elevate beyond the caller).
- Add a track input to publish-google-play (internal/alpha/beta/
production), default internal for manual dispatch so a stray
workflow_dispatch click can't ship develop straight to Production.
publish-release.yml explicitly passes track: production.
- Pass release_tag through env vars in publish-snap and
publish-fdroid-tag instead of interpolating into shell, closing
the workflow_dispatch command-injection vector.
- Validate fdroid release_tag matches semver before letting it near
git tag / git push.
Lets us iterate on the Partner Center submission logic via
workflow_dispatch (with a dry_run mode that skips the final commit
and cleans up the draft) instead of cutting a release every time.
Release pipeline keeps the publish-google-play gate by calling the
reusable workflow with secrets: inherit.
Mirror the Mac setup with an :ios platform block exposing ios_testflight
and ios_release lanes, plus a publish-ios-app-store job in the release
workflow. Both lanes reuse the existing App Store Connect API key (the
.p8 isn't platform-specific). The CI job installs the provisioning
profile to both Xcode 16's path and the legacy Fastlane location for
compatibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /submissions clones the last published submission body, so the
applicationPackages array still pointed at Hammer-2.1.1.msix. We never
updated it, so every commit since 2.1.1 succeeded against an unchanged
package list and the Store stayed frozen. Also: fileUploadUrl wants a
ZIP, not a raw MSIX.
- Mark cloned packages PendingDelete, append a PendingUpload entry for
the new MSIX, PUT the body back before uploading.
- Upload a ZIP whose entry name matches the new fileName.
- Clear any stale pending submission first so retries can run.
- Poll /status after commit and throw on CommitFailed so failures stop
hiding in the run log.
Wire up two Mac lanes (desktop_testflight, desktop_release) alongside the
existing Android setup, and a publish-mac-app-store job in the release
workflow so each new release uploads a sandboxed .pkg to TestFlight on a
macos-latest runner. The lane respects BUILD_NUMBER from env (CI sets it
to github.run_number) and falls back to TestFlight + 1 locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>