Skip to content

ci: adopt the TasmoFleet GitHub and release rules (R1–R15), enforced by CI and rulesets #295

Open

Description

@nofuturekid

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.kts stays unchanged, so CI produces it signed with the debug-key fallback (app/build.gradle.kts:57-61).
  • release.yml downloads that APK, signs it with apksigner, then verifies the result.
    • Signing runs in a job with environment: release and 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 schemes false, 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.sh unchanged. Its pinned certificate and its rule "exactly one distinct signer digest" fail closed if any debug signature survived.

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-release artifact" 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 upload or gh release edit.

What has to change:

  1. Publish draft-first. gh release create "$TAG" --verify-tag --title "$TAG" --notes-file changelog_section.md --generate-notes [--prerelease] <apk> mapping.txt creates a draft, uploads every file, then publishes, and deletes the draft if an upload fails (cli/cli create.go draftWhileUploading). Combined with --notes-file, it appends the generated notes to the body, which matches today's output.
  2. Make mapping.txt mandatory: ci.yml upload with if-no-files-found: error, and in release.yml no continue-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-gradle with cache-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 ci and matches the title exactly with jq --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:

  1. The green status must come from a pull_request run on the tagged SHA itself, not from a dispatch run made after tagging. Add to release-guard.sh: gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&status=success", filtered on head_repository.full_name == $GITHUB_REPOSITORY, must return at least 1. This is the NOVA port of TasmoFleet's release-green.sh part 1. The artifact may still come from the recovery dispatch.
  2. 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:
    1. Record the acceptance tests in this issue. A1: gh api repos/hellwand/nova/rules/branches/main --jq '[.[].type]|sort' must give deletion, 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.
    2. Create label ci: gh label create ci --description "CI and scheduled-run failures".
    3. Optional now, required before WP3: the maintainer creates environment release and 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
      
  • Acceptance: the read-backs. gh api repos/hellwand/nova/environments/release/deployment-branch-policies --jq '[.branch_policies[] | [.name,.type]]' must give [["v*","tag"]], and gh 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:
      1. The build job gets the per-event name from (b).
      2. In setup, diff against git merge-base origin/main "$HEAD" instead of base.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.
      3. New job ci-lint, always runs, in build's needs, accepts only success. It runs:
        • actionlint over all workflows, using the pinned tarball and SHA-256 moved from actionlint.yml;
        • shellcheck .github/scripts/*.sh;
        • renovate-config-validator from a pinned renovate@<exact version>, only when renovate.json changed;
        • a check that no commit message in merge-base..head contains 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).
      4. New job release-check, always runs (no Gradle) and is in needs. It exits 0 when versionName is unchanged against the merge base, and otherwise runs .github/scripts/release-preflight.sh HEAD.
      5. The mapping upload gets if-no-files-found: error.
      6. Remove the stale squash wording in the header (ci.yml:3-10).
    • .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-level contents: read, and contents: write on 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_RE stays unchanged, because the allowlist is unchanged.
  • Acceptance on the platform:
    1. The WP1 PR shows build pass, with ci-lint and release-check successful.
    2. Run gh workflow run ci.yml --ref <probe branch>. Then gh api 'repos/hellwand/nova/commits/<sha>/check-runs?check_name=build' must not list this run, and a check build (dispatch) must exist.
    3. 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. build must run the full build and fail on ci-lint. Then close it.
    4. A throwaway PR that bumps versionName without a CHANGELOG section must fail build. Then close it.
    5. The next real release PR shows release-check successful.
  • 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.yml and 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 in release.yml reads.

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:
      1. Top-level permissions: contents: read.
      2. Job guard (contents: read, actions: read) runs find_run, then release-guard.sh, now with the PR-green check and release-preflight.sh, so a missing CHANGELOG section is fatal before any download.
      3. Job publish (contents: write), if: ${{ !cancelled() && needs.guard.result == 'success' && github.event_name == 'push' }}. It downloads the APK and the mapping (mandatory), runs release-verify-apk.sh, slices the changelog, and runs gh release create "$TAG" --verify-tag ... with both files. This replaces softprops/action-gh-release.
      4. Add a workflow_dispatch with inputs sha and version that runs only guard: a platform dry run that publishes nothing. It replaces the shell dry run in HANDOFF.md, "Release dry run".
    • 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 main and before the next tag: gh api -X PUT repos/hellwand/nova/immutable-releases.
  • Acceptance on the platform:
    1. gh workflow run release.yml -f sha=<release PR head> -f version=<versionName>: guard prints its ok: lines and publish is skipped.
    2. The same dispatch on a SHA without a PR run is refused by the PR-green check.
    3. gh api repos/hellwand/nova/immutable-releases must give {"enabled":true,...}.
    4. The next tag run publishes. gh api repos/hellwand/nova/releases/tags/<tag> --jq '.immutable, .prerelease, [.assets[].name]' must give true, the correct flag, and the APK plus mapping.txt. gh release verify <tag> must pass, and the in-app updater offers it.
  • Risk: a bug in release.yml uses up that version, because a re-run uses the workflow file of the tagged commit. The fix goes into a new commit with the next versionCode (ADR-0015). The dry run lowers this risk. Rollback: a revert PR, and gh 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-release artifact of a CI run and sign it with a throwaway test key, never the release key. Then check three things: apksigner verify --print-certs shows exactly one signer, zipalign -c -P 16 -v 4 passes, 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 uploads app-release and mapping; release.yml already ignores fork runs. Delete build-release-unsigned, and take the bot/fork condition out of build.
    • .github/workflows/release.yml: new job sign between guard and publish, with environment: release and contents: read. It decodes the keystore into $RUNNER_TEMP, runs apksigner sign with the pinned flags from (c), checks the alignment and the entry CRCs, runs release-verify-apk.sh, removes the keystore, and uploads the signed APK for publish.
    • 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:
    1. 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.
    2. 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.
    3. gh api repos/hellwand/nova/actions/secrets --jq '[.secrets[].name]' must give [], and the next PR run is still green.
  • 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 change app/, 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.yml and .github/scripts/failure-issue.sh as in (e). codeql.yml gets a failure-issue job for scheduled runs.
  • Acceptance on the platform:
    1. gh workflow run weekly.yml --ref main is green.
    2. gh workflow run weekly.yml --ref main -f probe=true opens Weekly full build failed on main with label ci. A second probe adds a comment instead of a second issue. Then close the issue.
    3. 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):

  1. R12, "environment limited to main and release tags": it would refuse NOVA's PR-time signing. Adopted by moving the signing to the tag, with an environment limited to v* tags only (WP3, decision c).
  2. R2, "no workflow_dispatch on the workflow that produces the context": NOVA keeps the dispatch for the documented artifact recovery. A dispatch reports as build (dispatch) and never produces the context, which gives the same guarantee.
  3. R14's verify command over the whole history: 67 commits from May 2026 cannot be rewritten. The command starts at eb821eb.
  4. R8: it can only be switched on after WP2, because of the order of release creation and asset upload in the pinned action.
  5. 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.
  6. 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: push job named build (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

  1. self-assigned this
    on Nov 13, 2026
  2. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    Status 2026-11-13

    WP1 landed (#297) and is accepted at the platform

    • A manual run of ci.yml on a branch commit reports as build (dispatch); the check named build on that commit came only from the pull request run.
    • Negative test A (probe: stacked docs-only PR with shellcheck error #302, closed unmerged): a docs-only change stacked on a branch with an app/ change, plus a script with a shellcheck error. The gate diffed against the merge base with main, ran the full build, and build failed because ci-lint failed.
    • Negative test B (probe: version bump without changelog section #301, closed unmerged): a versionName bump without a changelog section. release-check failed with "CHANGELOG.md has no non-empty section", and build failed although the build jobs passed.

    WP4 landed (#296) and is accepted at the platform

    Also done today: stable v0.1.45 was the first release through the fast-forward flow; release.yml found 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).

  3. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    WP2 landed (#309) and is accepted at the platform (2026-11-13)

    • Dry run on a commit without a release build: refused in the guard, publish skipped (run 43424043146).
    • Dry run on the release commit of v0.1.46-beta1 after main was pushed: all guard lines ok, nothing published (run 43425478168).
    • Tag v0.1.46-beta1 (run 43425717280): guard and publish green; the new pull-request-green line and the changelog line are present; APK package, version and pinned signer verified.
    • Draft-first holds: both assets were created at 12:30:43Z, the release was published at 12:30:46Z (for v0.1.45-beta1 the assets came after publication). Pre-release flag set, title equals the tag, body is the changelog section plus generated notes, latest is still v0.1.45, no draft left over.
    • Immutable releases are switched on now ({"enabled":true}); they apply to releases published from here on. The stable 0.1.46 will be the first immutable release - check immutable: true and gh release verify on it.

    Open: WP3 (sign at the tag in an environment limited to v*, then remove the repository secrets).

  4. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    Stable v0.1.46 is the first immutable release (2026-11-13): immutable: true on the release, both assets created three seconds before publication, gh release verify v0.1.46 passes, latest moved 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).

  5. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions