Skip to content

Commit 5f1b429

Browse files
authored
fix: ignore squash release ancestry drift (#795)
1 parent 997d41c commit 5f1b429

6 files changed

Lines changed: 194 additions & 31 deletions

File tree

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@
3434

3535
## Hotfix follow-up (required when source branch is `hotfix/*`)
3636

37-
- [ ] After merge, open a `chore/sync-main-to-staging` PR to bring `main` back into `staging` and prevent ancestry drift on the next milestone release.
37+
- [ ] After merge, open a `chore/sync-main-to-staging` PR that applies the main-only hotfix content to `staging`, then let CI redeploy staging.

.github/workflows/detect-staging-drift.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jobs:
1111
permissions:
1212
contents: read
1313
issues: write
14+
pull-requests: read
1415
steps:
1516
- uses: actions/checkout@v4
1617
with:
@@ -27,13 +28,26 @@ jobs:
2728
git log --oneline origin/staging..origin/main
2829
fi
2930
30-
- name: Open drift issue if needed
31+
- name: Classify drift source
32+
id: drift_policy
3133
if: steps.drift.outputs.count != '0'
34+
env:
35+
DRIFT_COUNT: ${{ steps.drift.outputs.count }}
36+
GITHUB_TOKEN: ${{ github.token }}
37+
GITHUB_REPOSITORY: ${{ github.repository }}
38+
GITHUB_SHA: ${{ github.sha }}
39+
run: node scripts/staging-drift-policy.mjs
40+
41+
- name: Open drift issue if needed
42+
if: steps.drift.outputs.count != '0' && steps.drift_policy.outputs.open_issue == 'true'
3243
uses: actions/github-script@v7
44+
env:
45+
DRIFT_REASON: ${{ steps.drift_policy.outputs.reason }}
3346
with:
3447
script: |
3548
const count = '${{ steps.drift.outputs.count }}';
3649
const sha = context.sha.slice(0, 8);
50+
const reason = process.env.DRIFT_REASON || 'main push was not classified as a staging promotion';
3751
3852
// Check for an existing open drift issue to avoid duplicates
3953
const issues = await github.rest.issues.listForRepo({
@@ -54,12 +68,14 @@ jobs:
5468
body: [
5569
`**${count} commit(s)** in \`main\` are not in \`staging\`'s ancestry after push to main at ${sha}.`,
5670
'',
57-
'This drift will cause conflicts on the next `staging → main` promotion.',
71+
`Policy reason: ${reason}`,
72+
'',
73+
'This push was not classified as a normal squash-merged `staging → main` release promotion.',
5874
'',
5975
'## Required action',
6076
'1. Create a `chore/sync-main-to-staging` branch from `origin/staging`',
61-
'2. `git merge origin/main -X ours --no-edit` (prefer staging for any conflicts)',
62-
'3. PR the branch into `staging`, merge, redeploy staging',
77+
'2. Apply the main-only hotfix/reconcile content to staging in commits that can be squash-merged',
78+
'3. PR the branch into `staging`, merge, and let CI redeploy staging',
6379
'4. Close this issue',
6480
'',
6581
'> Auto-opened by the detect-staging-drift workflow.',

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@
8484
- If code changes after staging verification, rerun local verification and redeploy staging before production.
8585
- Normal production promotion PR must be `staging` -> `main`.
8686
- Hotfix promotion PR may be `hotfix/<slug>` -> `main` only with explicit user approval in-thread.
87-
- **After any hotfix merges to main**: immediately create a `chore/sync-main-to-staging` branch from `origin/staging`, run `git merge origin/main -X ours --no-edit`, PR into `staging`, merge, and redeploy staging. Do not start new feature work until staging is back in sync. The `detect-staging-drift` workflow will open a GitHub Issue as a reminder if this is missed.
87+
- Normal squash-merged `staging` -> `main` production releases are not staging drift; the release content already came from `staging`, even though the squash commit is not in staging ancestry.
88+
- **After any hotfix merges to main**: immediately create a `chore/sync-main-to-staging` branch from `origin/staging`, apply the main-only hotfix/reconcile content in commits that can be squash-merged, PR into `staging`, merge, and let CI redeploy staging. Do not start new feature work until staging is back in sync. The `detect-staging-drift` workflow will open a GitHub Issue as a reminder if this is missed.
8889
- **Release-reconcile fallback rule**: if production promotion cannot be completed via direct `staging` -> `main` and uses a `hotfix/*` snapshot/reconcile PR instead, the same pass is not complete until `main` is synced back into `staging`, staging is redeployed, and the drift issue is closed.
8990
- Local run reliability:
9091
- Restart local server whenever runtime/config/env changes can affect behavior.
@@ -125,7 +126,8 @@
125126
- Run and report: `git log --oneline origin/staging -5`
126127
- Run and report: `git log --oneline origin/main -5`
127128
- Run and report: `git cherry -v origin/staging origin/main`
128-
- If drift exists, create a dedicated `chore/reconcile-...` PR before feature work.
129+
- If `git cherry` only reports the latest normal squash-merged `staging` -> `main` production release commit, treat it as expected ancestry-only drift and proceed.
130+
- If new main-only hotfix/reconcile content appears, create a dedicated `chore/sync-main-to-staging` PR before feature work.
129131
- Verification gates for deep-link/API-affecting work:
130132
- `npm run test -- --run src/lib/deepLink.test.ts`
131133
- `npm run test -- --run functions/api/v1/calculate.test.ts`

docs/release-flow.md

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -126,33 +126,12 @@
126126
- Issue branches must be created from latest `origin/staging`.
127127
- PRs into `staging` or `main` must be up-to-date with the base branch before merge.
128128
- Never promote from `issue/*` or `chore/*` directly into `main`.
129+
- Normal squash-merged `staging` -> `main` production releases are not staging drift. The release content already came from `staging`, even though the squash commit is not in staging ancestry.
129130
- After any `hotfix/*` merge into `main` (including release-reconcile fallback), immediately:
130131
1. create `chore/sync-main-to-staging` from `origin/staging`
131-
2. `git merge origin/main -X ours --no-edit`
132+
2. apply the main-only hotfix/reconcile content in commits that can be squash-merged
132133
3. PR and merge into `staging`
133-
4. `npm run deploy:staging` and verify deployment
134-
5. close the drift issue
135-
136-
## Drift prevention rules
137-
- Issue branches must be created from latest `origin/staging`.
138-
- PRs into `staging` or `main` must be up-to-date with the base branch before merge.
139-
- Never promote from `issue/*` or `chore/*` directly into `main`.
140-
- After any `hotfix/*` merge into `main` (including release-reconcile fallback), immediately:
141-
1. create `chore/sync-main-to-staging` from `origin/staging`
142-
2. `git merge origin/main -X ours --no-edit`
143-
3. PR and merge into `staging`
144-
4. `npm run deploy:staging` and verify deployment
145-
5. close the drift issue
146-
147-
## Drift prevention rules
148-
- Issue branches must be created from latest `origin/staging`.
149-
- PRs into `staging` or `main` must be up-to-date with the base branch before merge.
150-
- Never promote from `issue/*` or `chore/*` directly into `main`.
151-
- After any `hotfix/*` merge into `main` (including release-reconcile fallback), immediately:
152-
1. create `chore/sync-main-to-staging` from `origin/staging`
153-
2. `git merge origin/main -X ours --no-edit`
154-
3. PR and merge into `staging`
155-
4. `npm run deploy:staging` and verify deployment
134+
4. let CI deploy staging and verify deployment
156135
5. close the drift issue
157136

158137
## Deploy Targets Reference

scripts/staging-drift-policy.mjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const asCount = (value) => {
2+
const parsed = Number(value);
3+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
4+
};
5+
6+
const normalizePullRequest = (pullRequest) => ({
7+
headRefName: pullRequest?.headRefName ?? pullRequest?.head?.ref ?? "",
8+
baseRefName: pullRequest?.baseRefName ?? pullRequest?.base?.ref ?? "",
9+
mergedAt: pullRequest?.mergedAt ?? pullRequest?.merged_at ?? null,
10+
htmlUrl: pullRequest?.url ?? pullRequest?.html_url ?? "",
11+
});
12+
13+
export const isNormalStagingPromotion = (pullRequest) => {
14+
const normalized = normalizePullRequest(pullRequest);
15+
return normalized.baseRefName === "main" && normalized.headRefName === "staging" && Boolean(normalized.mergedAt);
16+
};
17+
18+
export const shouldOpenStagingDriftIssue = ({ driftCount, associatedPullRequests }) => {
19+
const count = asCount(driftCount);
20+
if (count === 0) {
21+
return { openIssue: false, reason: "no ancestry drift detected" };
22+
}
23+
const pullRequests = Array.isArray(associatedPullRequests) ? associatedPullRequests : [];
24+
const stagingPromotion = pullRequests.map(normalizePullRequest).find(isNormalStagingPromotion);
25+
if (stagingPromotion) {
26+
return {
27+
openIssue: false,
28+
reason: `normal squash-merged staging->main promotion: ${stagingPromotion.htmlUrl || "associated PR"}`,
29+
};
30+
}
31+
return { openIssue: true, reason: `${count} main commit(s) are not represented by a staging promotion PR` };
32+
};
33+
34+
const writeGitHubOutput = async (entries) => {
35+
const outputPath = process.env.GITHUB_OUTPUT;
36+
const lines = Object.entries(entries).map(([key, value]) => `${key}=${value}`);
37+
if (!outputPath) {
38+
console.log(lines.join("\n"));
39+
return;
40+
}
41+
const { appendFileSync } = await import("node:fs");
42+
appendFileSync(outputPath, `${lines.join("\n")}\n`);
43+
};
44+
45+
const fetchAssociatedPullRequests = async ({ token, repository, sha }) => {
46+
const [owner, repo] = repository.split("/");
47+
if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
48+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/pulls`, {
49+
headers: {
50+
Accept: "application/vnd.github+json",
51+
Authorization: `Bearer ${token}`,
52+
"X-GitHub-Api-Version": "2022-11-28",
53+
},
54+
});
55+
if (!response.ok) {
56+
throw new Error(`GitHub API returned ${response.status} while reading associated PRs`);
57+
}
58+
return response.json();
59+
};
60+
61+
export const runCli = async () => {
62+
const driftCount = process.env.DRIFT_COUNT ?? "0";
63+
const token = process.env.GITHUB_TOKEN ?? "";
64+
const repository = process.env.GITHUB_REPOSITORY ?? "";
65+
const sha = process.env.GITHUB_SHA ?? "";
66+
if (!token || !repository || !sha) {
67+
throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_SHA are required");
68+
}
69+
70+
const associatedPullRequests = await fetchAssociatedPullRequests({ token, repository, sha });
71+
const result = shouldOpenStagingDriftIssue({ driftCount, associatedPullRequests });
72+
await writeGitHubOutput({
73+
open_issue: result.openIssue ? "true" : "false",
74+
reason: result.reason,
75+
});
76+
console.log(`[staging-drift-policy] ${result.openIssue ? "open issue" : "skip issue"}: ${result.reason}`);
77+
};
78+
79+
if (import.meta.url === `file://${process.argv[1]}`) {
80+
runCli().catch((error) => {
81+
console.error(error);
82+
process.exit(1);
83+
});
84+
}

src/lib/stagingDriftPolicy.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/// <reference types="node" />
2+
3+
import { describe, expect, it } from "vitest";
4+
import { execFileSync } from "node:child_process";
5+
import { resolve } from "node:path";
6+
7+
const scriptPath = resolve(process.cwd(), "scripts/staging-drift-policy.mjs");
8+
9+
const evaluatePolicy = (expression: string) => {
10+
const output = execFileSync(
11+
process.execPath,
12+
[
13+
"--input-type=module",
14+
"--eval",
15+
`import { isNormalStagingPromotion, shouldOpenStagingDriftIssue } from ${JSON.stringify(scriptPath)};
16+
const result = ${expression};
17+
console.log(JSON.stringify(result));`,
18+
],
19+
{ encoding: "utf8" },
20+
);
21+
return JSON.parse(output) as unknown;
22+
};
23+
24+
describe("staging drift policy", () => {
25+
it("does not open a drift issue for a squash-merged staging to main promotion", () => {
26+
const result = evaluatePolicy(`shouldOpenStagingDriftIssue({
27+
driftCount: 1,
28+
associatedPullRequests: [
29+
{
30+
head: { ref: "staging" },
31+
base: { ref: "main" },
32+
merged_at: "2026-04-27T12:00:00Z",
33+
html_url: "https://github.com/wilhel1812/LinkSim/pull/769",
34+
},
35+
],
36+
})`) as { openIssue: boolean; reason: string };
37+
38+
expect(result.openIssue).toBe(false);
39+
expect(result.reason).toContain("staging->main");
40+
});
41+
42+
it("opens a drift issue for hotfix or other non-staging main pushes", () => {
43+
const result = evaluatePolicy(`shouldOpenStagingDriftIssue({
44+
driftCount: 1,
45+
associatedPullRequests: [
46+
{
47+
head: { ref: "hotfix/auth-timeout" },
48+
base: { ref: "main" },
49+
merged_at: "2026-04-27T12:00:00Z",
50+
},
51+
],
52+
})`) as { openIssue: boolean };
53+
54+
expect(result.openIssue).toBe(true);
55+
});
56+
57+
it("does not open a drift issue when there are no main-only commits", () => {
58+
const result = evaluatePolicy(`shouldOpenStagingDriftIssue({
59+
driftCount: 0,
60+
associatedPullRequests: [],
61+
})`) as { openIssue: boolean };
62+
63+
expect(result.openIssue).toBe(false);
64+
});
65+
66+
it("recognizes associated pull request shapes from the GitHub API and GraphQL-style data", () => {
67+
expect(
68+
evaluatePolicy(`isNormalStagingPromotion({
69+
head: { ref: "staging" },
70+
base: { ref: "main" },
71+
merged_at: "2026-04-27T12:00:00Z",
72+
})`),
73+
).toBe(true);
74+
expect(
75+
evaluatePolicy(`isNormalStagingPromotion({
76+
headRefName: "staging",
77+
baseRefName: "main",
78+
mergedAt: "2026-04-27T12:00:00Z",
79+
})`),
80+
).toBe(true);
81+
});
82+
});

0 commit comments

Comments
 (0)