Repository navigation
ci: sign the release at the tag in a protected environment - #340
Merged
Merged
Conversation
Two small queries (GetApiVersion, GetKeyAccess) tell NOVA which Unraid API version the active server runs and which roles and permissions its API key holds. The result is kept in memory as ServerCapabilities and exposed as MainViewModel.capabilities; nothing reads it yet, so there is no visible change. Detection runs when the server, its address or the key changes, on pull-to-refresh, and when the app returns to the foreground. There is no timer, no retry loop and no polling. Unknown is never denied: a failed detection, a refused `me` and a role NOVA does not know leave the access unknown, so controls stay as they are today. Only a successful answer can say "denied". The model, the role table, the classification of server answers and the repository wiring have unit tests built on the real response bodies of an Unraid 7.4.0 server. ADR-0047 records the decision. Refs #315, #247, #317, #316 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Refresh the capabilities on every ON_START so a fresh MainViewModel after a finished activity re-detects (the repository joins a running detection, so the first start does not probe twice). Correct the schema comment about unknown enum values (UNKNOWN__, no raw name) and the CapabilityProbeTest KDoc about which bodies are recorded and which are constructed. ADR-0047 follows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… already reported First part of alerts outside the app (#321): the pure decision logic and its stored state. Nothing runs in the background yet. evaluateAlerts takes what one check of one server returned and the remembered state, and returns the alerts to post and the next state. New unread alert and warning notifications are posted once per id; the array conditions "not started" and "degraded" (the rules of attentionItems, wrapped) are posted when they begin and again only after they were gone. The first successful read only records, a failed read changes nothing, the level filters, at most five alerts per server and check get their own notification, and the remembered ids are capped. The state is stored per server in the existing DataStore and removed with the server entry. ServerProbe is the interface for fetching one check once; only a test fake exists. ADR-0048 records the decision and amends ADR-0017. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- An array with state Offline (not known) now counts as not loaded, so it neither clears the open conditions nor sets the baseline. - "Alerts only" no longer suppresses the two array conditions; Unraid never notifies about them. ADR-0048 item 5 updated. - ADR-0048: only WAKE_LOCK and RECEIVE_BOOT_COMPLETED are new permissions; ACCESS_NETWORK_STATE is already declared, FOREGROUND_SERVICE is stripped. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The release signing key is no longer available to pull request builds (issue #295, WP3). ci.yml has one release job for every run, human, bot, fork and dispatch alike, and it has no secret: app/build.gradle.kts signs with the debug fallback, and a first step fails the job if a keystore or a signing variable is ever present. The unsigned variant job and the author condition are gone; the artifacts stay app-release and mapping. release.yml gets a job sign between guard and publish. It declares environment: release (secrets KEYSTORE_B64, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD; deployment policy limited to v* tags) and contents: read. It downloads the CI artifact, decodes the keystore into the runner's temp directory, signs with apksigner (v2 only), checks zipalign, compares the entry list with CRC-32 of the downloaded and the signed APK and fails on any difference, runs release-verify-apk.sh unchanged (pinned certificate, one signer), deletes the keystore and hands the signed APK to publish. publish takes that APK and the mapping from the CI run. The dry run still runs only guard. The signing and comparison logic is .github/scripts/release-sign.sh. Spike (throwaway key): apksigner replaces the debug signature completely, the result is one signer, zip-aligned (-P 16), with identical entries. ADR-0004 (sign at promotion) and ADR-0014 (bot heads carry an artifact, are still never tagged) are amended; CONTRIBUTING, HANDOFF (recovery, the one-time deletion of the repository secrets) and the Claude rules follow. Refs #295 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- release.yml: correct the find_run comment; since WP3 a fork PR also uploads app-release, so the head_repository filter is what keeps it from shadowing the real run. - CLAUDE.md R-SECRETS: the repository-level copies of the four signing secrets still exist until the one-time cleanup; say so instead of claiming the environment is the only place. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, base64 hint - release-sign.sh: fail() writes to stderr so the message survives $(...) (build_tools, entry_list); the extra-scheme pattern accepts v3.N. - ci.yml: new step after the build fails if either release APK carries the pinned release certificate (digest read from release-verify-apk.sh), so the no-secret claim holds for a key that shows up after the first step; comment reworded to say what each check covers. - release.yml: invalid base64 in KEYSTORE_B64 now names the secret and the re-run command. - HANDOFF.md: describe the new step. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
nofuturekid
force-pushed
the
ci/sign-at-the-tag
branch
from
November 13, 2026 17:36
d31bcb4 to
00c999b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Work package WP3 of #295 (decision c): the release signing key is no longer available to pull request builds. It lives only in the GitHub environment
release(deployment policy: tags matchingv*), and the release run signs the artifact that CI built.ci.ymlbuild-release) for every run: human, bot, fork and dispatch. It has no secret.app/build.gradle.ktsis unchanged, so the release variants are signed with the debug fallback.app/release.keystoreor any ofKEYSTORE_B64,KEYSTORE_PASSWORD,KEY_ALIAS,KEY_PASSWORDis present, and otherwise printsRelease build runs WITHOUT the keystore: ....apksigner verify --print-certson both release APKs and fails if either carries the pinned release certificate (digest read fromrelease-verify-apk.sh), so the check also catches a key that appears after the first step.build-release-unsignedand the author condition around the signed job are gone. The aggregatebuildneeds[setup, ci-lint, release-check, build-debug, build-release, test]and, on a build-affecting run, accepts onlysuccessfrombuild-release(a skip is a failure). Artifact namesapp-releaseandmappingare unchanged, sorelease.yml's lookup works as before.release.ymlsignbetweenguardandpublish:environment: release,contents: read,actions: read, tag pushes only. It downloads the CI artifact, decodes the keystore into$RUNNER_TEMP(mode 600), runs.github/scripts/release-sign.sh sign, thenrelease-verify-apk.shunchanged (pinned certificate, exactly one signer), deletes the keystore in analways()step and uploads the signed APK as artifactsigned-apkof this run.publishneeds[guard, sign], takes the signed APK from thesignjob andmapping.txtfrom the CI run, and runsrelease-verify-apk.shagain (the file crossed a job boundary).guard: no signing, no environment..github/scripts/release-sign.sh(new, shellcheck 0.11.0 clean):sign <in> <out>runsapksigner signwith--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled false --v4-signing-enabled false(passwords throughenv:options, never on the command line), checks that apksigner verifies the result with no scheme besides v2 and no v1 signature file left, runszipalign -c -P 16 -v 4(4-byte check with a warning if the build tools have no-P), and compares names, sizes, methods and CRC-32 of both APKs (v1 signature files excluded), failing on any difference.compare <a> <b>is the comparison alone.Docs: ADR-0004 (sign at promotion; "bit-for-bit" becomes "entry for entry"), ADR-0014 (bot heads now carry an
app-releaseartifact but are still never tagged), CONTRIBUTING.md, HANDOFF.md (new section "Release signing": what a pull request builds, what the release run does, recovery whensignfails, and the one-time deletion of the four repository secrets),.claude/CLAUDE.md(R-SECRETS, R-RELEASE),.claude/agents/release-engineer.md,docs/local-build.md, a comment inscripts/local-ci.sh.Spike (before any workflow change; throwaway key, never the release key)
The release APK built the way a secret-less run builds it (
./scripts/local-ci.sh, no keystore) is signed by the debug fallback: one signer,CN=Android Debug, v2 only (the same scheme set as the official v0.1.47 asset: v2 true, v1/v3/v3.1/v4 false, one signer). A copy signed with a 30-day throwaway key using the pinned flags:apksigner verify --print-certs: exactly one signer, the throwaway one; no debug signature survives (a copy that was v1+v2+v3-signed first also ends with its v1 files removed).zipalign -c -P 16 -v 4and plain-c -v 4: Verification successful.unzip -vlistings by name, CRC-32, size and method: 125 entries on both sides, identical.release-verify-apk.shaccepts package, version and code of that file and refuses it only for the signer (the throwaway certificate is not the pinned one), which is the expected fail-closed result.release-sign.sh: a copy with one flipped byte inclasses.dex, a copy with an added entry and a copy with a dropped entry are each refused with the differing line shown; a wrong keystore password fails without an output file; output equal to input is refused.So no alternative was needed, and
app/build.gradle.ktsis untouched. F-Droid builds thestoreflavor from source with its own key (recipegradle: [store], ADR-0040), so its build input is unchanged.Deliberately left out
.claude/settings.json;app/; the Play Store app bundle.What stays unproven until a tag
Everything that needs a
v*ref or the real secrets: the deployment torelease, that the real PKCS12 keystore opens with apksigner's default store type, the real signing and the pinned-signer match, the entry comparison on a real CI artifact on the runner's build-tools,actions/upload-artifact/download-artifactbetweensignandpublish, and the environment refusing a pull request branch. The pull request's own CI proves the secret-less release build and the artifact upload.Acceptance at the next beta tag:
gh api repos/hellwand/nova/environments/release/deployment-branch-policies --jq '[.branch_policies[] | [.name,.type]]'gives[["v*","tag"]];gh secret list --env release -R hellwand/novalists the four names..github/workflows/env-probe.ymlwithon: pull_request,permissions: {contents: read}and one job (runs-on: ubuntu-latest,timeout-minutes: 5,environment: release, oneechostep); push it signed, open a throwaway pull request, thengh run list -R hellwand/nova --workflow env-probe.yml --limit 1andgh run view <id> -R hellwand/nova: the job must not start and the annotation must say the branch is "not allowed to deploy to release due to environment protection rules". Close the pull request and delete the branch.signandpublishskipped, no environment):gh workflow run release.yml -R hellwand/nova -f sha=<release PR head> -f version=<versionName>.gh run view <id> -R hellwand/nova --logshows in jobsign:ok: apksigner verifies the signed APK, v2 only, no v1 signature files left,ok: zipalign -c -P 16 -v 4 passes,ok: 125 entries, names, sizes, methods and CRC-32 identical to the CI build ...(the count varies),ok: APK signature valid, signer certificate matches the pinned release key;gh api 'repos/hellwand/nova/deployments?environment=release' --jq '.[0] | {ref,environment}'shows the tag. Thengh release download <tag> -R hellwand/nova -p '*.apk' -D <dir>andapksigner verify --print-certson it gives9f86baa17c1208004d9284e99597f77276c77301b06340b10075c793152b6824;gh api repos/hellwand/nova/releases/tags/<tag> --jq '.immutable, .prerelease, [.assets[].name]'andgh release verify <tag>.gh secret delete KEYSTORE_B64 -R hellwand/nova, and likewiseKEYSTORE_PASSWORD,KEY_ALIAS,KEY_PASSWORD);gh api repos/hellwand/nova/actions/secrets --jq '[.secrets[].name]'must give[]and the next pull request's CI is still green.Behavioural differences in
release.ymlagainst todaysign(environmentrelease); on a tag push,publishnow also waits for it and is skipped if it fails.publishno longer downloadsapp-releasefrom the CI run; it downloads artifactsigned-apkof its own run. Themappingdownload is unchanged.release-verify-apk.shruns twice (insign, then inpublish) instead of once. Inpublishthe "exactly one APK" message now namessigned-apk.signed-apkexists (retention 7 days,overwrite: trueso a re-run of the whole run can upload again).signis recovered by fixing the environment secret andgh run rerun; no new tag.guard), the guard and its script, concurrency, draft-firstgh release create, changelog slicing, pre-release detection, APK nameNOVA-<tag>.apk.Refs #295
🤖 Generated with Claude Code