diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 6c75f3e..9283298 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -39,9 +39,9 @@ jobs: # Defensive guard (the trigger already implies it): only a real, published release. if: github.event.release.prerelease == false && github.event.release.draft == false permissions: - contents: write # commit the regenerated feed back to main (best-effort) + contents: write # push the regenerated feed to its own branch (never to the line itself) + pull-requests: write # open the feed PR and label it (6j6v.exga) id-token: write # OIDC: assume nxs-prod-release (copy + manifest write) - actions: write # dispatch publish-content.yml after the feed commit (85y.19; re-homed by 6j6v.0a6p) env: # Tag/release data flows ONLY through env, never `${{ }}`-interpolated into a shell # (security hygiene, spec §2). version-check.yml enforces tag == v. @@ -195,50 +195,63 @@ jobs: done echo "All 4 tarballs + .sha256 + .minisig present in s3://.../download/stable/${VERSION}/" - # Best-effort: commit the regenerated feed back to main. `continue-on-error` is scoped to - # THIS step only — a branch-protection reject of the feed push must NOT fail an already- - # done promotion. The push outcome drives `dispatch`: only a landed feed commit should - # trigger the site refresh. - - name: Commit + push regenerated feed (best-effort) - id: feed - continue-on-error: true + # The regenerated feed reaches its line through a PULL REQUEST, never a push (6j6v.exga). main + # carries ruleset 23939298 ("every change through a pull request", no bypass — owner rule), so + # the old direct push was rejected, and it degraded to a ::warning:: while the promotion + # itself stayed green: main's feed silently lacked the stable entry and the public changelog + # never refreshed. Now the feed commit goes to its own branch and a PR carries it. + # + # WHY THE OWNER RE-OPENS THE PR. A PR opened with GITHUB_TOKEN starts no workflows (GitHub's + # loop prevention), so its six required checks would never report and it could never merge. + # Closing and re-opening it as a human fires `pull_request: reopened`, and every required + # check runs. The owner chose that over a GitHub App token (6j6v.exga): a stable promotion is + # already a deliberate human gesture, and it costs no new secret. The job summary spells it + # out. + # + # NOT best-effort any more: a feed that cannot reach a PR fails the job. The promotion above + # has already landed by then, so red here means "stable is live, main's feed is not", which + # is exactly what somebody must see. + # + # publish-content is NOT dispatched from here any more. It must run on the MERGED feed, and + # the owner's merge is a human push to the line that touches release-notes.json, which fires + # publish-content.yml's own push trigger (main only; a backport's entry reaches main through + # its forward-port PR, as before). + - name: Open the feed PR env: VERSION: ${{ steps.v.outputs.version }} LINE_REF: ${{ steps.line.outputs.ref }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail if git diff --quiet -- release-notes.json; then - echo "feed unchanged — nothing to commit" - echo "dispatch=false" >> "$GITHUB_OUTPUT"; exit 0 + echo "feed unchanged — nothing to commit"; exit 0 fi + branch="release-notes/promote-v${VERSION}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" git add release-notes.json git commit -m "chore(release-notes): promote ${VERSION} to stable" - # Push back to the LINE it came from (main for a normal release; release/x.Y for a - # backport). A backport's stable entry reaches main + the public site via the - # forward-port step in the backport runbook (docs/specs/maintenance-and-backports.md). - if git push origin "HEAD:${LINE_REF}"; then - echo "dispatch=true" >> "$GITHUB_OUTPUT" - else - echo "::warning::feed commit rejected (branch protection?) — promotion already succeeded, skipping content refresh" - echo "dispatch=false" >> "$GITHUB_OUTPUT" - fi + # The branch is this job's own: a re-run regenerates the same feed and may overwrite it. + git push --force origin "HEAD:refs/heads/${branch}" - # Refresh the public changelog from the just-pushed feed (spec §8, 85y.19; re-homed to the - # content provider by 6j6v.0a6p). The dispatch is EXPLICIT because a GITHUB_TOKEN push never - # triggers `on: push` workflows (Actions loop prevention) — publish-content.yml's push trigger - # only catches human commits. NOT best-effort: unlike the feed push, a failed dispatch means - # the public changelog silently never refreshes, so let it fail the job and surface (the - # promotion itself already landed). - # - # ONLY for the main line. The public landing builds from main's feed; a backport commits its - # feed entry to release/x.Y, which is NOT yet on main, so dispatching here would refresh the - # content to the SAME (backport-less) state — a misleading, wasted run. A backport's entry - # reaches main + the landing via the forward-port PR (backport runbook §4.6), whose merge fires - # publish-content.yml on main naturally. - - name: Publish content (refresh public changelog) - if: steps.feed.outputs.dispatch == 'true' && steps.line.outputs.ref == 'main' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run publish-content.yml --ref main -f env=prod + pr="$(gh pr list --head "$branch" --base "$LINE_REF" --state open --json url --jq '.[0].url')" + if [ -z "$pr" ]; then + pr="$(gh pr create --base "$LINE_REF" --head "$branch" --label skip-changelog \ + --title "chore(release-notes): promote ${VERSION} to stable" \ + --body "The stable entry for ${VERSION}, regenerated by promote.yml after the promotion itself succeeded. The checks do not start on their own for a PR opened by a workflow: close and re-open this PR, wait for the required checks, then merge. The merge refreshes the public changelog through publish-content.yml.")" + fi + number="${pr##*/}" + echo "feed PR: $pr" + { + echo "## Stable ${VERSION} is live. Its feed entry waits in a PR" + echo + echo "Merge $pr to put the stable entry into \`${LINE_REF}\` and refresh the public changelog." + echo "Its checks do not start by themselves (the PR was opened by a workflow). Start them by closing and re-opening it:" + echo + echo '```' + echo "gh pr close ${number} -R ${GITHUB_REPOSITORY} && gh pr reopen ${number} -R ${GITHUB_REPOSITORY}" + echo "gh pr checks ${number} -R ${GITHUB_REPOSITORY} --watch" + echo "gh pr merge ${number} -R ${GITHUB_REPOSITORY} --squash --delete-branch" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/NEXUS_MEMORY.md b/NEXUS_MEMORY.md index 721b197..f0d7f95 100644 --- a/NEXUS_MEMORY.md +++ b/NEXUS_MEMORY.md @@ -1972,7 +1972,10 @@ for the commit, red release, never a fallback test run. Merging to main produces PROMOTION TO STABLE IS A MANUAL OWNER STEP: open the GitHub release, deselect "Set as pre-release", set "Set as latest". That fires `promote.yml` (`release: types: [released]`), which SERVER-SIDE -COPIES the same bytes and signatures to stable — nothing is rebuilt or re-signed. +COPIES the same bytes and signatures to stable — nothing is rebuilt or re-signed. Since 6j6v.exga +it then opens a FEED PR (branch release-notes/promote-v): main's ruleset allows no direct push. +A workflow-opened PR starts no checks, so CLOSE AND RE-OPEN it, wait for the checks, MERGE it. Only +that merge puts the stable entry on main and refreshes the public changelog (publish-content.yml). AND IF PROMOTE EVER COMPLAINS "no beta entry for to promote": do NOT flip the GitHub release first. Add the entry, re-run `version set`, then fire the promotion. That failure is now structurally diff --git a/docs/specs/maintenance-and-backports.md b/docs/specs/maintenance-and-backports.md index 1787491..a02e7f1 100644 --- a/docs/specs/maintenance-and-backports.md +++ b/docs/specs/maintenance-and-backports.md @@ -85,8 +85,9 @@ For each active line `x.Y` (current first, then the previous ones): aggregates within the line (`prev-stable` returns the line predecessor `vx.Y.z`, not a higher-line stable — proven by the `backport_promotion_is_line_scoped_across_parallel_lines` test), and copies the same bytes beta→stable (build-once-promote, per line). -6. **Forward-port the line's feed entry to `main`.** `promote.yml` commits the regenerated feed - back to the **line** branch. To surface the backport in the canonical feed + the public +6. **Forward-port the line's feed entry to `main`.** `promote.yml` opens a PR carrying the + regenerated feed against the **line** branch (release-management.md §5.5 step 5: close and + re-open it so its checks run, then merge). To surface the backport in the canonical feed + the public changelog (which build from `main`'s `release-notes.json`), open a small PR cherry-picking that `release-notes.json` change onto `main`. Merging it triggers `site.yml` and refreshes the public site. (The canonical feed on `main` is the **union** of all lines' entries; backport diff --git a/docs/specs/release-management.md b/docs/specs/release-management.md index f11a6d5..83fdb5b 100644 --- a/docs/specs/release-management.md +++ b/docs/specs/release-management.md @@ -526,9 +526,16 @@ Flow (identical to the reference, only n platforms instead of 2-into-1): condenses multiple beta refinements of one feature into a single stable line, and phrases for a stable audience before publishing. 4. Rewrite `manifests/stable.json`, promote the feed + publish to S3. -5. Commit the regenerated feed **best-effort** back to `main` (no `[skip ci]` — the - push triggers exactly the site deploy that updates the changelog; loop-free, because the - release runs only on tags). A branch-protection reject must not fail the promotion. +5. Carry the regenerated feed to its line **through a pull request** (6j6v.exga): the commit goes + to its own branch `release-notes/promote-v`, and `promote.yml` opens a PR against the line + (`main`, or `release/x.Y` for a backport), labelled `skip-changelog`. `main` carries a ruleset + with no bypass ("every change through a pull request"), so the old direct push was rejected and, + being best-effort, left main's feed silently without the stable entry. Failing to open the PR now + fails the job; the promotion itself has landed by then. **Owner step:** a PR opened by a workflow + starts no workflows, so its required checks run only after the owner closes and re-opens it; then + the owner merges. That merge is a human push touching `release-notes.json`, and it fires + `publish-content.yml`, which refreshes the public changelog. The job summary prints the three + commands. ### 5.6 Container image for `nxf-relay` — **specified, deliberately not built yet** @@ -720,7 +727,7 @@ prerendered, EN + `/de`, no router — the reasons documented there hold unchang | `changelog-check.yml` | PR (+`labeled`/`unlabeled`) | fragment requirement, waived by itself when nothing the PR touches ships, opt-out `skip-changelog`; plus the explicit `facade:` verdict when the diff touches a consumed surface (§4.2, `6j6v.gmjd`) | | `facade-semver.yml` | PR, main, `release/.`, tags `v*` | `cargo xtask facade semver-check` — public-API SemVer gate over all three consumed surfaces, `nexus-flow-facade`/`nexus-chat`/`nexus-memory` (patch never breaks); fail-closed on a patch bump, report-only on a feature PR / minor; runs per line (§4.3) | | `release.yml` | tag `v*` (prod) / dispatch (staging) | **`ci-green` gate** → matrix build, sign, GitHub pre-release, S3 publish, beta manifest, GHCR image. The gate DEMANDS a green `ci.yml` run for the tagged commit (`push`/`workflow_dispatch` only — a `pull_request` run skips the release profile) and never tests the tree itself; no green run ⇒ red release. It replaced a `test` job that re-ran `cargo test --all` and `cargo test --all --release` on a commit CI had just tested — 21m28 of a 31m52 release on v0.51.0 (6j6v.31rs). Script: `.github/scripts/require-green-ci.sh`, hermetically tested by `tests/require-green-ci.test.sh`. | -| `promote.yml` | `release: released` | beta→stable: S3 copy, stable manifest, notes aggregate, feed commit, image retag; **line-aware** — promotes from `main` or, for a backport, the release's `release/x.Y` line | +| `promote.yml` | `release: released` | beta→stable: S3 copy, stable manifest, notes aggregate, feed PR (owner re-opens + merges), image retag; **line-aware** — promotes from `main` or, for a backport, the release's `release/x.Y` line | **SIX ROWS USED TO STAND HERE AND THE FILES ARE GONE.** `staging-gate.yml`, `infra-staging.yml`, `infra-staging-release.yml`, `infra-prod.yml` and `infra-ci.yml` were deleted with the infra @@ -762,7 +769,8 @@ This table was never exhaustive and is less so now; `.github/workflows/` is the 3. **Dogfood on beta**: `nxs self-update --channel beta` (we use nexus-flow ourselves — the engine eats its own tail, vision §Phases). 4. **Promote**: open the GitHub release, deselect "Set as pre-release", set "Set as latest" - → `promote.yml` copies the same bytes to stable. + → `promote.yml` copies the same bytes to stable and opens the feed PR. **Then** close and re-open + that PR (its checks do not start otherwise), wait for them, and merge it (§5.5 step 5). 5. **Broken?** `enabled:false` into the affected channel manifest (kill switch, immediate), fix forward as ``; stable simply skips the broken version.