Skip to content

Commit eeb72c5

Browse files
authored
ci(api): publish semver image tags (firecrawl#3781)
1 parent 49e16e4 commit eeb72c5

3 files changed

Lines changed: 324 additions & 32 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env python3
2+
"""Resolve the next deployable API image version.
3+
4+
Deploy image versions are full SemVer Git tags: vX.Y.Z. Existing two-part
5+
marketing release tags such as v2.10 are treated as v2.10.0 bases.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import os
12+
import re
13+
import subprocess
14+
import sys
15+
from dataclasses import dataclass
16+
17+
18+
VERSION_TAG_RE = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$")
19+
20+
21+
@dataclass(frozen=True)
22+
class SemverTag:
23+
name: str
24+
version: tuple[int, int, int]
25+
commit: str
26+
27+
28+
def git(*args: str) -> str:
29+
return subprocess.check_output(["git", *args], text=True).strip()
30+
31+
32+
def list_semver_tags() -> list[SemverTag]:
33+
raw_tags = git("tag", "--list", "v*").splitlines()
34+
tags: list[SemverTag] = []
35+
36+
for tag in raw_tags:
37+
match = VERSION_TAG_RE.match(tag)
38+
if not match:
39+
continue
40+
41+
major, minor, patch = match.groups()
42+
commit = git("rev-list", "-n", "1", tag)
43+
tags.append(
44+
SemverTag(
45+
name=tag,
46+
version=(int(major), int(minor), int(patch or 0)),
47+
commit=commit,
48+
)
49+
)
50+
51+
return sorted(tags, key=lambda item: item.version)
52+
53+
54+
def bump_version(base: tuple[int, int, int] | None, bump: str) -> tuple[int, int, int]:
55+
if base is None:
56+
return (0, 1, 0) if bump == "minor" else (0, 0, 1)
57+
58+
major, minor, patch = base
59+
if bump == "minor":
60+
return (major, minor + 1, 0)
61+
62+
return (major, minor, patch + 1)
63+
64+
65+
def write_output(name: str, value: str) -> None:
66+
print(f"{name}={value}")
67+
68+
github_output = os.environ.get("GITHUB_OUTPUT")
69+
if github_output:
70+
with open(github_output, "a", encoding="utf-8") as output:
71+
output.write(f"{name}={value}\n")
72+
73+
74+
def main() -> int:
75+
parser = argparse.ArgumentParser()
76+
parser.add_argument("--bump", choices=["patch", "minor"], default="patch")
77+
parser.add_argument("--sha", default=os.environ.get("GITHUB_SHA", "HEAD"))
78+
parser.add_argument("--image-name", default="firecrawl")
79+
parser.add_argument(
80+
"--include-current-commit-tags",
81+
action="store_true",
82+
help="Use the highest SemVer tag even when it already points at --sha.",
83+
)
84+
args = parser.parse_args()
85+
86+
current_sha = git("rev-parse", args.sha)
87+
short_sha = git("rev-parse", "--short=12", current_sha)
88+
semver_tags = list_semver_tags()
89+
90+
if args.include_current_commit_tags:
91+
base_tags = semver_tags
92+
else:
93+
# Excluding tags already on this commit makes production retries
94+
# idempotent. If a prior attempt created vX.Y.Z but failed before
95+
# pushing every manifest, the same target version is reused instead of
96+
# burning vX.Y.(Z+1).
97+
base_tags = [tag for tag in semver_tags if tag.commit != current_sha]
98+
99+
base = base_tags[-1].version if base_tags else None
100+
version = bump_version(base, args.bump)
101+
tag_name = f"v{version[0]}.{version[1]}.{version[2]}"
102+
103+
existing_target = next((tag for tag in semver_tags if tag.name == tag_name), None)
104+
if existing_target and existing_target.commit != current_sha:
105+
print(
106+
f"Resolved tag {tag_name} already exists on {existing_target.commit}, "
107+
f"not {current_sha}",
108+
file=sys.stderr,
109+
)
110+
return 1
111+
112+
repo_owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "firecrawl").lower()
113+
image = f"ghcr.io/{repo_owner}/{args.image_name}"
114+
version_text = ".".join(str(part) for part in version)
115+
116+
write_output("version", version_text)
117+
write_output("tag", tag_name)
118+
write_output("tag_exists", "true" if existing_target else "false")
119+
write_output("major", str(version[0]))
120+
write_output("minor", str(version[1]))
121+
write_output("major_minor", f"{version[0]}.{version[1]}")
122+
write_output("short_sha", short_sha)
123+
write_output("image", image)
124+
125+
return 0
126+
127+
128+
if __name__ == "__main__":
129+
raise SystemExit(main())
Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,62 @@
11
name: STAGING Deploy Images to GHCR
22

3-
env:
4-
DOTNET_VERSION: '6.0.x'
5-
63
on:
74
workflow_dispatch:
85

6+
permissions:
7+
contents: read
8+
packages: write
9+
10+
concurrency:
11+
group: api-image-staging-release
12+
cancel-in-progress: false
13+
914
jobs:
10-
push-app-image:
11-
runs-on: blacksmith-4vcpu-ubuntu-2404
15+
version:
16+
runs-on: blacksmith-2vcpu-ubuntu-2404
17+
outputs:
18+
version: ${{ steps.version.outputs.version }}
19+
short_sha: ${{ steps.version.outputs.short_sha }}
20+
image: ${{ steps.version.outputs.image }}
21+
staging_image: ${{ steps.staging_image.outputs.image }}
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
with:
26+
fetch-depth: 0
27+
28+
- name: Fetch tags
29+
run: git fetch --force --tags origin
30+
31+
- name: Resolve staging image version
32+
id: version
33+
run: python .github/scripts/resolve_api_image_version.py --bump patch --sha "${GITHUB_SHA}" --include-current-commit-tags
34+
35+
- name: Resolve staging image repository
36+
id: staging_image
37+
run: echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/firecrawl-staging" >> "$GITHUB_OUTPUT"
38+
39+
build:
40+
needs: version
41+
runs-on: ${{ matrix.runner }}
42+
strategy:
43+
matrix:
44+
include:
45+
- platform: linux/amd64
46+
platform_suffix: linux-amd64
47+
runner: blacksmith-4vcpu-ubuntu-2404
48+
- platform: linux/arm64
49+
platform_suffix: linux-arm64
50+
runner: blacksmith-4vcpu-ubuntu-2404-arm
1251
defaults:
1352
run:
1453
working-directory: './apps/api'
1554
steps:
16-
- name: 'Checkout GitHub Action'
17-
uses: actions/checkout@main
55+
- name: Checkout repository
56+
uses: actions/checkout@v4
57+
58+
- name: Setup Blacksmith Builder
59+
uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1
1860

1961
- name: 'Login to GitHub Container Registry'
2062
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
@@ -23,7 +65,45 @@ jobs:
2365
username: ${{github.actor}}
2466
password: ${{secrets.GITHUB_TOKEN}}
2567

26-
- name: 'Build Inventory Image'
68+
- name: 'Build and Push Image'
69+
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
70+
with:
71+
context: ./apps/api
72+
push: true
73+
tags: ${{ needs.version.outputs.image }}:sha-${{ needs.version.outputs.short_sha }}-staging-${{ matrix.platform_suffix }}
74+
platforms: ${{ matrix.platform }}
75+
provenance: false
76+
77+
publish:
78+
runs-on: blacksmith-2vcpu-ubuntu-2404
79+
needs:
80+
- version
81+
- build
82+
steps:
83+
- name: 'Login to GitHub Container Registry'
84+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
85+
with:
86+
registry: ghcr.io
87+
username: ${{github.actor}}
88+
password: ${{secrets.GITHUB_TOKEN}}
89+
90+
- name: 'Create and Push Staging Multi-Arch Manifest'
91+
env:
92+
IMAGE: ${{ needs.version.outputs.image }}
93+
STAGING_IMAGE: ${{ needs.version.outputs.staging_image }}
94+
VERSION: ${{ needs.version.outputs.version }}
95+
SHORT_SHA: ${{ needs.version.outputs.short_sha }}
2796
run: |
28-
docker build . --tag ghcr.io/firecrawl/firecrawl-staging:latest
29-
docker push ghcr.io/firecrawl/firecrawl-staging:latest
97+
amd64_image="${IMAGE}:sha-${SHORT_SHA}-staging-linux-amd64"
98+
arm64_image="${IMAGE}:sha-${SHORT_SHA}-staging-linux-arm64"
99+
manifest_tags=(
100+
"${IMAGE}:${VERSION}-staging"
101+
"${STAGING_IMAGE}:${VERSION}-staging"
102+
"${STAGING_IMAGE}:latest"
103+
)
104+
105+
for target in "${manifest_tags[@]}"; do
106+
docker manifest rm "${target}" >/dev/null 2>&1 || true
107+
docker manifest create "${target}" "${amd64_image}" "${arm64_image}"
108+
docker manifest push "${target}"
109+
done

0 commit comments

Comments
 (0)