-
Notifications
You must be signed in to change notification settings - Fork 0
fix(ci): rebase merge actualizations #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,10 @@ permissions: | |
| contents: write | ||
| pull-requests: write | ||
|
|
||
| concurrency: | ||
| group: fork-history-mutation | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| promote: | ||
| if: >- | ||
|
|
@@ -61,16 +65,28 @@ 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 '<!-- actualization-main-sha: [0-9a-f]{40} -->' | sed -E 's/.*: ([0-9a-f]{40}) -->/\1/' | head -n1 || true)" | ||
| if [[ ! "$expected_main_sha" =~ ^[0-9a-f]{40}$ ]]; then | ||
| echo 'Actualization PR is missing its main history marker.' >&2 | ||
| exit 1 | ||
| fi | ||
| upstream_sha="$(jq -r '.body // ""' <<<"$pr_json" | grep -oE '<!-- actualization-upstream-sha: [0-9a-f]{40} -->' | 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" | ||
|
Comment on lines
+259
to
+262
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If another feature merge or manual update reaches AGENTS.md reference: AGENTS.md:L1-L3 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in cbaa718. Promotion waits until both the PR base SHA and remote main equal the synthetic safe base, then rechecks remote main immediately before calling the merge endpoint. Any mismatch enters the workflow-safe rollback path. |
||
| )" | ||
|
Comment on lines
+259
to
+263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If GitHub completes the rebase merge but the connection drops before AGENTS.md reference: AGENTS.md:L1-L3 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 757a1af. Once a merge request begins, the trap queries the PR before cleanup. A confirmed merge preserves staging; an unavailable PR state is treated as uncertain and also preserves staging and PR state. Destructive rollback runs only after GitHub confirms the PR is still unmerged. |
||
| 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)." | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If posting the promotion comment fails transiently, AGENTS.md reference: AGENTS.md:L1-L3 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c86f485. Finalization now attempts both branch deletions before posting the comment, reports a failure if either cleanup call fails, and cannot leave branches behind because comment creation failed. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this concurrency group differs from
sync-upstream-main, a sync can start as soon as the merge API closes the actualization PR and the sync gate no longer sees an open PR. It can then advanceupstream/mainbefore lines 314-319 validate against that live ref, making a valid promotion fail, or replaceactualization/incomingbefore line 347 deletes it, causing the promotion run to delete the next actualization head. Use a shared concurrency group or validate/delete immutable recorded SHAs with leases.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 83621c5. Sync and promotion now share the fork-history-mutation concurrency group, and both use cancel-in-progress: false. A sync cannot start until promotion finishes validation and deletes the old head, and it cannot cancel an active promotion.