diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f1b26be7..0e9b8bea 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 `, 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 push origin :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= -f version=`, 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 ` inside such a bounded loop, never `--watch`. No `tail -f`, no unbounded loops. | convention | diff --git a/.claude/agents/release-engineer.md b/.claude/agents/release-engineer.md index 6fa37cfa..61bd8a39 100644 --- a/.claude/agents/release-engineer.md +++ b/.claude/agents/release-engineer.md @@ -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. @@ -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 :refs/heads/main`, then a check that - `main` == ``, then the release dry run (HANDOFF.md, "Release dry run"), + `main` == ``, then the release dry run (`gh workflow run release.yml -f sha= -f + version=`, HANDOFF.md, "Release dry run"; the guard job must be + green, `publish` skipped), then `git tag -s vX.Y.Z-betaN -m "..." && git push origin refs/tags/vX.Y.Z-betaN`. `release.yml` then promotes the known-good artifact built for that exact SHA. diff --git a/.github/scripts/release-guard.sh b/.github/scripts/release-guard.sh index e723870c..754920c4 100755 --- a/.github/scripts/release-guard.sh +++ b/.github/scripts/release-guard.sh @@ -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, 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) @@ -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 diff --git a/.github/scripts/release-preflight.sh b/.github/scripts/release-preflight.sh index a8bd9f4c..bf83cfa0 100755 --- a/.github/scripts/release-preflight.sh +++ b/.github/scripts/release-preflight.sh @@ -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 # diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f973652..5bc47013 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,24 +3,52 @@ name: Release # 'Build once, promote' — release builds REUSE the APK produced by ci.yml # for the same commit instead of rebuilding from scratch. # +# Two jobs (issue #295, WP2): +# guard read-only. Finds the CI run, checks that tag, commit and run agree +# (release-guard.sh, which also runs release-preflight.sh). +# publish contents: write. Runs only when the guard passed and the event is a +# tag push. Downloads the APK and mapping, verifies the APK and +# publishes the release. +# The write permission therefore never exists in a job that has not passed the +# guard, and a dry run (workflow_dispatch, below) can never publish. +# # Resolution order for finding the build artifact (ADR-0014): -# 1. Direct lookup: CI run whose head_sha matches the tagged commit -# (covers manual `gh workflow run ci.yml --ref ` rebuilds). -# 2. PR lookup: parse `(#NN)` from the tagged commit's message, -# resolve that PR's head SHA via the GitHub API, find its CI run. -# Covers the normal case — squash-merge commit on main → PR-CI artifact. +# 1. Direct lookup: CI run whose head_sha matches the tagged commit. Normal +# since 2026-11-13: the tagged commit is the pull request head that was +# landed by a fast-forward push, so its own pull_request run matches. Also +# covers a recovery run (`gh workflow run ci.yml --ref `) when the +# artifact of that run expired. +# 2. PR lookup, for squash-era tags (before 2026-11-13): parse `(#NN)` from +# the tagged commit's subject, resolve that PR's head SHA via the GitHub +# API, find its CI run. The guard verifies that the PR was merged as +# exactly this commit. # # Falls back to a clear error message if neither path resolves; recovery -# is a manual workflow_dispatch on the tag. Before anything is downloaded or -# published, release-guard.sh checks tag/commit/CI-run consistency; after the -# download release-verify-apk.sh checks the APK's own version, package, signer. +# is a workflow_dispatch of ci.yml on the tag. Before anything is downloaded or +# published, release-guard.sh checks tag/commit/CI-run consistency, that a +# pull_request run of this repository was green on the tagged commit itself, and +# that the version and the CHANGELOG.md section are in order; after the download +# release-verify-apk.sh checks the APK's own version, package, signer. +# +# Dry run: `gh workflow run release.yml -f sha= -f version=` +# runs only the guard job, on a commit that is already on main and has no tag +# yet. Nothing is published and no tag is needed (HANDOFF.md, "Release dry run"). on: push: - tags: ['v*'] + tags: ["v*"] + workflow_dispatch: + inputs: + sha: + description: "Commit to check (the release PR head, already on main)" + required: true + type: string + version: + description: "versionName of that commit, e.g. 0.1.46 or 0.1.46-beta1 (the tag is v)" + required: true + type: string permissions: - contents: write - actions: read + contents: read # Serialise releases per tag and never cancel an in-flight one: a release # job that has started publishing must finish (it writes a user-visible @@ -32,31 +60,74 @@ concurrency: cancel-in-progress: false jobs: - release: + guard: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + outputs: + run_id: ${{ steps.run.outputs.id }} + vcode: ${{ steps.guard.outputs.vcode }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # need history to resolve tag → commit SHA - # contents: write job; git only reads here, gh uses GH_TOKEN. + fetch-depth: 0 # need history to resolve tag → commit SHA and the previous tag persist-credentials: false - - name: Resolve target commit SHA - id: sha + # Tag push: the tag and the commit it points at. Dry run: the tag the + # version input would get, and the commit input. Inputs come in through + # env, never inline in the script. + - name: Resolve target tag and commit + id: target + env: + EVENT: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_VERSION: ${{ inputs.version }} run: | - SHA=$(git rev-parse "${GITHUB_REF#refs/tags/}^{commit}") - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "Tag ${GITHUB_REF#refs/tags/} points at $SHA" + set -euo pipefail + if [ "$EVENT" = push ]; then + TAG="$REF_NAME" + SHA=$(git rev-parse "$TAG^{commit}") + echo "Tag $TAG points at $SHA" + else + VER="${INPUT_VERSION#v}" + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc|pre)[0-9]+)?$ ]]; then + echo "::error::Input version \"$INPUT_VERSION\" is not X.Y.Z or X.Y.Z-(alpha|beta|rc|pre)N (ADR-0005)." + exit 1 + fi + if ! [[ "$INPUT_SHA" =~ ^[0-9a-f]{7,40}$ ]]; then + echo "::error::Input sha \"$INPUT_SHA\" is not a hexadecimal commit id." + exit 1 + fi + TAG="v$VER" + SHA=$(git rev-parse --verify --quiet "$INPUT_SHA^{commit}") \ + || { echo "::error::Commit $INPUT_SHA does not exist in this repository."; exit 1; } + echo "Dry run: would release $TAG from $SHA. Nothing will be published." + fi + { echo "tag=$TAG"; echo "sha=$SHA"; } >> "$GITHUB_OUTPUT" - name: Find CI artifact for this commit id: run env: GH_TOKEN: ${{ github.token }} - SHA: ${{ steps.sha.outputs.sha }} + SHA: ${{ steps.target.outputs.sha }} + TAG: ${{ steps.target.outputs.tag }} + EVENT: ${{ github.event_name }} REPO: ${{ github.repository }} run: | set -e + # What to do after fixing a missing artifact. + if [ "$EVENT" = push ]; then + AGAIN="re-run this release run: gh run rerun $GITHUB_RUN_ID (tags are protected; do not re-push)." + RECOVER_REF="$TAG" + else + AGAIN="start the dry run again." + RECOVER_REF="" + fi + # Newest successful ci.yml run for a head SHA that (a) came from a # trusted source — `pull_request` from THIS repo (not a fork) or # `workflow_dispatch` — and (b) actually has a live `app-release` @@ -77,10 +148,12 @@ jobs: done } - # Path 1 — direct: CI run whose head_sha == tagged commit. - # Hits when someone manually triggered ci.yml on the tag - # (workflow_dispatch with --ref ). Normal squash-merge commits - # won't have a direct match and fall through to Path 2. + # Path 1 — direct: CI run whose head_sha == tagged commit. This is + # the normal case: the tagged commit is the pull request head that + # was landed by a fast-forward push. It also hits when someone + # triggered ci.yml on the tag (workflow_dispatch with --ref ) + # to rebuild an expired artifact. release-guard.sh separately + # requires a successful pull_request run on this commit. RUN_ID=$(find_run "$SHA") if [ -n "$RUN_ID" ]; then echo "Found CI run $RUN_ID for $SHA (direct match)." @@ -88,29 +161,32 @@ jobs: exit 0 fi - # Path 2 — via PR: parse `(#NN)` from the tagged commit's - # subject line (GitHub's squash-merge format), resolve PR head - # SHA, look up the PR's CI run. The guard step below verifies the - # PR really was merged as this commit. + # Path 2 — via PR, for squash-era tags (before 2026-11-13): parse + # `(#NN)` from the tagged commit's subject line (GitHub's squash + # format), resolve the PR head SHA, look up the PR's CI run. The + # guard step below verifies the PR really was merged as this commit. SUBJECT=$(git log -1 "$SHA" --format=%s) PR_NUM=$(echo "$SUBJECT" | grep -oE '\(#[0-9]+\)$' | tr -d '(#)' || true) if [ -z "$PR_NUM" ]; then echo "::error::No CI run with an app-release artifact for $SHA and no PR # in commit message." echo "::error::Subject was: $SUBJECT" - echo "::error::Direct-push? Run CI on the tag: gh workflow run ci.yml --ref ${GITHUB_REF#refs/tags/} — wait for it, then re-run this release run: gh run rerun $GITHUB_RUN_ID (tags are protected; do not re-push)." + echo "::error::Artifact expired or never built? Run CI on the tag: gh workflow run ci.yml --ref $RECOVER_REF — wait for it, then $AGAIN" exit 1 fi - PR_SHA=$(gh pr view "$PR_NUM" --json headRefOid --jq '.headRefOid // empty') + # An issue number in the subject (not a PR) or a deleted PR makes + # `gh pr view` fail; that lands in the error below, with the hint. + PR_SHA=$(gh pr view "$PR_NUM" --json headRefOid --jq '.headRefOid // empty' 2>/dev/null || true) if [ -z "$PR_SHA" ]; then - echo "::error::Couldn't resolve PR #$PR_NUM head SHA (deleted PR?)." + echo "::error::Couldn't resolve PR #$PR_NUM head SHA (#$PR_NUM is an issue or a deleted PR?), and there is no CI run with an app-release artifact for $SHA." + echo "::error::Artifact expired or never built? Run CI on the tag: gh workflow run ci.yml --ref $RECOVER_REF — wait for it, then $AGAIN" exit 1 fi RUN_ID=$(find_run "$PR_SHA") if [ -z "$RUN_ID" ]; then echo "::error::No successful PR CI run with an app-release artifact for SHA $PR_SHA (PR #$PR_NUM)." - echo "::error::Re-run the PR's CI (expired artifact: gh workflow run ci.yml --ref ${GITHUB_REF#refs/tags/}), wait for it, then re-run this release run: gh run rerun $GITHUB_RUN_ID (tags are protected; do not re-push)." + echo "::error::Re-run the PR's CI (expired artifact: gh workflow run ci.yml --ref $RECOVER_REF), wait for it, then $AGAIN" exit 1 fi @@ -118,20 +194,43 @@ jobs: { echo "id=$RUN_ID"; echo "head_sha=$PR_SHA"; echo "pr_num=$PR_NUM"; } >> "$GITHUB_OUTPUT" # Refuse to promote an artifact that does not belong to this tag: the - # commit must be on main, the PR merged as exactly this commit, the CI - # tree equal to the tagged tree, versionName == tag, versionCode up. + # commit must be on main, its own pull request must have been green (or, + # for a squash-era tag, merged as exactly this commit), the CI tree equal + # to the tagged tree, the version and the CHANGELOG section in order + # (release-preflight.sh), versionName == tag. # Logic lives in .github/scripts/release-guard.sh (CI review H2, ADR-0014). - name: Guard — artifact matches tag id: guard env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - SHA: ${{ steps.sha.outputs.sha }} + TAG: ${{ steps.target.outputs.tag }} + SHA: ${{ steps.target.outputs.sha }} RUN_ID: ${{ steps.run.outputs.id }} RUN_HEAD_SHA: ${{ steps.run.outputs.head_sha }} PR_NUM: ${{ steps.run.outputs.pr_num }} run: .github/scripts/release-guard.sh + - name: Dry run result + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + TAG: ${{ steps.target.outputs.tag }} + SHA: ${{ steps.target.outputs.sha }} + run: echo "Dry run passed — a push of tag $TAG on $SHA would be released. Nothing was published." + + publish: + needs: guard + if: ${{ !cancelled() && needs.guard.result == 'success' && github.event_name == 'push' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # create the release + actions: read # download the artifacts of the CI run + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # contents: write job; git only reads here, gh uses GH_TOKEN. + persist-credentials: false + # The `app-release` artifact contains the `direct`-flavor APK only # (ADR-0040). CI builds both flavors at release type but uploads # only `direct` here — `store` is built by F-Droid themselves from @@ -143,17 +242,18 @@ jobs: with: name: app-release path: artifacts/apk - run-id: ${{ steps.run.outputs.id }} + run-id: ${{ needs.guard.outputs.run_id }} github-token: ${{ github.token }} + # Mandatory: under immutable releases a missing mapping.txt can never be + # added later, so the job fails here instead of publishing without it. - name: Download mapping from CI run uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: mapping path: artifacts/mapping - run-id: ${{ steps.run.outputs.id }} + run-id: ${{ needs.guard.outputs.run_id }} github-token: ${{ github.token }} - continue-on-error: true # The APK is the source of truth, not the gradle text the guard parsed: # require its own package, versionName (== tag), versionCode (== the @@ -162,11 +262,13 @@ jobs: - name: Verify APK version, package and signer env: TAG: ${{ github.ref_name }} - EXPECTED_VERSION_CODE: ${{ steps.guard.outputs.vcode }} + EXPECTED_VERSION_CODE: ${{ needs.guard.outputs.vcode }} run: | set -e [ "$(find artifacts/apk -maxdepth 1 -name '*.apk' | wc -l)" = 1 ] \ || { echo "::error::Expected exactly one APK in the app-release artifact."; ls -la artifacts/apk; exit 1; } + [ -f artifacts/mapping/mapping.txt ] \ + || { echo "::error::The mapping artifact has no mapping.txt."; ls -la artifacts/mapping; exit 1; } APK=$(find artifacts/apk -maxdepth 1 -name '*.apk') .github/scripts/release-verify-apk.sh - name: Rename APK to include tag @@ -191,10 +293,11 @@ jobs: fi # Slice the CHANGELOG.md section matching this tag into the release - # body (ADR-0031). Non-fatal: a missing/empty section just warns and - # the release still publishes with the appended auto notes. The tag - # name comes from GITHUB_REF (maintainer-pushed, not untrusted input) - # and is read via an env var, quoted. + # body (ADR-0031). Fatal when empty: the guard already refused a tag + # without a section, so this only trips if the file differs from the one + # the guard read. The tag name comes from GITHUB_REF (maintainer-pushed, + # not untrusted input) and is read via an env var, quoted. The heading is + # matched literally, as release-preflight.sh does. - name: Extract changelog section id: changelog env: @@ -204,26 +307,34 @@ jobs: TAG="${REF#refs/tags/}" VER="${TAG#v}" awk -v ver="$VER" ' - $0 ~ ("^## \\[" ver "\\]") {found=1; next} + index($0, "## [" ver "]") == 1 {found = 1; next} found && /^## \[/ {exit} found {print} - ' CHANGELOG.md > changelog_section.md || true - # strip leading/trailing blank lines + ' CHANGELOG.md > changelog_section.md + # strip leading blank lines sed -i -e '/./,$!d' changelog_section.md if [ ! -s changelog_section.md ]; then - echo "::warning::No CHANGELOG.md section for $VER — publishing with auto notes only." - else - echo "Changelog section for $VER:" - sed 's/^/ /' changelog_section.md + echo "::error::No CHANGELOG.md section for $VER (the guard should have refused this tag)." + exit 1 fi + echo "Changelog section for $VER:" + sed 's/^/ /' changelog_section.md + # Draft-first, in ONE command: `gh release create` with assets creates the + # release as a draft, uploads every file, and only then publishes it (and + # deletes the draft if an upload fails). The assets are therefore in place + # before the release is public, which immutable releases require. With + # --notes-file and --generate-notes the generated notes are appended to the + # changelog section. Nothing may edit or upload to the release afterwards. - name: Publish GitHub Release - uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 - with: - files: | - ${{ steps.rename.outputs.apk }} - artifacts/mapping/mapping.txt - body_path: changelog_section.md - generate_release_notes: true - fail_on_unmatched_files: false - prerelease: ${{ steps.prerelease.outputs.value == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + APK: ${{ steps.rename.outputs.apk }} + PRERELEASE: ${{ steps.prerelease.outputs.value }} + run: | + set -euo pipefail + args=(--verify-tag --title "$TAG" --notes-file changelog_section.md --generate-notes) + if [ "$PRERELEASE" = true ]; then args+=(--prerelease); fi + gh release create "$TAG" "${args[@]}" "$APK" artifacts/mapping/mapping.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e6509b3..77a0cd2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,15 +45,17 @@ Tags are the release trigger. After your code is on `main` AND `ci.yml` is green ```bash git checkout main && git pull +gh workflow run release.yml -f sha=$(git rev-parse HEAD) -f version=0.2.0 # dry run: only the guard job runs +# wait for it: the guard job must be green and `publish` skipped (HANDOFF.md, "Release dry run") git tag -a v0.2.0 -m "v0.2.0 — what's new" git push origin v0.2.0 ``` -`release.yml` then runs. It does **not** rebuild — it downloads the release APK that `ci.yml` already produced for this commit's PR (the PR head SHA), and attaches it to a new GitHub Release. +`release.yml` then runs. It does **not** rebuild — it downloads the release APK and `mapping.txt` that `ci.yml` already produced for this commit (the PR head SHA) and publishes them as a new GitHub Release. The release is created as a draft, both files are uploaded, and only then it becomes public, in one `gh release create` command (this is what lets immutable releases work). If you tag too early (before CI is green), the release workflow fails with a clear message; just wait for CI and re-run the failed release run (`gh run rerun `). Tags cannot be re-pushed (the ruleset forbids update and deletion). -Before anything is published, a guard step checks that the tag matches what CI built: the commit is on `main`, the CI run built exactly this commit (the PR head that was fast-forwarded onto `main`; for tags before 2026-11-13 the squash commit's PR), `versionName` in `app/build.gradle.kts` equals the tag (including a `-betaN` suffix) and `versionCode` is higher than at the previous tag. A failure names the mismatch; fix it with a new commit, not by moving the tag (tags are protected against update and deletion). If the artifact lookup finds no CI run (e.g. after a direct push), (or the artifact expired) run `gh workflow run ci.yml --ref ` (it reports as `build (dispatch)` and never as `build`, so it cannot satisfy the ruleset), wait for it, then re-run the failed release run (`gh run rerun `). With tags locked, a guard failure on a version mismatch uses up that tag: fix forward with a new commit and the next version. After the artifact download, a second step reads the APK itself (`aapt2`/`apksigner`) and requires package `io.github.nofuturekid.nova`, `versionName` == tag, `versionCode` == the value in source, and the pinned release-signing certificate before anything is published ([ADR-0014](docs/adr/0014-single-ci-run-per-pr.md), `.github/scripts/release-verify-apk.sh`). +Before anything is downloaded or published, the read-only `guard` job checks that the tag matches what CI built: the commit is on `main`, it was the head of its own pull request and that pull request's `ci.yml` run was green on exactly this commit (a `workflow_dispatch` run made after tagging does not count; for tags before 2026-11-13 the squash commit's PR must have been merged as this commit), the CI run built the same tree, `versionName` in `app/build.gradle.kts` equals the tag (including a `-betaN` suffix), `versionCode` is higher than at the previous tag, and `CHANGELOG.md` has a non-empty section for the version (`.github/scripts/release-preflight.sh`, the same check the pull request runs). Only the `publish` job has write access, and it runs only after the guard passed. A failure names the mismatch; fix it with a new commit, not by moving the tag (tags are protected against update and deletion). If the artifact lookup finds no CI run (e.g. after a direct push), (or the artifact expired) run `gh workflow run ci.yml --ref ` (it reports as `build (dispatch)` and never as `build`, so it cannot satisfy the ruleset), wait for it, then re-run the failed release run (`gh run rerun `). With tags locked, a guard failure on a version mismatch uses up that tag: fix forward with a new commit and the next version. Before pushing the tag, run the dry run, which executes only the guard (`gh workflow run release.yml -f sha= -f version=`; see HANDOFF.md, "Release dry run"). After the artifact download, a second step reads the APK itself (`aapt2`/`apksigner`) and requires package `io.github.nofuturekid.nova`, `versionName` == tag, `versionCode` == the value in source, and the pinned release-signing certificate before anything is published ([ADR-0014](docs/adr/0014-single-ci-run-per-pr.md), `.github/scripts/release-verify-apk.sh`). ### Naming the release commit / PR @@ -77,6 +79,7 @@ The `` is what the cut delivers (the bundled feature/fix), not ### Pre-releases (beta → rc → stable) ```bash +gh workflow run release.yml -f sha=$(git rev-parse HEAD) -f version=0.2.0-beta1 # dry run first: guard green, `publish` skipped git tag -a v0.2.0-beta1 -m "v0.2.0 beta 1" git push origin v0.2.0-beta1 ``` diff --git a/HANDOFF.md b/HANDOFF.md index 4264e0f5..d55287ba 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -150,7 +150,7 @@ app/src/main/ The "why" for everything in this section lives in ADRs under [`docs/adr/`](docs/adr/). What follows is the operational summary. - **Rulesets** on `main` (classic branch protection is removed): "main (no bypass)" carries deletion, non-fast-forward, linear history, signed commits and the required status check `build` (strict, GitHub Actions), with no bypass actor; "main: pull request (admin push bypass)" carries the pull-request rule (0 required approvals, solo-dev-friendly) and lets the admin role push. A green, signed, rebased PR head therefore lands by a fast-forward push; nothing else can reach `main`. Break-glass: set the ruleset "main (no bypass)" to `disabled` deliberately and back to `active`. See [ADR-0003](docs/adr/0003-pr-gate-required-status-check.md) (amendment 2026-11-13). -- **Build-once-promote** split between `ci.yml` (builds debug+release APKs, uploads artifacts) and `release.yml` (`v*` tag → finds the SHA's successful CI run (the PR run whose head is the tagged commit; squash-era tags before 2026-11-13 resolve the PR from `(#NN)` in the subject), runs the release guard (commit on `main`, CI tree == tag tree, and on the `(#NN)` path PR merged as this commit, `versionName` == tag, `versionCode` up), downloads that run's `app-release` artifact, verifies the APK itself (package id, `versionName` == tag, `versionCode` == source, pinned signer certificate; `.github/scripts/release-verify-apk.sh`), then publishes the GitHub Release; **never rebuilds**). See [ADR-0004](docs/adr/0004-build-once-promote-pipeline.md). +- **Build-once-promote** split between `ci.yml` (builds debug+release APKs, uploads artifacts) and `release.yml` (`v*` tag → finds the SHA's successful CI run (the PR run whose head is the tagged commit; squash-era tags before 2026-11-13 resolve the PR from `(#NN)` in the subject), runs the release guard in the read-only job `guard` (commit on `main`, a green `pull_request` run of this repository on the tagged commit itself, or on the `(#NN)` path for squash-era tags the PR merged as this commit, CI tree == tag tree, `.github/scripts/release-preflight.sh`: version shape, `versionCode` up, tag name free, non-empty `CHANGELOG.md` section; `versionName` == tag), then the job `publish` (the only one with `contents: write`) downloads that run's `app-release` and `mapping` artifacts (both mandatory), 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 with one `gh release create` (draft, upload both files, then public); **never rebuilds**). See [ADR-0004](docs/adr/0004-build-once-promote-pipeline.md). - **Concurrency**: `ci.yml` cancels superseded runs unconditionally; it no longer runs on push to `main` (ADR-0014), so there is no main run to protect (ADR-0011's carve-out is moot). See [ADR-0011](docs/adr/0011-cancel-in-progress-only-on-pr-branches.md), [ADR-0014](docs/adr/0014-single-ci-run-per-pr.md). - **Docs-only CI bypass**: `ci.yml` builds only when a build-affecting path changed (inverted allowlist: `app/`, `gradle/`, wrapper, root Gradle files, `ci.yml`); anything else finishes in ~10s and still reports `SUCCESS` on `build`. No version bump, no tag for these. The diff is taken against the merge base with `origin/main` and fails closed (no merge base or an empty diff fails `setup`). The jobs `ci-lint` (actionlint, shellcheck, `renovate.json` validator when changed, no session links in commit messages) and `release-check` (`.github/scripts/release-preflight.sh` when `versionName` changed) run on every run and `build` needs `success` from both. On `workflow_dispatch` the aggregate job reports as `build (dispatch)`, never as `build`. See [ADR-0029](docs/adr/0029-invert-ci-build-skip-filter.md) (supersedes ADR-0009; amendment 2026-11-13) and [ADR-0003](docs/adr/0003-pr-gate-required-status-check.md). - **Tag conventions**: stable `v0.X.Y`, pre-release `v0.X.Y-beta1` / `-rc1` with trailing digit required. See [ADR-0005](docs/adr/0005-prerelease-tag-convention.md). @@ -182,11 +182,20 @@ The "why" for everything in this section lives in ADRs under [`docs/adr/`](docs/ A burnt tag (v0.1.42-beta1: tag pushed, release verification failed) is never moved; fix forward with a new commit and the next version. -If a tag is pushed before CI on its commit is green, `release.yml` fails with "no successful CI run found for commit". Wait, then tag. +If a tag is pushed before CI on its commit is green, `release.yml` fails in the `guard` job ("no successful CI run" or the pull-request-green check). Wait, then re-run the release run; if the commit never had a green pull request run, it cannot be released: fix forward with a new commit and the next version. ### Release dry run -Run this read-only check after `main` was pushed and before the tag is pushed. It runs the lookup of `release.yml` and the guard `.github/scripts/release-guard.sh` on the same inputs and only reads (`gh api` GET, git). It matters because the lookup by head SHA ("direct match") is the normal path since 2026-11-13 and had not run in production before. +Run this after `main` was pushed and before the tag is pushed. It is the workflow `release.yml` itself started by hand: only the read-only `guard` job runs, with the same lookup and the same `release-guard.sh` as the tag run. Nothing is published and no tag is needed; the tag name is derived from the version input. + +```sh +gh workflow run release.yml -f sha= -f version= +timeout 120 gh run list --workflow release.yml --event workflow_dispatch --limit 1 # then: gh run view +``` + +The `guard` job must be green (it prints the `ok:` lines: commit on `origin/main`, green `pull_request` run, CI tree, version shape, `versionCode` up, tag name free, `CHANGELOG.md` section, `versionName` == tag) and `publish` must show as skipped. A red guard names the mismatch; a missing CHANGELOG section or a commit without a green pull request run is refused here, before any tag exists. The dry run matters because the lookup by head SHA ("direct match") is the normal path since 2026-11-13. + +Fallback if dispatching is not possible: the same guard can be run locally, read-only (`gh api` GET, git), in a worktree whose `origin/main` is fetched and contains the SHA: ```sh SHA=; TAG= @@ -195,12 +204,14 @@ RUN_ID=$(timeout 120 gh api "repos/hellwand/nova/actions/workflows/ci.yml/runs?h --jq '.workflow_runs[] | select((.event=="pull_request" or .event=="workflow_dispatch") and .head_repository.full_name=="hellwand/nova") | .id' | head -1) timeout 120 gh api "repos/hellwand/nova/actions/runs/$RUN_ID/artifacts" \ --jq '[.artifacts[] | select(.name=="app-release" and (.expired|not))] | length' # must be >= 1 -# in a worktree whose origin/main is fetched and contains SHA: TAG=$TAG SHA=$SHA RUN_ID=$RUN_ID RUN_HEAD_SHA=$SHA PR_NUM= GITHUB_REPOSITORY=hellwand/nova MAIN_REF=origin/main \ - bash .github/scripts/release-guard.sh # must print the four "ok:" lines + GH_TOKEN=$(gh auth token) bash .github/scripts/release-guard.sh # must end with "ok: versionName ... == tag" ``` -If the dry run passes and the release run still fails, recover with `gh run rerun ` (after `gh workflow run ci.yml --ref ` if the artifact is missing; that run reports as `build (dispatch)`). Tags are never re-pushed. +If the dry run passes and the release run still fails, recover with `gh run rerun ` (after `gh workflow run ci.yml --ref ` if the artifact is missing; that run reports as `build (dispatch)` and supplies the artifact only, the green pull request run on the commit is still required). Tags are never re-pushed. Two cautions once immutable releases are on: + +- Never re-run a release run whose tagged commit predates WP2 (issue #295): a re-run uses the `release.yml` of the tagged commit, and the old file publishes a pre-release before its APK is uploaded, which would lock the tag without the APK. Only runs of tags made after WP2 are re-runnable. +- Before re-running a failed `publish` job, look at what exists for the tag: `gh api repos/hellwand/nova/releases --jq '.[] | select(.tag_name=="") | {id,draft,immutable,assets:[.assets[].name]}'`. If a published release is listed, do not re-run and do not delete it (`gh` can report an error after the server already published, and a deleted immutable release burns the tag name for good). Delete a leftover draft (`gh api -X DELETE repos/hellwand/nova/releases/`) before re-running; a draft is left behind when the job is killed during the upload. ### Release cleanup diff --git a/docs/adr/0010-release-cleanup-keeps-tags.md b/docs/adr/0010-release-cleanup-keeps-tags.md index e2b3d6a7..85af2866 100644 --- a/docs/adr/0010-release-cleanup-keeps-tags.md +++ b/docs/adr/0010-release-cleanup-keeps-tags.md @@ -89,3 +89,11 @@ convention only; performing it is out of band. ## Amendment 2026-11-10 — tags are now mechanically protected "Never delete a tag" relied on discipline. The repository ruleset "Protect release tags" now applies to `refs/tags/v*` and blocks **update** (moving a tag) and **deletion**; creation stays allowed. Consequences: the re-push recovery above (delete/recreate a tag, steps 1–3) now needs the ruleset disabled temporarily — a deliberate break-glass step, as intended. A wrong tag is fixed by a new commit and a new tag (ADR-0015), never by moving it. The release-entry pruning policy in the previous amendment is unaffected (it deletes Release entries, not tags). Retention is also not yet being applied: the `v0.1.40-beta1…beta10` entries are still published; that is a process item, tracked separately. + +## Amendment 2026-11-13 — immutable releases + +Once the repository setting "immutable releases" is on (issue #295, WP2), a published release locks its assets and its tag. The tag of an immutable release cannot be deleted while the release exists, and after the release is deleted its tag name can never be reused. The recovery recipe above (re-push a deleted tag) therefore does not apply to those tags; it was already moot because the tag ruleset blocks deletion with no bypass. The setting applies only to releases published after it is switched on. + +Pruning is unaffected: the retention policy deletes old beta/rc release *entries* and keeps their tags, which stays allowed (`gh release delete --yes`, never `--cleanup-tag`). The release body, title and the pre-release and latest flags stay editable, but nothing in the flow edits or uploads to a release after publication: `release.yml` publishes draft-first in a single `gh release create` (ADR-0014 amendment), so the assets are in place before the release is public. + +A re-run (`gh run rerun`) uses the `release.yml` of the tagged commit. A release run started before WP2 therefore still runs the old file with `softprops/action-gh-release`, which publishes a pre-release first and uploads the assets afterwards. Once immutable releases are on, never re-run a release run whose tagged commit predates WP2: it would publish a beta without its APK and lock the tag. Only runs of tags made after WP2 are re-runnable. diff --git a/docs/adr/0014-single-ci-run-per-pr.md b/docs/adr/0014-single-ci-run-per-pr.md index e9c308ec..8b3edef2 100644 --- a/docs/adr/0014-single-ci-run-per-pr.md +++ b/docs/adr/0014-single-ci-run-per-pr.md @@ -90,3 +90,12 @@ Since PRs land by a fast-forward push of their head commit (ADR-0003 amendment o - **Bots.** Bot heads never carry an `app-release` artifact and are never tagged (ADR-0015 needs a release commit of its own). Unchanged. No functional change in `.github/` is needed. Comments and messages that still describe the squash era are reworded in a later change. + +## Amendment 2026-11-13 (2) — pull-request-green check, guard/publish split, draft-first (issue #295, WP2) + +The squash-era wording in `release.yml` and `release-guard.sh` is reworded; the lookup itself (both paths) is unchanged; path 2 is kept for the dry run and for rebase-merged commits, and re-runs of existing tags use the workflow file at their own commit. Four changes: + +- **Pull-request-green check.** The recovery dispatch (`gh workflow run ci.yml --ref `) makes a commit green after tagging, so the lookup alone would let any ancestor of `main` be released, even one that never had its own pull request run. `release-guard.sh` now also requires, on lookup path 1, a successful `ci.yml` run of event `pull_request` from this repository on exactly the tagged SHA (`gh api .../workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&status=success`, filtered on `head_repository.full_name`). The artifact may still come from a recovery dispatch run. There is no fallback: a commit without such a run is refused, whatever its subject says. Path 2 (PR number in the subject, no run on the tagged commit itself) keeps its check that the PR was merged as exactly this commit and additionally requires the same green `pull_request` run on the PR head, because the lookup also accepts a dispatch run there. +- **Preflight at the tag.** The guard calls `.github/scripts/release-preflight.sh`, the script the pull request job `release-check` also runs: version shape (ADR-0005), `versionCode` above the previous `v*` tag, tag name free or already on this commit, and a non-empty `CHANGELOG.md` section (ADR-0031). A missing section was a warning before and is fatal now, before anything is downloaded. The guard keeps its own check `v == tag`. +- **Guard/publish split and draft-first.** `release.yml` has top-level `permissions: contents: read`. Job `guard` (`contents: read`, `actions: read`) does the lookup and the guard. Job `publish` (`contents: write`, `actions: read` to download the CI artifacts) runs only when the guard succeeded and the event is a tag push; it downloads the APK and `mapping.txt` (the mapping is mandatory now), runs `release-verify-apk.sh` unchanged, and publishes with one `gh release create --verify-tag` instead of `softprops/action-gh-release`. With assets, `gh` creates a draft, uploads every file, then publishes it, and deletes the draft if an upload fails; the pre-release action published first and uploaded afterwards, which immutable releases cannot accept (ADR-0010 note). Every job has `timeout-minutes`. +- **Dry run.** A `workflow_dispatch` trigger with inputs `sha` and `version` runs only the guard job (tag `v`, commit `sha`, no tag needed) and publishes nothing. It replaces the shell dry run in HANDOFF.md. diff --git a/docs/adr/0031-curated-changelog.md b/docs/adr/0031-curated-changelog.md index e51a49da..a8474c15 100644 --- a/docs/adr/0031-curated-changelog.md +++ b/docs/adr/0031-curated-changelog.md @@ -131,3 +131,7 @@ removes its footer reference-links. See [ADR-0010](./0010-release-cleanup-keeps-tags.md) for the tag-keeping + release-prune rationale this amendment is symmetric with. + +## Amendment 2026-11-13 — the section is mandatory (issue #295, WP1/WP2) + +The section for a release is no longer optional. `release-preflight.sh` checks it on the version-bump pull request (`release-check` in `ci.yml`) and again in the release guard, before anything is downloaded; the publish step of `release.yml` fails when the extracted section is empty. This replaces the non-fatal extraction above, and the release body is passed to `gh release create --notes-file ... --generate-notes` instead of `body_path` of `softprops/action-gh-release` (ADR-0014 amendment). The API still prepends the curated body to the generated notes, so the compare link stays below the curated section as before.