diff --git a/.github/workflows/history-validation.yml b/.github/workflows/history-validation.yml index 6c96eb3d8..c153f0da0 100644 --- a/.github/workflows/history-validation.yml +++ b/.github/workflows/history-validation.yml @@ -40,6 +40,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} REF_NAME: ${{ github.ref_name }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} DISPATCH_BASE_REF: ${{ inputs.base_ref }} run: | set -euo pipefail @@ -50,7 +51,11 @@ jobs: echo "HISTORY_BASE_REF=refs/remotes/upstream/$upstream_branch" >> "$GITHUB_ENV" exit 0 elif [[ "$EVENT_NAME" == pull_request ]]; then - base_ref="$PR_BASE_REF" + if [[ " $PR_LABELS " == *' actualization '* ]]; then + base_ref=upstream/main + else + base_ref="$PR_BASE_REF" + fi elif [[ -n "$DISPATCH_BASE_REF" ]]; then base_ref="$DISPATCH_BASE_REF" else diff --git a/.github/workflows/prepare-actualization.yml b/.github/workflows/prepare-actualization.yml index 5f8e29667..649fa6427 100644 --- a/.github/workflows/prepare-actualization.yml +++ b/.github/workflows/prepare-actualization.yml @@ -53,6 +53,7 @@ jobs: - name: Open draft actualization PR env: + BASE_REF: ${{ steps.refs.outputs.base_ref }} GH_TOKEN: ${{ steps.app_token.outputs.token }} HEAD_BRANCH: ${{ inputs.head_branch }} UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} @@ -63,8 +64,8 @@ jobs: gh pr create \ --repo "$GITHUB_REPOSITORY" \ --draft \ - --base upstream/main \ + --base "$BASE_REF" \ --head "$HEAD_BRANCH" \ --title "$TITLE" \ --label actualization \ - --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. Promote with /promote after the checks pass.\n\n' + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. Promote with /promote after the checks pass.\n\n\n' diff --git a/.github/workflows/promote-history.yml b/.github/workflows/promote-history.yml index 03216d759..c820ca668 100644 --- a/.github/workflows/promote-history.yml +++ b/.github/workflows/promote-history.yml @@ -8,6 +8,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: fork-history-mutation + cancel-in-progress: false + jobs: promote: if: >- @@ -61,9 +65,11 @@ jobs: exit 1 fi base_ref="$(jq -r .base.ref <<<"$pr_json")" + upstream_sha="" + validation_ref="$base_ref" if [[ "$kind" == actualization ]]; then if [[ "$base_ref" != upstream/main ]]; then - echo 'Actualization PR must target upstream/main.' >&2 + echo 'Actualization PR must target upstream/main until promotion.' >&2 exit 1 fi expected_main_sha="$(jq -r '.body // ""' <<<"$pr_json" | grep -oE '' | sed -E 's/.*: ([0-9a-f]{40}) -->/\1/' | head -n1 || true)" @@ -71,6 +77,16 @@ jobs: echo 'Actualization PR is missing its main history marker.' >&2 exit 1 fi + upstream_sha="$(jq -r '.body // ""' <<<"$pr_json" | grep -oE '' | sed -E 's/.*: ([0-9a-f]{40}) -->/\1/' | head -n1 || true)" + if [[ ! "$upstream_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Actualization PR is missing its upstream history marker.' >&2 + exit 1 + fi + if [[ "$(jq -r .base.sha <<<"$pr_json")" != "$upstream_sha" ]]; then + echo 'Actualization PR base does not match its upstream history marker.' >&2 + exit 1 + fi + validation_ref=upstream/main else if [[ "$base_ref" != main ]]; then echo 'Release PR must target main.' >&2 @@ -84,7 +100,8 @@ jobs: echo "base_sha=$(jq -r .base.sha <<<"$pr_json")" echo "base_ref=$base_ref" echo "expected_main_sha=$expected_main_sha" - echo "validation_ref=$base_ref" + echo "upstream_sha=$upstream_sha" + echo "validation_ref=$validation_ref" echo "head_sha=$(jq -r .head.sha <<<"$pr_json")" echo "head_ref=$(jq -r .head.ref <<<"$pr_json")" } >> "$GITHUB_OUTPUT" @@ -99,7 +116,12 @@ jobs: - name: Fetch promotion base env: BASE_REF: ${{ steps.pr.outputs.base_ref }} - run: git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + KIND: ${{ steps.pr.outputs.kind }} + run: | + git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + if [[ "$KIND" == actualization ]]; then + git fetch --no-tags origin upstream/main:refs/remotes/origin/upstream/main + fi - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -114,7 +136,7 @@ jobs: env: EXPECTED_MAIN_SHA: ${{ steps.pr.outputs.expected_main_sha }} KIND: ${{ steps.pr.outputs.kind }} - BASE_SHA: ${{ steps.pr.outputs.base_sha }} + UPSTREAM_SHA: ${{ steps.pr.outputs.upstream_sha }} run: | set -euo pipefail git fetch origin main @@ -125,8 +147,8 @@ jobs: fi if [[ "$KIND" == actualization ]]; then current_upstream="$(git rev-parse refs/remotes/origin/upstream/main)" - if [[ "$current_upstream" != "$BASE_SHA" ]]; then - echo "upstream/main moved from $BASE_SHA to $current_upstream; refresh the PR before promoting" >&2 + if [[ "$current_upstream" != "$UPSTREAM_SHA" ]]; then + echo "upstream/main moved from $UPSTREAM_SHA to $current_upstream; refresh the PR before promoting" >&2 exit 1 fi fi @@ -158,6 +180,25 @@ jobs: VALIDATION_REF: ${{ steps.pr.outputs.validation_ref }} run: node scripts/validate-fork-history.ts --ref HEAD --upstream-ref "refs/remotes/origin/$VALIDATION_REF" + - id: actualization_base + name: Resolve actualization base + if: steps.pr.outputs.kind == 'actualization' + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + UPSTREAM_SHA: ${{ steps.pr.outputs.upstream_sha }} + run: | + set -euo pipefail + first_fork_sha="$(git rev-list --reverse "$UPSTREAM_SHA..$HEAD_SHA" | head -n 1)" + if [[ -z "$first_fork_sha" ]]; then + echo 'Actualization has no fork commits to rebase merge.' >&2 + exit 1 + fi + if [[ "$(git rev-parse "$first_fork_sha^")" != "$UPSTREAM_SHA" ]]; then + echo 'The first fork commit is not directly based on upstream/main.' >&2 + exit 1 + fi + echo "sha=$first_fork_sha" >> "$GITHUB_OUTPUT" + - name: Verify validated main for release promotion if: steps.pr.outputs.kind == 'release' env: @@ -175,46 +216,112 @@ jobs: exit 1 fi - - id: push - name: Promote reviewed history + - id: release_push + name: Promote release history + if: steps.pr.outputs.kind == 'release' env: BASE_SHA: ${{ steps.pr.outputs.base_sha }} EXPECTED_MAIN_SHA: ${{ steps.pr.outputs.expected_main_sha }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - KIND: ${{ steps.pr.outputs.kind }} PR_NUMBER: ${{ steps.pr.outputs.number }} GIT_COMMITTER_NAME: ${{ steps.app_token.outputs.app-slug }}[bot] GIT_COMMITTER_EMAIL: noreply@github.com run: | set -euo pipefail - if [[ "$KIND" == actualization ]]; then - promoted_sha="$HEAD_SHA" - else - parent="$(git rev-parse "$BASE_SHA^")" - tree="$(git rev-parse "$HEAD_SHA^{tree}")" - export GIT_AUTHOR_NAME="$(git show -s --format=%an "$HEAD_SHA")" - export GIT_AUTHOR_EMAIL="$(git show -s --format=%ae "$HEAD_SHA")" - export GIT_AUTHOR_DATE="$(git show -s --format=%aI "$HEAD_SHA")" - message="$(git show -s --format=%B "$HEAD_SHA")" - message+=$'\n\nRelease-PR: #'"$PR_NUMBER"$'\n' - promoted_sha="$(printf '%s' "$message" | git commit-tree "$tree" -p "$parent")" - fi + parent="$(git rev-parse "$BASE_SHA^")" + tree="$(git rev-parse "$HEAD_SHA^{tree}")" + GIT_AUTHOR_NAME="$(git show -s --format=%an "$HEAD_SHA")" + GIT_AUTHOR_EMAIL="$(git show -s --format=%ae "$HEAD_SHA")" + GIT_AUTHOR_DATE="$(git show -s --format=%aI "$HEAD_SHA")" + export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE + message="$(git show -s --format=%B "$HEAD_SHA")" + message+=$'\n\nRelease-PR: #'"$PR_NUMBER"$'\n' + promoted_sha="$(printf '%s' "$message" | git commit-tree "$tree" -p "$parent")" git push --force-with-lease="refs/heads/main:$EXPECTED_MAIN_SHA" origin "$promoted_sha:refs/heads/main" echo "promoted_sha=$promoted_sha" >> "$GITHUB_OUTPUT" - - name: Finalize promotion PR + - id: actualization_merge + name: Rebase merge actualization on staging base + if: steps.pr.outputs.kind == 'actualization' env: GH_TOKEN: ${{ steps.app_token.outputs.token }} + EXPECTED_MAIN_SHA: ${{ steps.pr.outputs.expected_main_sha }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} PR_NUMBER: ${{ steps.pr.outputs.number }} - KIND: ${{ steps.pr.outputs.kind }} - PROMOTED_SHA: ${{ steps.push.outputs.promoted_sha }} + PROMOTION_BASE_SHA: ${{ steps.actualization_base.outputs.sha }} + run: | + set -euo pipefail + staging_ref="actualization/base-$PR_NUMBER" + git push --force origin "$PROMOTION_BASE_SHA:refs/heads/$staging_ref" + gh pr ready "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \ + -f base="$staging_ref" >/dev/null + response="$( + gh api --method PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/merge" \ + -f merge_method=rebase \ + -f sha="$HEAD_SHA" + )" + if ! jq -e '.merged == true' <<<"$response" >/dev/null; then + jq . <<<"$response" >&2 + exit 1 + fi + promoted_sha="$(jq -r .sha <<<"$response")" + git fetch --no-tags origin "$staging_ref:refs/remotes/origin/$staging_ref" + staged_sha="$(git rev-parse "refs/remotes/origin/$staging_ref")" + if [[ "$staged_sha" != "$promoted_sha" ]]; then + echo "staging branch points at $staged_sha instead of merge result $promoted_sha" >&2 + exit 1 + fi + git push --force-with-lease="refs/heads/main:$EXPECTED_MAIN_SHA" origin "$staged_sha:refs/heads/main" + { + echo "promoted_sha=$promoted_sha" + echo "staging_ref=$staging_ref" + } >> "$GITHUB_OUTPUT" + + - id: actualization_validation + name: Verify promoted actualization history + if: steps.pr.outputs.kind == 'actualization' + run: | + set -euo pipefail + git fetch --force --no-tags origin \ + main:refs/remotes/origin/main \ + upstream/main:refs/remotes/origin/upstream/main + node scripts/validate-fork-history.ts \ + --ref refs/remotes/origin/main \ + --upstream-ref refs/remotes/origin/upstream/main + + - name: Finalize release promotion PR + if: steps.pr.outputs.kind == 'release' + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + PROMOTED_SHA: ${{ steps.release_push.outputs.promoted_sha }} HEAD_REF: ${{ steps.pr.outputs.head_ref }} - BASE_REF: ${{ steps.pr.outputs.base_ref }} run: | set -euo pipefail - gh pr comment "$PR_NUMBER" --body "Promoted as $PROMOTED_SHA ($KIND)." + gh pr comment "$PR_NUMBER" --body "Promoted as $PROMOTED_SHA (release)." pr_state="$(gh pr view "$PR_NUMBER" --json state --jq .state)" if [[ "$pr_state" == OPEN ]]; then gh api --method PATCH "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" -f state=closed >/dev/null fi gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$HEAD_REF" >/dev/null || true + + - name: Finalize actualization PR + if: always() && steps.actualization_merge.outcome == 'success' + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + PROMOTED_SHA: ${{ steps.actualization_merge.outputs.promoted_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + STAGING_REF: ${{ steps.actualization_merge.outputs.staging_ref }} + VALIDATION_OUTCOME: ${{ steps.actualization_validation.outcome }} + run: | + set -euo pipefail + cleanup_failed=false + gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$HEAD_REF" >/dev/null || cleanup_failed=true + gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$STAGING_REF" >/dev/null || cleanup_failed=true + if [[ "$cleanup_failed" == true ]]; then + echo 'Failed to remove one or more actualization branches.' >&2 + exit 1 + fi + gh pr comment "$PR_NUMBER" --body "Promoted as $PROMOTED_SHA (actualization, rebase merge; post-merge validation: $VALIDATION_OUTCOME)." diff --git a/.github/workflows/sync-upstream-main.yml b/.github/workflows/sync-upstream-main.yml index ce1c5f177..ccbef0665 100644 --- a/.github/workflows/sync-upstream-main.yml +++ b/.github/workflows/sync-upstream-main.yml @@ -14,8 +14,8 @@ permissions: contents: write concurrency: - group: sync-upstream-main - cancel-in-progress: true + group: fork-history-mutation + cancel-in-progress: false jobs: sync: @@ -40,7 +40,7 @@ jobs: GH_TOKEN: ${{ steps.app_token.outputs.token }} run: | set -euo pipefail - open_prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --base upstream/main --state open --label actualization --json number --jq length)" + open_prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --label actualization --json number --jq length)" echo "has_open_actualization=$([[ "$open_prs" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - name: Checkout repository @@ -76,9 +76,11 @@ jobs: fi current_sha="$(git rev-parse refs/remotes/origin/upstream/main 2>/dev/null || true)" main_sha="$(git rev-parse refs/remotes/origin/main)" - echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" - echo "current_sha=$current_sha" >> "$GITHUB_OUTPUT" - echo "main_sha=$main_sha" >> "$GITHUB_OUTPUT" + { + echo "upstream_sha=$upstream_sha" + echo "current_sha=$current_sha" + echo "main_sha=$main_sha" + } >> "$GITHUB_OUTPUT" - name: Update upstream main mirror if: >- @@ -105,7 +107,7 @@ jobs: gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/heads/upstream/main" \ -f sha="$UPSTREAM_SHA" -F force=true >/dev/null - - name: Prepare conflict PR head + - name: Prepare actualization PR head if: steps.upstream.outputs.upstream_sha != steps.upstream.outputs.current_sha run: git push --force origin "refs/remotes/origin/main:refs/heads/actualization/incoming" @@ -121,7 +123,7 @@ jobs: if [[ -n "$existing" ]]; then gh pr edit "$existing" \ --title 'actualize fork on current upstream' \ - --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts with conflicts; rebuild its head manually before /promote.\n\n' + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts from the current main snapshot; rebuild its head before /promote.\n\n\n' else gh pr create \ --repo "$GITHUB_REPOSITORY" \ @@ -130,5 +132,5 @@ jobs: --head actualization/incoming \ --title 'actualize fork on current upstream' \ --label actualization \ - --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts with conflicts; rebuild its head manually before /promote.\n\n' + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts from the current main snapshot; rebuild its head before /promote.\n\n\n' fi diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 443560ec2..bf79b99d4 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -23,7 +23,7 @@ one mutable release-state commit `upstream/main` is a protected mirror. The sync workflow imports upstream objects, updates the mirror, snapshots current `main` into `actualization/incoming`, and opens a Draft PR against `upstream/main`. The mirror is left unchanged while that PR is open. -That PR is intentionally stale and normally conflicted: its first head is the current `main`, not a rebased result. Manual work rebuilds the head on the new mirror. Promotion then force-replaces `main` with the reviewed head. +The PR intentionally starts conflicted so GitHub can run checks against the mirrored upstream base after its head is rebuilt. Manual work rebuilds the fork commits on that base. After checks pass, promotion pushes the first fork commit to a temporary base, retargets the PR there, and asks GitHub to rebase-merge the remaining reviewed commits. Only that complete staged result is force-pushed to `main`, guarded by a lease. The default branch never exposes incomplete history. GitHub assigns new commit IDs during the merge, so the resulting `main` tip differs from the reviewed PR head. The release-state commit contains package versions and any final generated lock/hash state. It is replaced during release preparation. Dependency declarations stay with the feature or fix that needs them. Intermediate lockfiles and Nix hashes are consolidated before release. @@ -66,11 +66,11 @@ Actualization is a local rebuild followed by a Draft PR promoted into `main`. 5. Remove the old release-state commit. Consolidate generated lockfile and Nix hash changes, then add one release-state commit with the last published stable version. 6. Run `range-diff`, the full fork delta review, focused checks for every conflict area, `history/validated`, and the Nix runtime build. 7. Push the temporary branch. -8. The sync workflow opens a Draft `actualization/incoming -> upstream/main` PR with intentional conflicts because its head starts as current `main`. +8. The sync workflow opens a Draft `actualization/incoming -> upstream/main` PR whose head starts as the current `main` snapshot. 9. Rebuild that PR head manually on the current `upstream/main`, preserving the seven strata, then resolve the conflicts and push the head. -10. After checks pass, comment `/promote`. Promotion validates the candidate against `upstream/main`, force-updates `main`, closes the PR, and deletes the temporary head. +10. After checks pass, comment `/promote`. Promotion validates the candidate against `upstream/main`, pushes its first fork commit to a temporary base, and rebase-merges the PR there. It then force-pushes the complete merge result to `main` with a lease. GitHub records the PR as merged, and the workflow deletes both temporary branches. -If `main` or `upstream/main` moves before promotion, refresh the actualization. Promotion uses a lease and refuses a stale base. +If `main` or `upstream/main` moves before promotion, refresh the actualization. Sync and promotion share one concurrency group, so neither can change refs during the other's final checks and cleanup. The final `main` push uses a lease. A failed promotion leaves its PR and staging branch visible for manual recovery. ## Stable release @@ -111,13 +111,13 @@ Pushes to `canary/*` run CI and history validation against the matching upstream ## Promotion rules - `/promote` is accepted only from repository members, collaborators, or the owner. -- `actualization` replaces `main` with the exact reviewed PR head. +- `actualization` rebase-merges on a temporary staging base, then moves `main` to the complete staged result with a lease. - `release` replaces only the old release-state commit with the reviewed release tree. - A stale base, failed check, non-linear history, unexpected release-state file, or mismatched package version blocks promotion. - The GitHub App bypasses the `main` non-fast-forward rule. Human stable approval remains a separate Environment gate. ## Completion -An actualization is complete when its PR is closed by promotion, `main` points at the reviewed SHA, `history/validated` passes, and the temporary branch is gone. +An actualization is complete when GitHub records its PR as merged, `main` points at the rebase-merge result, `history/validated` passes, and both temporary branches are gone. A release is complete when the stable Environment job publishes the tag and assets, the release body contains the upstream and manual sections, and the next Draft release PR reflects the new stable tag.