Repository navigation
Description
The maintainer decided on 2026-11-13 that the GitHub and release rules hellwand/tasmofleet works under also apply to NOVA, and that CI and repository rulesets enforce them, not convention. This issue compares each rule with what NOVA enforces today (from API reads and the files on main at bc54552), decides every engineering question, and lists the work packages in the order they should land.
Freeze note. v0.1.45-beta1 is a published pre-release waiting for on-device acceptance. Under R-FREEZE, nothing under .github/ (except SECURITY.md and workflows/pages.yml) changes on main until the maintainer lifts the freeze. Only WP0 can go now. WP1 to WP4 wait, and the order below keeps the release path working at every step.
1. Rule by rule
Legend: platform means GitHub refuses it. CI means a job fails. convention means nothing stops it.
| Rule | NOVA today: enforced by what, with evidence | Action |
|---|---|---|
R1 Every change lands through a PR against main |
platform for non-admins, through ruleset 27182420 main: pull request (admin push bypass) (allowed_merge_methods: ["rebase"], 0 approvals, admin bypass_mode: always). For the admin it holds only through R3, and R3 currently has a gap: a manual run of ci.yml puts a check named build on any commit (see R2). |
WP1: a manual run reports under a different check name. |
| R2 One aggregate check gates the merge. It always reports and passes only if every gate that had to run passed. | CI: job build in ci.yml (needs: [setup, build-debug, build-release, build-release-unsigned, test], if: always(), ci.yml:377-380). The trigger has no paths: filter (ci.yml:39-41). The aggregation works in both directions. A failing test on a Dependabot PR made build fail (run 43329289634). A cancelled release job made it fail (43341481624), and so did a cancelled run (43320863325). A docs-only PR got a green build (43383054979), and a bot PR was green on the unsigned path (43332041720). Gaps: (1) workflow_dispatch also creates a passing build. On c1d92a7, the only build check comes from dispatch run 43302932208, and a feature branch was dispatched once (run 26664824704). (2) Only 8 path patterns trigger a gate. Changes to release.yml, .github/scripts/*, renovate.json or other workflows pass with no gate at all (ci.yml:129, 134-136, 399-402). actionlint runs outside needs and is advisory (actionlint.yml:8 DELIBERATELY ADVISORY). (3) An empty diff passes. (4) The diff is taken against the PR's base branch (ci.yml:84 BASE="${{ github.event.pull_request.base.sha }}"), so a docs-only PR stacked on an unbuilt branch gets a green build on a head that contains untested app/ changes. |
WP1 |
| R3 Nothing lands before the check is green on that exact commit. No bypass. | platform: ruleset 27182419 main (no bypass) requires build@15368 with strict on. Its read-back shows "bypass_actors": [] and "current_user_can_bypass": "never". A3 was run on 2026-11-13: a push of an unchecked signed commit was refused with GH013, naming the required status check build (output below the table). A5 is also on record: the fast-forward push of PR #293's head bc54552 was accepted at 07:05:40Z (activity API), and build was success on that SHA from a pull_request run. |
Adopted. |
| R4 Signed commits, never disabled | platform, admins included: required_signatures sits in the no-bypass ruleset, which is stricter than TasmoFleet. The last 20 commits on main read verified: true. |
Adopted. |
| R5 Linear history, fast-forward push only, no force push, no deletion | platform, admins included: non_fast_forward, required_linear_history and deletion are in 27182419. The repository allows only rebase merges (allow_merge_commit false, allow_squash_merge false, allow_rebase_merge true, allow_auto_merge false). git log --merges bc54552 is empty. That nobody uses the merge button is convention plus the local command guard. |
Adopted. Stale squash-merge text goes in WP1 and WP2. |
R6 A release tag sits on an already-green main commit, and a bad tag publishes nothing |
CI in release.yml, before anything is published. release-guard.sh checks the tag shape (:31), that the commit is an ancestor of origin/main (:36), that the CI run tree equals the tag tree (:53), that versionName equals the tag (:63), and that versionCode went up (:68). find_run needs a successful ci.yml run with an app-release artifact (release.yml:66-72). The tag ruleset 26978537 blocks update and deletion of refs/tags/v* with no bypass. Weaker than the spec: a workflow_dispatch run counts as green (release.yml:71). The documented recovery (gh workflow run ci.yml --ref <tag>) makes a commit green after tagging, so any ancestor of main, even one that never had its own PR run, can be released. |
WP2: add a PR-green check on the tagged SHA. |
| R7 A release needs its CHANGELOG section, and CI checks it | Missing. If the section is missing, the release only warns: release.yml:213-214 ::warning::No CHANGELOG.md section for $VER — publishing with auto notes only. Nothing checks it on the PR. |
WP1 (PR side), WP2 (fatal at the tag) |
| R8 Published releases are immutable | Missing. gh api repos/hellwand/nova/immutable-releases returns {"enabled":false,...}. It cannot be switched on yet: the pinned softprops/action-gh-release v3.0.4 creates a pre-release already published and uploads the assets afterwards (src/github.ts:1058 const draft = prerelease === true ? config.input_draft === true : true;). v0.1.45-beta1 shows this: published_at 06:24:45Z, assets created at 06:24:46Z. With immutability on, the next beta would be published without its APK, and the tag would be locked. |
WP2 first, then the setting |
| R9 Compatibility guards run on the PR as well as at the tag | Weaker. The versionName shape, the versionCode increase and the CHANGELOG check run only at the tag (release-guard.sh:60-73). |
WP1. The pinned-signer check stays tag-only on purpose (see decision c). |
| R10 Renovate PRs pass the check, no automerge, actions pinned by SHA | platform + config. renovate.json: "extends": ["config:recommended", "helpers:pinGitHubActionDigests"] and "automerge": false. sha_pinning_required: true is set. Every uses: is pinned to 40 hex characters, and there is no secrets: inherit. Bot PRs run through build (run 43332041720). |
Adopted. Keep the groups (decision g). |
R11 A scheduled full run on main opens one issue when it fails |
Missing. The only scheduled job is CodeQL, weekly (codeql.yml:82 cron: '27 4 * * 1'), and it opens no issue. There is no ci label. |
WP4 |
| R12 Secrets only in environments limited to release refs; tokens read-only by default | Tokens: platform, default_workflow_permissions: read and can_approve_pull_request_reviews: false. Secrets: missing. KEYSTORE_B64, KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD are repository secrets. The only environment is github-pages. The only guard is the job-level if in ci.yml:194-200, and that if lives in the workflow file at the run's own commit. release.yml and dependency-submission.yml grant contents: write at workflow level. |
WP3 (sign at the tag in environment release), WP2 (job-level permissions) |
| R13 Secret scanning and push protection on | platform: secret_scanning enabled, secret_scanning_push_protection enabled, Dependabot security updates enabled. |
Adopted. |
| R14 No session links or session trailers | convention + local command guard + the builder's grep. The spec's verify command cannot pass for NOVA: 67 commits from 2026-05-15 to 2026-05-19 carry them (newest eb821eb), and main cannot be rewritten. The 82 commits after eb821eb carry none. |
WP1: CI rejects them in commit messages of the PR range. Verify with the spec's git log --grep command, restricted to the range eb821eb..origin/main; it must print nothing. PR bodies and issues stay convention. |
| R15 Housekeeping after the jobs that create the mess | Not needed. There are 29 caches (5.7 GB), all on refs/heads/main. Their only writer is dependency-submission.yml, and NOVA publishes no images. |
None |
| Tag ruleset (optional in the spec) | platform: 26978537, deletion + update, no bypass. |
Adopted. Who may create a v* tag stays convention, as in TasmoFleet. |
A3, verbatim (2026-11-13, push of the unchecked signed commit bc54552 to main before its pull request existed):
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: Review all repository rules at https://github.com/hellwand/nova/rules?ref=refs%2Fheads%2Fmain
remote:
remote: - Required status check "build" is expected.
remote:
! [remote rejected] bc54552fd8a1e59e3b2eafa336c3de47797b6e91 -> main (push declined due to repository rule violations)
2. Engineering decisions
(a) Aggregate check: keep build, add no required job, no ruleset switch. build already behaves like TasmoFleet's required. It always reports, since the trigger has no paths filter and the job has if: always(). It fails on a failed or cancelled gate, and on a skip caused by a failed setup. It passes on docs-only changes and on bot PRs (runs cited above). R2 explicitly allows the name when the ruleset names the same context. Renaming it would touch ruleset 27182419, the merge-train ready gate (check_name=build), R-MERGE and ADR-0003, and gain no protection. What is missing are the inputs, not the aggregator, and WP1 fixes them. Ordering: a pull_request run uses the workflow file of the PR itself. So a WP1 that broke build would show red on its own PR and could not land, and a revert PR runs the old ci.yml. main never becomes unlandable. If a switch to required were ever wanted, the safe order is: land a PR that reports both build and required; then PUT ruleset 27182419 with context required and read it back; then update the merge-train gate and ADR-0003; and only then drop the old name.
(b) The dispatch hole: the job reports under a different name on dispatch. In ci.yml, give job build the name name: ${{ github.event_name == 'pull_request' && 'build' || 'build (dispatch)' }}. jobs.<job_id>.name may use the github context (GitHub docs, contexts table). After this change, a manual run can never produce the context build, so neither the ruleset nor the merge-train gate can count it. Nothing in the release path depends on the name: find_run looks for the app-release artifact (release.yml:66-76). So the documented recovery gh workflow run ci.yml --ref <tag> (HANDOFF.md:193, CONTRIBUTING.md:56, ADR-0014:75) and the signed build on dispatch keep working. Other options were rejected. Dropping workflow_dispatch (the letter of R2) needs a separate tag-only rebuild workflow, changes to find_run, and a rewrite of three recovery texts, and protects nothing more. Skipping jobs on dispatch does not work: GitHub reports a job skipped by a condition as success. Failing setup for dispatches outside main/v* would also guard nothing, because the dispatched ref brings its own ci.yml. Whether GitHub would count a dispatch-made build for a direct push is not settled: the docs say dispatch checks do not satisfy a branch ruleset for a pull request, but "Required status checks do not take ... event trigger types into account". With the rename, that question no longer matters. What remains is inherent to GitHub: a branch that adds its own on: push job named build is stopped only by review, as in TasmoFleet.
(c) Signing secrets: sign at the tag in environment release, limited to v* tags. An environment's branch and tag policy is matched against GITHUB_REF. A pull_request run has refs/pull/N/merge, so an environment limited to main and v* refuses every PR run. Moving the secrets therefore means moving the signing step. The model:
- The PR builds the release APK once, with no secrets. Every run, including bot and fork PRs, takes one release path.
app/build.gradle.ktsstays unchanged, so CI produces it signed with the debug-key fallback (app/build.gradle.kts:57-61). release.ymldownloads that APK, signs it withapksigner, then verifies the result.- Signing runs in a job with
environment: releaseand these pinned flags:--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled false --v4-signing-enabled false. Today's APK is v2-only (CI log of run 43377561353:Verified using v2 scheme ... true, all other schemesfalse,Number of signers: 1). - It then checks
zipalign -c -P 16 -v 4. - It compares the entry list with CRC-32 values of the downloaded and the signed APK, signature files excluded, and fails on any difference.
- Last, it runs
release-verify-apk.shunchanged. Its pinned certificate and its rule "exactly one distinct signer digest" fail closed if any debug signature survived.
- Signing runs in a job with
Is this sound? Yes. Build-once-promote keeps its meaning: the compiled code and resources are the ones the PR built and tested, entry for entry, and the entry comparison proves it on every release. ADR-0004's "bit-for-bit" becomes "the same entries, with the signature added at promotion". The signed file is not byte-identical to the CI artifact, and nothing needs it to be: the in-app updater checks the published asset's digest, and immutability (d) freezes that digest.
F-Droid is not affected. It builds the store flavor from the source tag and signs with its own key; ADR-0040 rejected reproducible builds with our signature. Keeping the Gradle fallback unchanged keeps F-Droid's build input unchanged.
What it costs:
- PR CI no longer proves that the release key works. A broken secret fails the tag run before anything is published, and it can be fixed by correcting the secret and re-running the release run (
gh run rerun), with no new tag. - One release job fewer in
ci.yml. - About a minute more per release.
- ADR-0004 and ADR-0014 need amendments. ADR-0014:90 "Bot heads never carry an
app-releaseartifact" no longer holds; bot heads still are never tagged.
What it buys: today the keystore and its passwords are present in every human same-repo PR build. That build runs the Gradle plugins, KSP processors and build-time code of every dependency, including dependencies that Renovate landed earlier, plus three third-party setup actions. After the change, the key exists only in one job per v* tag, a job that runs apksigner from the runner's SDK and no Gradle, and the boundary is a platform policy instead of an if that a branch can edit.
No required reviewer on the environment. R-RELEASE already makes every tag the maintainer's decision, all guards run before signing, and TasmoFleet's dockerhub environment has none either.
Not verified, so WP3 starts with a spike. Does apksigner sign replace the debug signature of the fallback APK completely, leaving one signer and identical entries? The fail-closed verification protects against a wrong answer. If it does not replace it, the alternative is an unsigned release output when no keystore exists, and then the fdroiddata build recipe has to be checked for the output file name.
(d) Immutable releases: enable, after WP2. Once a release is published, its assets and its tag are locked. The tag cannot be deleted while the release exists. After the release is deleted, its tag name can never be reused. The title, the notes, the pre-release flag and the latest flag stay editable, and the setting applies only to future releases (GitHub docs). NOVA's flow survives it:
- A stable release is always a new tag on a new commit (ADR-0015), never a converted pre-release.
- ADR-0010's pruning deletes old beta release entries and keeps their tags, which stays allowed.
- Nothing edits or uploads to a release after publication: the publish step is the last step, and nothing else calls
gh release uploadorgh release edit.
What has to change:
- Publish draft-first.
gh release create "$TAG" --verify-tag --title "$TAG" --notes-file changelog_section.md --generate-notes [--prerelease] <apk> mapping.txtcreates a draft, uploads every file, then publishes, and deletes the draft if an upload fails (cli/clicreate.godraftWhileUploading). Combined with--notes-file, it appends the generated notes to the body, which matches today's output. - Make
mapping.txtmandatory:ci.ymlupload withif-no-files-found: error, and inrelease.ymlnocontinue-on-error. Under immutability, a missing mapping can never be added later.
ADR-0010's recovery recipe (re-push a deleted tag) does not work for tags of immutable releases. It is moot already, because the tag ruleset blocks deletion with no bypass; note that in ADR-0010.
(e) Scheduled run: a weekly full build, not a nightly. NOVA has no test tier beyond the PR run: there are no androidTest sources, and 36 unit-test files run on every code PR. A nightly that repeats PR CI would prove nothing. A weekly run still catches three things PR CI cannot:
- Dependencies that can no longer be resolved from scratch. PR builds read the cache of
main, while F-Droid and the documented recovery rebuild from nothing. - Store-flavor lint and unit tests, which PR CI skips.
- Runner image drift during quiet months.
New weekly.yml, with cron 41 3 * * 1 and a workflow_dispatch that has an input probe:
- Job
full(ubuntu-latest, 60 min):setup-gradlewithcache-disabled: true, then all variants of both flavors:./gradlew :app:assemble, lint and unit tests of both flavors. The builder confirms the task names with:app:tasks --all. There are no secrets, so the release variants are signed with the debug fallback. - Job
failure-issue,needs: [full],if: always() && needs.full.result != 'success' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || inputs.probe),permissions: { issues: write, actions: read, contents: read }. It runs.github/scripts/failure-issue.sh "<fixed title>".
The script does the following:
- Lists the open issues with label
ciand matches the title exactly withjq --arg. - On a match, it comments on that issue; otherwise it creates the issue (
gh issue create --label ci --body-file -). - The body holds the run link, the SHA, the failed or cancelled jobs (a timeout shows as cancelled), and the commits since the last green scheduled run.
- One fixed title per workflow keeps it to one open issue, which collects later failures as comments.
The same job calls the same script from codeql.yml for scheduled runs. The probe input makes full fail on purpose, so the issue path can be proven on the platform.
(f) Tags only on green main commits, and the changelog section.
Already enforced: the tag commit is an ancestor of origin/main, the CI run tree equals the tag tree, the version checks pass, the tag is never moved or deleted, and the APK has the pinned signer.
Missing:
- The green status must come from a
pull_requestrun on the tagged SHA itself, not from a dispatch run made after tagging. Add torelease-guard.sh:gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&status=success", filtered onhead_repository.full_name == $GITHUB_REPOSITORY, must return at least 1. This is the NOVA port of TasmoFleet'srelease-green.shpart 1. The artifact may still come from the recovery dispatch. - The changelog section must be fatal, and must be checked on the PR as well.
Both go into one shared script .github/scripts/release-preflight.sh <commit>. It checks the ADR-0005 shape of v<versionName>, that versionCode is greater than at the previous v* tag, that no tag of that name exists, and that CHANGELOG.md has a non-empty ## [<versionName>] section. release-guard.sh calls it at the tag, and the new PR job calls it when versionName changed, so the PR and the tag cannot drift apart. NOVA keeps its curated changelog (ADR-0031) and adds no fragments: only release PRs need a section, which R7 allows. Squash-era path 2 stays as ADR-0014 decided on 2026-11-13. It is unreachable for new tags but harmless.
(g) Renovate: keep the groups. The spec does not require one PR per update: R10 says "May: schedule, groups, managers", and TasmoFleet itself groups minor, patch and digest updates per ecosystem. NOVA's groups encode real constraints. AGP, Kotlin, KSP and Hilt must move together (toolchain, separateMajorMinor: false), and AndroidX moves with the Compose BOM. Every Gradle-file change builds cold, so single PRs would multiply 5-minute builds and merge-train work. The groups give no exemption from build, from review or from pinning. The rules that matter are already in force: no automerge, digests pinned, and sha_pinning_required set.
3. Work packages, in order
WP0: now (no repository files, not frozen)
- Changes:
- Record the acceptance tests in this issue. A1:
gh api repos/hellwand/nova/rules/branches/main --jq '[.[].type]|sort'must givedeletion, non_fast_forward, pull_request, required_linear_history, required_signatures, required_status_checks(read 2026-11-13: matches). A2:gh api repos/hellwand/nova/rulesets/27182419 --jq '.bypass_actors,.current_user_can_bypass'must give[],"never"(matches). A3: refused,GH013(output above). A5: PR docs: land pull requests by fast-forward push instead of squash merge #293. - Create label
ci:gh label create ci --description "CI and scheduled-run failures". - Optional now, required before WP3: the maintainer creates environment
releaseand enters the four signing secrets into it. Repository secret values cannot be read back or copied through the API, so they must come from the maintainer's offline copy. Never copy them with a workflow. Nothing references the environment yet, so this changes nothing.gh api -X PUT repos/hellwand/nova/environments/release --input - <<'EOF' {"deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}} EOF gh api -X POST repos/hellwand/nova/environments/release/deployment-branch-policies -f name='v*' -f type=tag gh secret set KEYSTORE_B64 --env release # and KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD
- Record the acceptance tests in this issue. A1:
- Acceptance: the read-backs.
gh api repos/hellwand/nova/environments/release/deployment-branch-policies --jq '[.branch_policies[] | [.name,.type]]'must give[["v*","tag"]], andgh api repos/hellwand/nova/environments/release/secrets --jq '[.secrets[].name]'must list the four names. - Risk / rollback: none. Delete the environment to undo. Touches
.github/: no.
WP1: harden the build gate (after the freeze is lifted; not the release path)
- Changes:
.github/workflows/ci.yml:- The
buildjob gets the per-event name from (b). - In
setup, diff againstgit merge-base origin/main "$HEAD"instead ofbase.sha. This covers stacked PRs and a base that is behind. Fail closed (exit 1) if the merge base cannot be computed or the diff is empty. Dispatch keeps doing a full build. - New job
ci-lint, always runs, inbuild'sneeds, accepts onlysuccess. It runs:- actionlint over all workflows, using the pinned tarball and SHA-256 moved from
actionlint.yml; shellcheck .github/scripts/*.sh;renovate-config-validatorfrom a pinnedrenovate@<exact version>, only whenrenovate.jsonchanged;- a check that no commit message in
merge-base..headcontains a session link or a session trailer. Use the same two patterns as the builder's check in.claude/workflows/nova-pr.js(LINK_PATTERN,TRAILER_PATTERN) (R14).
- actionlint over all workflows, using the pinned tarball and SHA-256 moved from
- New job
release-check, always runs (no Gradle) and is inneeds. It exits 0 whenversionNameis unchanged against the merge base, and otherwise runs.github/scripts/release-preflight.sh HEAD. - The
mappingupload getsif-no-files-found: error. - Remove the stale squash wording in the header (ci.yml:3-10).
- The
.github/scripts/release-preflight.sh: new (decision f)..github/workflows/actionlint.yml: delete it, since its job is now a gate..github/workflows/dependency-submission.yml: workflow-levelcontents: read, andcontents: writeon its one job..github/PULL_REQUEST_TEMPLATE.md:16: drop the squash line.- Docs:
- ADR-0029: amendment for the merge-base diff, the fail-closed rule and the new always-run jobs.
- ADR-0022: amendment, actionlint is now required.
- ADR-0003: note that the dispatch check is named
build (dispatch). nova-pr.js:BUILD_AFFECTING_REstays unchanged, because the allowlist is unchanged.
- Acceptance on the platform:
- The WP1 PR shows
build pass, withci-lintandrelease-checksuccessful. - Run
gh workflow run ci.yml --ref <probe branch>. Thengh api 'repos/hellwand/nova/commits/<sha>/check-runs?check_name=build'must not list this run, and a checkbuild (dispatch)must exist. - A4: a throwaway PR based on a branch that changes
app/, whose own diff is docs only plus a shellcheck error in a guard script.buildmust run the full build and fail onci-lint. Then close it. - A throwaway PR that bumps
versionNamewithout a CHANGELOG section must failbuild. Then close it. - The next real release PR shows
release-checksuccessful.
- The WP1 PR shows
- Risk: a wrong merge base makes builds run more often, never less (the direction is fail-safe). The actionlint download depends on GitHub release hosting. Rollback: a revert PR, which runs the old
ci.ymland lands normally. Touches.github/: yes. It does not change the release path: artifacts and names stay the same, and only the dispatch check name changes, which nothing inrelease.ymlreads.
WP2: release path, part 1, and immutable releases (early in the next cycle; the next beta tag is the acceptance)
- Changes:
.github/workflows/release.yml:- Top-level
permissions: contents: read. - Job
guard(contents: read,actions: read) runsfind_run, thenrelease-guard.sh, now with the PR-green check andrelease-preflight.sh, so a missing CHANGELOG section is fatal before any download. - Job
publish(contents: write),if: ${{ !cancelled() && needs.guard.result == 'success' && github.event_name == 'push' }}. It downloads the APK and the mapping (mandatory), runsrelease-verify-apk.sh, slices the changelog, and runsgh release create "$TAG" --verify-tag ...with both files. This replacessoftprops/action-gh-release. - Add a
workflow_dispatchwith inputsshaandversionthat runs onlyguard: a platform dry run that publishes nothing. It replaces the shell dry run in HANDOFF.md, "Release dry run".
- Top-level
- Docs: CONTRIBUTING.md:56, HANDOFF.md (the dry run), ADR-0014 amendment (PR-green check), ADR-0010 note (immutability).
- Setting, right after this PR is on
mainand before the next tag:gh api -X PUT repos/hellwand/nova/immutable-releases.
- Acceptance on the platform:
gh workflow run release.yml -f sha=<release PR head> -f version=<versionName>:guardprints itsok:lines andpublishis skipped.- The same dispatch on a SHA without a PR run is refused by the PR-green check.
gh api repos/hellwand/nova/immutable-releasesmust give{"enabled":true,...}.- The next tag run publishes.
gh api repos/hellwand/nova/releases/tags/<tag> --jq '.immutable, .prerelease, [.assets[].name]'must givetrue, the correct flag, and the APK plusmapping.txt.gh release verify <tag>must pass, and the in-app updater offers it.
- Risk: a bug in
release.ymluses up that version, because a re-run uses the workflow file of the tagged commit. The fix goes into a new commit with the nextversionCode(ADR-0015). The dry run lowers this risk. Rollback: a revert PR, andgh api -X DELETE repos/hellwand/nova/immutable-releases; releases published while the setting was on stay immutable. Touches.github/: yes, and it changes the release path, so land it at the start of a cycle and never while a beta waits for acceptance.
WP3: sign at the tag, and remove the repository secrets (the cycle after WP2; the next beta tag is the acceptance)
- Spike first (no repository change): take the debug-signed
app-releaseartifact of a CI run and sign it with a throwaway test key, never the release key. Then check three things:apksigner verify --print-certsshows exactly one signer,zipalign -c -P 16 -v 4passes, and the entry CRCs are unchanged. Record the output here. - Changes:
.github/workflows/ci.yml: one release job for every run (no secrets, debug fallback). It uploadsapp-releaseandmapping;release.ymlalready ignores fork runs. Deletebuild-release-unsigned, and take the bot/fork condition out ofbuild..github/workflows/release.yml: new jobsignbetweenguardandpublish, withenvironment: releaseandcontents: read. It decodes the keystore into$RUNNER_TEMP, runsapksigner signwith the pinned flags from (c), checks the alignment and the entry CRCs, runsrelease-verify-apk.sh, removes the keystore, and uploads the signed APK forpublish.- Docs: ADR-0004 amendment ("sign at promotion"), ADR-0014 (bots line), CONTRIBUTING.md.
- Settings, only after acceptance step 2:
gh secret delete KEYSTORE_B64(and the other three).
- Acceptance on the platform:
- A throwaway PR adds a job with
environment: release. GitHub must refuse it ("not allowed to deploy to release due to environment protection rules"). Then close it. - The next tag run shows the deployment to
release,ok: APK signature valid, signer certificate matches the pinned release key, and the CRC comparison with no difference. gh api repos/hellwand/nova/actions/secrets --jq '[.secrets[].name]'must give[], and the next PR run is still green.
- A throwaway PR adds a job with
- Risk: wrong environment secrets make the sign job fail before publishing; fix the secret and
gh run rerun. A code bug uses up the version (as in WP2). Rollback: a revert PR. After the repository secrets are deleted, a rollback also needs the maintainer to set them again, which is why they are deleted last. Touches.github/: yes, and it changes the release path and the signing. It does not changeapp/, so F-Droid's build is untouched.
WP4: weekly full run with a failure issue (after the freeze is lifted; independent of the release path, may land together with or after WP1)
- Changes:
.github/workflows/weekly.ymland.github/scripts/failure-issue.shas in (e).codeql.ymlgets afailure-issuejob for scheduled runs. - Acceptance on the platform:
gh workflow run weekly.yml --ref mainis green.gh workflow run weekly.yml --ref main -f probe=trueopensWeekly full build failed on mainwith labelci. A second probe adds a comment instead of a second issue. Then close the issue.- The first scheduled run is listed by
gh run list --workflow weekly.yml --event schedule.
- Risk: cold builds take longer and cost CI minutes once a week. Rollback: delete the workflow. Touches
.github/: yes. It does not touch the release path.
4. Summary lists
Adopted already (with proof): R3 (27182419, A3 GH013 on 2026-11-13, A5 PR #293); R4 and R5 (no-bypass ruleset, merge settings [false,false,true], no merge commits); R1 for non-admins (27182420); R10 (renovate.json, sha_pinning_required: true, every action pinned); R13 (security_and_analysis); the token defaults of R12; the tag ruleset 26978537; the aggregation logic of R2 (the runs cited above).
Cannot be adopted as written (for the maintainer):
- R12, "environment limited to
mainand release tags": it would refuse NOVA's PR-time signing. Adopted by moving the signing to the tag, with an environment limited tov*tags only (WP3, decision c). - R2, "no
workflow_dispatchon the workflow that produces the context": NOVA keeps the dispatch for the documented artifact recovery. A dispatch reports asbuild (dispatch)and never produces the context, which gives the same guarantee. - R14's verify command over the whole history: 67 commits from May 2026 cannot be rewritten. The command starts at
eb821eb. - R8: it can only be switched on after WP2, because of the order of release creation and asset upload in the pinned action.
- R9's signer pin on the PR: after WP3 the PR build is not release-signed, so the pin is checked at the tag, the only place the signature exists.
- R11 "nightly FULL tier": there is no tier beyond the PR run, so it becomes a weekly cold full build.
Stays convention:
- Who may create a
v*tag, and tag signing. - The maintainer's go for every release (R-RELEASE).
- Not using the merge button. It is refused for unsigned rebase commits under
required_signatures; that was not tested here. - A branch that adds its own
on: pushjob namedbuild(review). - Session links in PR bodies, issues and comments.
- No repository-level secrets after WP3 (read-back command above).
- One Renovate PR landed at a time.
- The actionlint version bump.
- Contributor credits.
🤖 Generated with Claude Code
Activity
ci: harden the build gate (dispatch check name, merge-base diff, lint…
ci: harden the build gate (dispatch check name, merge-base diff, lint…
ci: harden the build gate (dispatch check name, merge-base diff, lint…
nofuturekid commentedon Nov 13, 2026
Status 2026-11-13
WP1 landed (#297) and is accepted at the platform
ci.ymlon a branch commit reports asbuild (dispatch); the check namedbuildon that commit came only from the pull request run.app/change, plus a script with a shellcheck error. The gate diffed against the merge base withmain, ran the full build, andbuildfailed becauseci-lintfailed.versionNamebump without a changelog section.release-checkfailed with "CHANGELOG.md has no non-empty section", andbuildfailed although the build jobs passed.WP4 landed (#296) and is accepted at the platform
weekly.ymlonmain: green.ci; probe run 2 added a comment to Weekly full build failed on main #300 instead of opening a second issue. Weekly full build failed on main #300 is closed again.Also done today: stable v0.1.45 was the first release through the fast-forward flow;
release.ymlfound the CI run by the tagged commit itself ("direct match").Open: WP2 (release path, pull-request-green check at the tag, draft-first publishing, then immutable releases) and WP3 (sign at the tag in an environment limited to
v*, then remove the repository secrets).ci: release only pull-request-green commits, changelog section requir…
nofuturekid commentedon Nov 13, 2026
WP2 landed (#309) and is accepted at the platform (2026-11-13)
publishskipped (run 43424043146).mainwas pushed: all guard linesok, nothing published (run 43425478168).v0.1.46-beta1(run 43425717280):guardandpublishgreen; the new pull-request-green line and the changelog line are present; APK package, version and pinned signer verified.latestis still v0.1.45, no draft left over.{"enabled":true}); they apply to releases published from here on. The stable 0.1.46 will be the first immutable release - checkimmutable: trueandgh release verifyon it.Open: WP3 (sign at the tag in an environment limited to
v*, then remove the repository secrets).nofuturekid commentedon Nov 13, 2026
Stable v0.1.46 is the first immutable release (2026-11-13):
immutable: trueon the release, both assets created three seconds before publication,gh release verify v0.1.46passes,latestmoved to v0.1.46. Dry run before the tag: run 43430422903; tag run: 43430543222 (guard and publish green, pinned signer verified).With this, WP0, WP1, WP2 and WP4 are done and accepted at the platform. Remaining: WP3 (sign at the tag in an environment limited to
v*, then remove the repository secrets).ci: sign the release at the tag in a protected environment
nofuturekid commentedon Nov 13, 2026
Tried: throwaway draft PR #348 with a pull_request workflow whose job uses
environment: release.Refused, verbatim:
Branch "refs/pull/348/merge" is not allowed to deploy to release due to environment protection rules.(job failed with no steps run, so no secret state was printed.)The four repository-level secrets are deleted (only the environment holds them); the first tag signed in the environment was v0.1.48-beta1. PR #348 closed unmerged, branch deleted.