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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/history-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/prepare-actualization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->'
--body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. Promote with /promote after the checks pass.\n\n<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->\n<!-- actualization-upstream-sha: '"${UPSTREAM_SHA}"$' -->'
159 changes: 133 additions & 26 deletions .github/workflows/promote-history.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ permissions:
contents: write
pull-requests: write

concurrency:
group: fork-history-mutation
cancel-in-progress: false
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize promotion with upstream synchronization

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 advance upstream/main before lines 314-319 validate against that live ref, making a valid promotion fail, or replace actualization/incoming before 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

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.


jobs:
promote:
if: >-
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard the base SHA through the rebase merge

If another feature merge or manual update reaches main after the leased push on line 335 but before this request, promotion can rebase onto that unexpected commit: the REST endpoint's sha field guards only the pull request head, not the base (GitHub: Merge a pull request). The shared workflow concurrency group does not serialize ordinary merges, and the subsequent validator accepts additional linear fork commits, so this can complete with history other than the reviewed stack; pin or recheck main == $PROMOTION_BASE_SHA at the merge boundary.

AGENTS.md reference: AGENTS.md:L1-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile merge status before deleting staging

If GitHub completes the rebase merge but the connection drops before gh api returns its response, set -e invokes the trap while merged_to_staging is still false. The rollback then deletes the staging branch containing the completed rebased history, while the PR is already merged and cannot be retried because the initial draft guard rejects it. Before taking the destructive rollback path after a merge attempt, query the PR's merge state or otherwise preserve staging when the outcome is uncertain.

AGENTS.md reference: AGENTS.md:L1-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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)."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run branch cleanup before the fallible comment

If posting the promotion comment fails transiently, set -e exits this finalization step before either DELETE runs, leaving both temporary branches behind. Because the PR has already been merged, rerunning /promote is rejected by the draft guard, so the workflow cannot repair the cleanup even though the maintenance contract requires both branches to be removed; perform cleanup before the comment or guarantee it with a trap.

AGENTS.md reference: AGENTS.md:L1-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

20 changes: 11 additions & 9 deletions .github/workflows/sync-upstream-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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: >-
Expand All @@ -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"

Expand All @@ -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<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->'
--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<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->\n<!-- actualization-upstream-sha: '"${UPSTREAM_SHA}"$' -->'
else
gh pr create \
--repo "$GITHUB_REPOSITORY" \
Expand All @@ -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<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->'
--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<!-- actualization-main-sha: '"${MAIN_SHA}"$' -->\n<!-- actualization-upstream-sha: '"${UPSTREAM_SHA}"$' -->'
fi
Loading
Loading