Skip to content

ci: sign the release at the tag in a protected environment - #340

Merged
nofuturekid merged 7 commits into
mainfrom
ci/sign-at-the-tag
Nov 13, 2026
Merged

nofuturekid merged 7 commits into
mainfrom
ci/sign-at-the-tag

Conversation

@nofuturekid

@nofuturekid nofuturekid commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

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 matching v*), and the release run signs the artifact that CI built.

ci.yml

  • One release job (build-release) for every run: human, bot, fork and dispatch. It has no secret. app/build.gradle.kts is unchanged, so the release variants are signed with the debug fallback.
  • A first step fails the job if app/release.keystore or any of KEYSTORE_B64, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD is present, and otherwise prints Release build runs WITHOUT the keystore: ....
  • A step after the build ("Confirm the release APKs are not release-signed") runs apksigner verify --print-certs on both release APKs and fails if either carries the pinned release certificate (digest read from release-verify-apk.sh), so the check also catches a key that appears after the first step.
  • build-release-unsigned and the author condition around the signed job are gone. The aggregate build needs [setup, ci-lint, release-check, build-debug, build-release, test] and, on a build-affecting run, accepts only success from build-release (a skip is a failure). Artifact names app-release and mapping are unchanged, so release.yml's lookup works as before.

release.yml

  • New job sign between guard and publish: 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, then release-verify-apk.sh unchanged (pinned certificate, exactly one signer), deletes the keystore in an always() step and uploads the signed APK as artifact signed-apk of this run.
  • publish needs [guard, sign], takes the signed APK from the sign job and mapping.txt from the CI run, and runs release-verify-apk.sh again (the file crossed a job boundary).
  • The dispatch dry run still runs only guard: no signing, no environment.

.github/scripts/release-sign.sh (new, shellcheck 0.11.0 clean): sign <in> <out> runs apksigner sign with --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled false --v4-signing-enabled false (passwords through env: options, never on the command line), checks that apksigner verifies the result with no scheme besides v2 and no v1 signature file left, runs zipalign -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-release artifact but are still never tagged), CONTRIBUTING.md, HANDOFF.md (new section "Release signing": what a pull request builds, what the release run does, recovery when sign fails, 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 in scripts/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 4 and plain -c -v 4: Verification successful.
  • unzip -v listings by name, CRC-32, size and method: 125 entries on both sides, identical.
  • release-verify-apk.sh accepts 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.
  • Negative cases of release-sign.sh: a copy with one flipped byte in classes.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.kts is untouched. F-Droid builds the store flavor from source with its own key (recipe gradle: [store], ADR-0040), so its build input is unchanged.

Deliberately left out

  • Deleting the four repository secrets (the coordinator does that after the first good tag run; the HANDOFF step is written).
  • Environment, ruleset and repository settings; .claude/settings.json; app/; the Play Store app bundle.
  • A required reviewer on the environment (decision c: every tag is already the maintainer's decision, all guards run before signing).

What stays unproven until a tag

Everything that needs a v* ref or the real secrets: the deployment to release, 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-artifact between sign and publish, 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:

  1. Read-backs: 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/nova lists the four names.
  2. Refusal on a branch (before tagging): on a throwaway branch add .github/workflows/env-probe.yml with on: pull_request, permissions: {contents: read} and one job (runs-on: ubuntu-latest, timeout-minutes: 5, environment: release, one echo step); push it signed, open a throwaway pull request, then gh run list -R hellwand/nova --workflow env-probe.yml --limit 1 and gh 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.
  3. Dry run (guard only; sign and publish skipped, no environment): gh workflow run release.yml -R hellwand/nova -f sha=<release PR head> -f version=<versionName>.
  4. Tag run: gh run view <id> -R hellwand/nova --log shows in job sign: 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. Then gh release download <tag> -R hellwand/nova -p '*.apk' -D <dir> and apksigner verify --print-certs on it gives 9f86baa17c1208004d9284e99597f77276c77301b06340b10075c793152b6824; gh api repos/hellwand/nova/releases/tags/<tag> --jq '.immutable, .prerelease, [.assets[].name]' and gh release verify <tag>.
  5. After that: delete the four repository secrets (gh secret delete KEYSTORE_B64 -R hellwand/nova, and likewise KEYSTORE_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.yml against today

  1. New job sign (environment release); on a tag push, publish now also waits for it and is skipped if it fails.
  2. publish no longer downloads app-release from the CI run; it downloads artifact signed-apk of its own run. The mapping download is unchanged.
  3. release-verify-apk.sh runs twice (in sign, then in publish) instead of once. In publish the "exactly one APK" message now names signed-apk.
  4. The published APK is no longer byte-identical to the CI artifact: same entries, new signature (ADR-0004 amendment).
  5. A new run artifact signed-apk exists (retention 7 days, overwrite: true so a re-run of the whole run can upload again).
  6. A failed sign is recovered by fixing the environment secret and gh run rerun; no new tag.
  7. Unchanged: triggers, the dry run (only guard), the guard and its script, concurrency, draft-first gh release create, changelog slicing, pre-release detection, APK name NOVA-<tag>.apk.

Refs #295

🤖 Generated with Claude Code

nofuturekid and others added 7 commits November 13, 2026 18:31
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
nofuturekid merged commit 00c999b into main Nov 13, 2026
11 checks passed
@nofuturekid
nofuturekid deleted the ci/sign-at-the-tag branch November 13, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant