Skip to content

Commit 34aa8ce

Browse files
committed
(ELI-466) second draft
1 parent 72a429c commit 34aa8ce

3 files changed

Lines changed: 347 additions & 83 deletions

File tree

Lines changed: 70 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,109 @@
11
name: "4. CD | Deploy to PreProd"
22

33
concurrency:
4-
group: terraform-deploy-preprod
4+
group: preprod-deploy
55
cancel-in-progress: false
66

77
on:
8-
# Auto promote after TEST succeeds
98
workflow_run:
109
workflows: ["3. CD | Deploy to Test"]
1110
types: [completed]
12-
13-
# Manual override path (kept)
1411
workflow_dispatch:
1512
inputs:
1613
ref:
17-
description: "Branch/Tag/SHA to deploy to preprod (dev tag)"
18-
required: false
19-
default: ""
14+
description: "dev-* tag to deploy to PreProd"
15+
required: true
2016
release_type:
21-
description: "Version bump (rc|patch|minor|major)"
17+
description: "rc|patch|minor|major"
18+
required: true
19+
default: "rc"
20+
allow_older:
21+
description: "Allow deploying older than latest tested on main?"
2222
required: false
23-
default: ""
2423
type: choice
25-
options: [rc, patch, minor, major]
24+
options: ["false","true"]
25+
default: "false"
26+
reason:
27+
description: "Why are you doing a manual deployment?"
28+
required: true
29+
default: "To roll back to a previous commit"
30+
31+
permissions:
32+
contents: read
33+
actions: read
2634

2735
jobs:
2836
metadata:
29-
name: "Resolve ref + release_type"
37+
name: "Resolve ref + stale guard + release type"
3038
runs-on: ubuntu-latest
31-
32-
# Only run automatically if TEST succeeded,
33-
# or always if this is a manual dispatch
34-
if: >
35-
(github.event_name == 'workflow_run' &&
36-
github.event.workflow_run.conclusion == 'success')
37-
|| github.event_name == 'workflow_dispatch'
38-
3939
outputs:
40-
ref: ${{ steps.resolve_ref.outputs.ref }}
41-
release_type: ${{ steps.resolve_release_type.outputs.release_type }}
40+
ref: ${{ steps.resolver.outputs.this_ref }}
41+
this_sha: ${{ steps.resolver.outputs.this_sha }}
42+
latest_sha: ${{ steps.resolver.outputs.latest_test_sha }}
43+
release_type: ${{ steps.release_type.outputs.release_type }}
4244

4345
steps:
44-
- name: "Checkout exact commit (auto path) or repo (manual path)"
45-
uses: actions/checkout@v5
46-
with:
47-
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 'main' }}
48-
fetch-depth: 0
46+
- name: Checkout (full history & tags)
47+
uses: actions/checkout@v4
48+
with: { fetch-depth: 0 }
4949

50-
- name: "Resolve the dev-* tag for this commit (auto path)"
51-
id: resolve_ref
50+
- name: Announce candidate ref
51+
id: announce
5252
shell: bash
5353
run: |
5454
set -euo pipefail
55-
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
56-
# manual override provided?
57-
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
58-
echo "ref=${{ github.event.inputs.ref }}" >> $GITHUB_OUTPUT
59-
exit 0
60-
fi
61-
echo "No manual ref provided; attempting to derive from HEAD of default branch."
62-
SHA=$(git rev-parse HEAD)
63-
else
64-
SHA="${{ github.event.workflow_run.head_sha }}"
65-
fi
66-
67-
git fetch --tags --force
68-
TAG=$(git tag --points-at "$SHA" | grep '^dev-' | head -n1 || true)
69-
if [[ -z "$TAG" ]]; then
70-
echo "ERROR: No dev-* tag found on $SHA=$SHA and no manual ref provided." >&2
71-
exit 1
72-
fi
73-
echo "ref=$TAG" >> $GITHUB_OUTPUT
74-
echo "Resolved ref: $TAG"
55+
git fetch --tags --force --quiet
7556
76-
- name: "Infer release_type from PR labels (fallback to rc)"
77-
id: resolve_release_type
78-
env:
79-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80-
shell: bash
81-
run: |
82-
set -euo pipefail
83-
84-
# manual override wins
85-
if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${{ github.event.inputs.release_type }}" ]]; then
86-
echo "release_type=${{ github.event.inputs.release_type }}" >> $GITHUB_OUTPUT
87-
exit 0
88-
fi
89-
90-
# find PR for the commit that went to TEST
9157
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
92-
SHA="${{ github.event.workflow_run.head_sha }}"
58+
HEAD="${{ github.event.workflow_run.head_sha }}"
59+
TAG="$(git tag --points-at "$HEAD" | grep '^dev-' | head -n1 || true)"
9360
else
94-
SHA=$(git rev-parse HEAD)
61+
HEAD="$(git rev-list -n1 "${{ github.event.inputs.ref }}")"
62+
TAG="${{ github.event.inputs.ref }}"
9563
fi
9664
97-
# Use the commits->pulls API to get the PR number
98-
PR_NUM=$(gh api \
99-
-H "Accept: application/vnd.github.groot-preview+json" \
100-
/repos/${{ github.repository }}/commits/$SHA/pulls \
101-
--jq '.[0].number // empty')
65+
echo "CANDIDATE_TAG=$TAG"
66+
echo "CANDIDATE_SHA=$HEAD"
10267
103-
RELEASE_TYPE="rc" # safe default
104-
if [[ -n "$PR_NUM" ]]; then
105-
LABELS=$(gh api /repos/${{ github.repository }}/issues/$PR_NUM/labels --jq '.[].name')
106-
if echo "$LABELS" | grep -q '^release:major$'; then RELEASE_TYPE="major"; fi
107-
if echo "$LABELS" | grep -q '^release:minor$'; then RELEASE_TYPE="minor"; fi
108-
if echo "$LABELS" | grep -q '^release:patch$'; then RELEASE_TYPE="patch"; fi
109-
if echo "$LABELS" | grep -q '^release:rc$'; then RELEASE_TYPE="rc"; fi
110-
fi
68+
# Nice UI hints
69+
echo "::notice title=PreProd candidate::dev tag: ${TAG} | sha: ${HEAD}"
70+
{
71+
echo "### PreProd candidate"
72+
echo ""
73+
echo "- dev tag: \`${TAG:-<none>}\`"
74+
echo "- head sha: \`${HEAD:-<none>}\`"
75+
} >> "$GITHUB_STEP_SUMMARY"
11176
112-
echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT
113-
echo "Resolved release_type: $RELEASE_TYPE"
114-
115-
call:
77+
- name: Resolve THIS vs LATEST TEST + stale guard (auto only)
78+
id: resolver
79+
env:
80+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81+
EVENT_NAME: ${{ github.event_name }}
82+
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
83+
MANUAL_REF: ${{ github.event.inputs.ref }}
84+
ALLOW_OLDER: ${{ github.event.inputs.allow_older }}
85+
WORKFLOW_NAME: "3. CD | Deploy to Test"
86+
BRANCH: "main"
87+
LIMIT: "30"
88+
run: python3 scripts/version_resolver.py
89+
90+
- name: Resolve release_type (labels → default rc)
91+
id: release_type
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
BRANCH: "main"
95+
AGGREGATE: "true"
96+
THIS_SHA: ${{ steps.resolver.outputs.this_sha }}
97+
LATEST_TEST_SHA: ${{ steps.resolver.outputs.latest_test_sha }}
98+
MANUAL_RELEASE_TYPE: ${{ github.event.inputs.release_type }}
99+
run: python3 scripts/release_type_resolver.py
100+
101+
deploy:
102+
name: "Call base-deploy.yml (PreProd)"
116103
needs: [metadata]
117104
uses: ./.github/workflows/base-deploy.yml
118105
with:
119106
environment: preprod
120-
ref: ${{ needs.metadata.outputs.ref }}
107+
ref: ${{ needs.metadata.outputs.ref }}
121108
release_type: ${{ needs.metadata.outputs.release_type }}
122109
secrets: inherit
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""
2+
Resolve release_type from PR labels with safe defaults.
3+
4+
Modes
5+
-----
6+
1) Manual override (MANUAL_RELEASE_TYPE): emit that and exit.
7+
2) Single-PR mode (default): inspect PR labels for THIS_SHA; default "rc".
8+
3) Aggregate mode (AGGREGATE=true): consider *all PRs merged since latest final tag*
9+
up to LATEST_TEST_SHA (BOUNDARY), and pick highest of {major > minor > patch > rc}.
10+
11+
Env inputs
12+
----------
13+
GH_TOKEN / GITHUB_TOKEN : required
14+
THIS_SHA : SHA being promoted (required unless manual override)
15+
LATEST_TEST_SHA : required when AGGREGATE=true
16+
MANUAL_RELEASE_TYPE : optional (rc|patch|minor|major)
17+
AGGREGATE : "true"|"false" (default "false")
18+
BRANCH : branch (default "main")
19+
20+
Outputs (GITHUB_OUTPUT)
21+
-----------------------
22+
release_type : rc|patch|minor|major
23+
basis : manual|single-pr|aggregate
24+
pr_numbers : comma-separated PR numbers considered
25+
"""
26+
27+
import os, subprocess, sys
28+
from typing import List, Set
29+
30+
BRANCH = os.getenv("BRANCH", "main")
31+
32+
def run(cmd: List[str], check=True, capture=True) -> subprocess.CompletedProcess:
33+
return subprocess.run(cmd, check=check, capture_output=capture, text=True)
34+
35+
def fail(msg: str) -> int:
36+
print(f"::error::{msg}", file=sys.stderr)
37+
return 1
38+
39+
def ensure_graph():
40+
run(["git","fetch","origin", BRANCH, "--quiet"], check=True)
41+
run(["git","fetch","--tags","--force","--quiet"], check=True)
42+
43+
def gh_api(path: str, jq: str | None = None) -> List[str]:
44+
args = ["gh","api", path]
45+
if jq:
46+
args += ["--jq", jq]
47+
cp = run(args, check=True)
48+
return [x for x in cp.stdout.splitlines() if x]
49+
50+
def latest_final_tag() -> str | None:
51+
cp = run(["git","tag","--list","v[0-9]*.[0-9]*.[0-9]*","--sort=-v:refname"], check=True)
52+
tags = cp.stdout.splitlines()
53+
return tags[0] if tags else None
54+
55+
def first_commit() -> str:
56+
return run(["git","rev-list","--max-parents=0","HEAD"], check=True).stdout.strip()
57+
58+
def first_parent_merges(base: str, head: str) -> List[str]:
59+
rng = f"{base}..{head}"
60+
cp = run(["git","rev-list","--merges","--first-parent", rng], check=False)
61+
return [x for x in cp.stdout.splitlines() if x]
62+
63+
def prs_for_commit(sha: str) -> List[int]:
64+
nums = gh_api(f"/repos/{os.getenv('GITHUB_REPOSITORY')}/commits/{sha}/pulls", jq=".[].number")
65+
return [int(n) for n in nums]
66+
67+
def labels_for_pr(pr: int) -> List[str]:
68+
return gh_api(f"/repos/{os.getenv('GITHUB_REPOSITORY')}/issues/{pr}/labels", jq=".[].name")
69+
70+
def pick_highest(labels: List[str]) -> str | None:
71+
has_major = any(l == "release:major" for l in labels)
72+
has_minor = any(l == "release:minor" for l in labels)
73+
has_patch = any(l == "release:patch" for l in labels)
74+
has_rc = any(l == "release:rc" for l in labels)
75+
if has_major: return "major"
76+
if has_minor: return "minor"
77+
if has_patch: return "patch"
78+
if has_rc: return "rc"
79+
return None
80+
81+
def main() -> int:
82+
if not (os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")):
83+
return fail("GH_TOKEN/GITHUB_TOKEN is required")
84+
85+
manual = (os.getenv("MANUAL_RELEASE_TYPE") or "").strip()
86+
if manual:
87+
if manual not in {"rc","patch","minor","major"}:
88+
return fail(f"Invalid MANUAL_RELEASE_TYPE: {manual}")
89+
out = os.getenv("GITHUB_OUTPUT")
90+
if out:
91+
with open(out, "a") as f:
92+
f.write(f"release_type={manual}\n")
93+
f.write("basis=manual\n")
94+
f.write("pr_numbers=\n")
95+
print(f"Release type (manual) → {manual}")
96+
return 0
97+
98+
this_sha = (os.getenv("THIS_SHA") or "").strip()
99+
if not this_sha:
100+
return fail("THIS_SHA is required when no MANUAL_RELEASE_TYPE is provided")
101+
102+
ensure_graph()
103+
104+
aggregate = (os.getenv("AGGREGATE","false").lower() == "true")
105+
pr_nums: Set[int] = set()
106+
basis = "single-pr"
107+
release_type = "rc"
108+
109+
if aggregate:
110+
latest_test_sha = (os.getenv("LATEST_TEST_SHA") or "").strip()
111+
if not latest_test_sha:
112+
return fail("LATEST_TEST_SHA is required when AGGREGATE=true")
113+
base = latest_final_tag() or first_commit()
114+
merges = first_parent_merges(base, latest_test_sha)
115+
for m in merges:
116+
for n in prs_for_commit(m):
117+
pr_nums.add(n)
118+
all_labels: List[str] = []
119+
for pr in pr_nums:
120+
all_labels.extend(labels_for_pr(pr))
121+
release_type = pick_highest(all_labels) or "rc"
122+
basis = "aggregate"
123+
else:
124+
pnums = prs_for_commit(this_sha)
125+
if pnums:
126+
pr = pnums[0]
127+
pr_nums.add(pr)
128+
release_type = pick_highest(labels_for_pr(pr)) or "rc"
129+
else:
130+
release_type = "rc"
131+
basis = "single-pr"
132+
133+
out = os.getenv("GITHUB_OUTPUT")
134+
if out:
135+
with open(out, "a") as f:
136+
f.write(f"release_type={release_type}\n")
137+
f.write(f"basis={basis}\n")
138+
f.write(f"pr_numbers={','.join(str(x) for x in sorted(pr_nums))}\n")
139+
140+
print(f"Release type ({basis}) → {release_type}")
141+
if pr_nums:
142+
print(f"Considered PRs: {', '.join(str(x) for x in sorted(pr_nums))}")
143+
return 0
144+
145+
if __name__ == "__main__":
146+
sys.exit(main())

0 commit comments

Comments
 (0)