Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ cite these IDs. Renaming or removing an ID means updating them in the same PR.
| R-TRAILER | Commit messages end with `Co-Authored-By: Claude <Model> <noreply@anthropic.com>`, naming the model that wrote the change as your own system prompt states it. PR bodies end with the Claude Code footer line. No session link and no session trailer anywhere; this overrides any harness attribution reminder. | builder verify step (grep); convention |
| R-PUBLIC | Public texts (issues, PRs, commits, docs) are English and carry no private details: no LAN host names, IP addresses, names of the maintainer's containers or VMs, e-mail addresses or local paths. | reviewer scope; convention |
| R-MERGE | Fast-forward only. Agents never merge and never push to main; `gh pr merge`, the REST merge endpoint and the merge buttons are not used (the user-level guard blocks them). A PR is ready when its head commit and every commit in `main...head` are signed and verified, the range is linear, `compare main...head` reports `behind_by 0`, and check `build` (GitHub Actions) is SUCCESS on exactly that SHA. Only the coordinating session then runs `git -C <worktree> push origin <sha>:refs/heads/main`. If main moved: same-repo human PRs are rebased onto origin/main in their worktree (signed by the global config) and pushed with `--force-with-lease` to their own branch; bot PRs (Dependabot, Renovate) are rebased by the bot. Never `gh pr update-branch` or the Update-branch button. | ruleset "main (no bypass)"; ruleset "main: pull request (admin push bypass)"; the bash guard; ready gate in nova-merge-train (code) |
| R-RELEASE | No saved workflow pushes a tag or touches a release. A stable tag is pushed only on the maintainer's explicit go for that release, on the release PR's head commit by SHA, after the coordinator fast-forwarded main to it and before anything else is pushed to main; before pushing a tag, run the release dry run (HANDOFF.md, "Release dry run"); never mark a release latest or clear a pre-release flag by hand. Beta tails follow `.claude/agents/release-engineer.md`. | no tag or release step in any saved workflow; release-engineer boundary; convention (the v\* tag ruleset only blocks moving or deleting tags) |
| R-RELEASE | No saved workflow pushes a tag or touches a release. A stable tag is pushed only on the maintainer's explicit go for that release, on the release PR's head commit by SHA, after the coordinator fast-forwarded main to it and before anything else is pushed to main; before pushing a tag, run the release dry run (`gh workflow run release.yml -f sha=<release PR head> -f version=<versionName>`, HANDOFF.md, "Release dry run"); never mark a release latest or clear a pre-release flag by hand. Beta tails follow `.claude/agents/release-engineer.md`. | no tag or release step in any saved workflow; release-engineer boundary; convention (the v\* tag ruleset only blocks moving or deleting tags) |
| R-BUILD | An APK-changing PR runs `./scripts/local-ci.sh` green before push, one run at a time on a machine (shared Gradle volume). Docs-only PRs and read-only runs do not run Gradle, docker or local-ci. | nova-pr code: one APK task per run, refused while frozen; the local-ci run itself is convention |
| R-SECRETS | Never print secrets, never cat credential or config files, never open or hash keystores. `gh secret list` shows names only. | convention (settings.json denies only Edit/Write of \*.keystore) |
| R-WAIT | One Bash call waits at most 9 minutes (`timeout 540 sh -c 'until ...; do sleep 30; done'`). In read-only runs, prefix network and `gh` calls with `timeout 120`. Read CI with `gh pr checks <n>` inside such a bounded loop, never `--watch`. No `tail -f`, no unbounded loops. | convention |
Expand Down
19 changes: 12 additions & 7 deletions .claude/agents/release-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ the APK on every PR (not on push to `main`, ADR-0014) and uploads it as the
`app-release` artifact; `release.yml` does **not rebuild** — on a `v*` tag push
it resolves tag → commit SHA, finds the successful CI run (by the tagged SHA,
which is the PR head that main was fast-forwarded to; `(#NN)` only for
squash-era tags before 2026-11-13), runs a guard (commit on `main`, CI tree ==
tag tree, `versionName` == tag, `versionCode` increased; on the `(#NN)` path
also PR merged as exactly this commit), downloads the already-built artifact, verifies the APK itself
(package id, `versionName` == tag, `versionCode` == source, pinned signer
certificate; `.github/scripts/release-verify-apk.sh`), and publishes the GitHub
Release. A tag can only ship if the commit's CI was green. `v*` tags are
squash-era tags before 2026-11-13), runs a guard in a read-only job (commit on
`main`, a green `pull_request` run on the tagged commit itself, CI tree == tag
tree, `versionName` == tag, `versionCode` increased, non-empty `CHANGELOG.md`
section; on the `(#NN)` path also PR merged as exactly this commit), and only
then, in a second job with write access, downloads the already-built APK and
`mapping.txt`, verifies the APK itself (package id, `versionName` == tag,
`versionCode` == source, pinned signer certificate;
`.github/scripts/release-verify-apk.sh`), and publishes the GitHub Release
draft-first (draft, upload, then public). A tag can only ship if the commit's CI was green. `v*` tags are
protected against update and deletion; fix a bad tag with a new commit and
tag, never by moving it.

Expand Down Expand Up @@ -103,7 +106,9 @@ in-app updater shows verbatim. Rules:
that SHA) and **return** the push command plus the tag commands; you never
merge and never push `main` or a tag. The coordinator runs them in this
order: first `git push origin <sha>:refs/heads/main`, then a check that
`main` == `<sha>`, then the release dry run (HANDOFF.md, "Release dry run"),
`main` == `<sha>`, then the release dry run (`gh workflow run release.yml -f sha=<sha> -f
version=<versionName>`, HANDOFF.md, "Release dry run"; the guard job must be
green, `publish` skipped),
then `git tag -s vX.Y.Z-betaN -m "..." <sha> && git push origin
refs/tags/vX.Y.Z-betaN`. `release.yml` then promotes the known-good
artifact built for that exact SHA.
Expand Down
84 changes: 59 additions & 25 deletions .github/scripts/release-guard.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
#!/usr/bin/env bash
# Release guard (ADR-0014 amendment, CI review H2): runs in release.yml after the
# CI artifact was located and BEFORE it is downloaded or published. Fails loudly
# unless the tag, the tagged commit and the CI run that built the artifact agree.
# Release guard (ADR-0014 amendments, CI review H2, issue #295 WP2): runs in the
# `guard` job of release.yml after the CI artifact was located and BEFORE it is
# downloaded or published. Fails loudly unless the tag, the tagged commit and the
# CI run that built the artifact agree. The same script runs in the dry run
# (workflow_dispatch), where no tag exists yet: TAG is then v<version input>, and
# release-preflight.sh accepts a tag name that is still free.
#
# Checks, in order: tag shape; the commit is on main; a green pull_request run of
# this repository exists on the pull request head (the tagged commit itself on
# lookup path 1; on path 2, the head of the pull request named in the subject,
# which must also have been merged as exactly this commit); the CI tree equals
# the tagged tree; release-preflight.sh (version shape, versionCode up, tag name
# free, non-empty CHANGELOG section); versionName == tag.
#
# Env (all required unless noted):
# TAG tag name, e.g. v0.1.41
# SHA commit the tag points at
# SHA commit the tag points at (the commit to release in a dry run)
# RUN_ID CI run the artifact will be downloaded from
# RUN_HEAD_SHA head_sha of that run
# PR_NUM PR number parsed from the commit subject (empty on the direct path)
Expand All @@ -31,46 +41,70 @@ gradle_field() { # $1 = commit, $2 = versionCode|versionName; fails on more than
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc|pre)[0-9]+)?$ ]] \
|| fail "tag $TAG is not vX.Y.Z or vX.Y.Z-(alpha|beta|rc|pre)N (trailing digit required, ADR-0005); a digitless pre-release tag would be published as stable."

# (b) The tagged commit must be on main. A PR head tagged before the squash
# merge is not an ancestor of main.
# (b) The tagged commit must be on main. A PR head tagged before the PR landed
# is not an ancestor of main.
git merge-base --is-ancestor "$SHA" "$MAIN_REF" \
|| fail "tagged commit $SHA is not an ancestor of $MAIN_REF (tag on a PR/feature-branch commit?). Merge the PR first, then tag the squash commit on main."
|| fail "tagged commit $SHA is not an ancestor of $MAIN_REF (tag on a PR/feature-branch commit?). Land the PR on main by the fast-forward push first, then tag its head commit."
echo "ok: $SHA is on $MAIN_REF"

# (c) PR path: the PR must be merged, and its merge commit must be the tagged commit.
# (c) The pull request behind the commit.
pr_merged_as_sha() { # $1 = PR number: merged, and its merge commit is the tagged commit
local info
info=$(gh pr view "$1" --json state,mergeCommit --jq '.state + " " + (.mergeCommit.oid // "")')
[ "${info%% *}" = "MERGED" ] || fail "PR #$1 is not merged (state: ${info%% *})."
[ "${info#* }" = "$SHA" ] \
|| fail "PR #$1 was merged as ${info#* }, not as the tagged commit $SHA (cherry-pick or direct push reusing the subject?)."
echo "ok: PR #$1 merged as $SHA"
}
green_pr_run() { # $1 = commit: a successful ci.yml pull_request run of THIS repository (not a fork) on it
local n
n=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$1&event=pull_request&status=success&per_page=50" \
| jq -r --arg repo "$GITHUB_REPOSITORY" '[.workflow_runs[] | select(.head_repository.full_name == $repo)] | length')
[ "$n" -gt 0 ] \
|| fail "no successful ci.yml pull_request run from $GITHUB_REPOSITORY on $1. Only a commit that is its own pull request head and was green there can be released; a dispatch run made after tagging does not count. Open a pull request for the change, let it go green, land it by the fast-forward push, and tag that head."
echo "ok: $1 has a successful ci.yml pull_request run from this repository ($n)"
}
if [ -n "${PR_NUM:-}" ]; then
PR_INFO=$(gh pr view "$PR_NUM" --json state,mergeCommit --jq '.state + " " + (.mergeCommit.oid // "")')
[ "${PR_INFO%% *}" = "MERGED" ] || fail "PR #$PR_NUM is not merged (state: ${PR_INFO%% *})."
[ "${PR_INFO#* }" = "$SHA" ] \
|| fail "PR #$PR_NUM was merged as ${PR_INFO#* }, not as the tagged commit $SHA (cherry-pick or direct push reusing the subject?)."
echo "ok: PR #$PR_NUM merged as $SHA"
# Path 2 (the lookup in release.yml found no run on the tagged commit and took
# the PR number from the subject, e.g. a squash commit or a rebase-merged
# commit): the PR must have been merged as exactly this commit, and its head
# (RUN_HEAD_SHA) must have been green in a pull_request run of its own. The
# lookup alone also accepts a dispatch run on that head.
pr_merged_as_sha "$PR_NUM"
green_pr_run "$RUN_HEAD_SHA"
else
# Path 1 (normal since 2026-11-13): the tagged commit is its own pull request
# head, so a successful ci.yml run of event pull_request from THIS repository
# (not a fork) must exist on exactly this SHA. The artifact may still come from
# a workflow_dispatch run made later for recovery, which is why the lookup in
# release.yml accepts both events and this check does not (issue #295, f). No
# fallback: a commit without such a run is refused, whatever its subject says.
green_pr_run "$SHA"
fi

# (d) The tree CI built must equal the tagged commit's tree (holds under strict
# branch protection; an up-to-date PR head has the tree of its squash commit).
# branch protection; an up-to-date PR head has the tree of its merged commit).
RUN_TREE=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$RUN_HEAD_SHA" --jq '.tree.sha')
TAG_TREE=$(git rev-parse "$SHA^{tree}")
[ "$RUN_TREE" = "$TAG_TREE" ] \
|| fail "CI run $RUN_ID built tree $RUN_TREE (commit $RUN_HEAD_SHA) but the tagged commit has tree $TAG_TREE; the artifact does not match the tag source."
echo "ok: CI run $RUN_ID built the tagged tree $TAG_TREE"

# (e) Version and changelog checks shared with the pull request job `release-check`
# in ci.yml: versionName shape (ADR-0005), versionCode above the previous v* tag
# (ADR-0015), the tag name free or already on this commit, and a non-empty
# CHANGELOG.md section (ADR-0031). Fatal here, before anything is downloaded.
"$(dirname "${BASH_SOURCE[0]}")/release-preflight.sh" "$SHA"

# (a) versionName at the tagged commit must equal the tag (the pre-release suffix
# is part of versionName, ADR-0013), and versionCode must have increased
# since the previous v* tag (ADR-0015).
# is part of versionName, ADR-0013). The preflight derives the tag from
# versionName; this ties it to the tag that was actually pushed.
VNAME=$(gradle_field "$SHA" versionName)
VCODE=$(gradle_field "$SHA" versionCode)
[ -n "$VNAME" ] && [ -n "$VCODE" ] || fail "could not read versionName/versionCode from app/build.gradle.kts at $SHA."
[ "v$VNAME" = "$TAG" ] \
|| fail "app/build.gradle.kts at $SHA has versionName \"$VNAME\" but the tag is $TAG. The updater would offer an APK that installs as a no-op (ADR-0013/0015); bump the version in a new commit and tag that."
if PREV=$(git describe --tags --abbrev=0 --match 'v*' "$SHA^" 2>/dev/null); then
PREV_VCODE=$(gradle_field "$PREV" versionCode)
[ -n "$PREV_VCODE" ] || fail "could not read versionCode from app/build.gradle.kts at the previous tag $PREV."
[ "$VCODE" -gt "$PREV_VCODE" ] \
|| fail "versionCode $VCODE at $SHA is not greater than $PREV_VCODE at the previous tag $PREV (ADR-0015)."
echo "ok: versionName $VNAME == tag, versionCode $VCODE > $PREV_VCODE ($PREV)"
else
echo "ok: versionName $VNAME == tag, versionCode $VCODE (no previous v* tag)"
fi
echo "ok: versionName $VNAME == tag"
# Hand the source-derived versionCode to the post-download APK verification
# (release-verify-apk.sh), which requires the APK to report exactly this value.
if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "vcode=$VCODE" >> "$GITHUB_OUTPUT"; fi
5 changes: 3 additions & 2 deletions .github/scripts/release-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# Release preflight (issue #295, decision f): the checks on a version bump that
# do not depend on a CI run, so that the pull request and the tag cannot drift
# apart. ci.yml (job `release-check`) runs it on a pull request whose
# versionName changed; release.yml will call it at the tag (a later work
# package). It only reads git; it needs no token and no Gradle.
# versionName changed; release-guard.sh runs it in the `guard` job of
# release.yml (tag run and dry run). It only reads git; it needs no token and
# no Gradle.
#
# Usage: release-preflight.sh <commit>
#
Expand Down
Loading
Loading