|
| 1 | +name: update changelog |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + version: |
| 7 | + description: "Target version; leave empty to auto bump from debian/changelog" |
| 8 | + required: false |
| 9 | + type: string |
| 10 | + name: |
| 11 | + description: "Maintainer name used in debian/changelog" |
| 12 | + required: true |
| 13 | + type: string |
| 14 | + email: |
| 15 | + description: "Maintainer email used in debian/changelog" |
| 16 | + required: true |
| 17 | + type: string |
| 18 | + base_branch: |
| 19 | + description: "Base branch to read and target" |
| 20 | + required: false |
| 21 | + default: master |
| 22 | + type: string |
| 23 | + distribution: |
| 24 | + description: "Changelog distribution" |
| 25 | + required: false |
| 26 | + default: unstable |
| 27 | + type: string |
| 28 | + create_pr: |
| 29 | + description: "Create a pull request after generating changelog" |
| 30 | + required: false |
| 31 | + default: true |
| 32 | + type: boolean |
| 33 | + workflow_call: |
| 34 | + inputs: |
| 35 | + version: |
| 36 | + required: false |
| 37 | + type: string |
| 38 | + name: |
| 39 | + required: true |
| 40 | + type: string |
| 41 | + email: |
| 42 | + required: true |
| 43 | + type: string |
| 44 | + base_branch: |
| 45 | + required: false |
| 46 | + type: string |
| 47 | + default: master |
| 48 | + distribution: |
| 49 | + required: false |
| 50 | + type: string |
| 51 | + default: unstable |
| 52 | + create_pr: |
| 53 | + required: false |
| 54 | + type: boolean |
| 55 | + default: true |
| 56 | + secrets: |
| 57 | + APP_ID: |
| 58 | + required: false |
| 59 | + APP_PRIVATE_KEY: |
| 60 | + required: false |
| 61 | + outputs: |
| 62 | + pr_url: |
| 63 | + value: ${{ jobs.update_changelog.outputs.pr_url }} |
| 64 | + pr_number: |
| 65 | + value: ${{ jobs.update_changelog.outputs.pr_number }} |
| 66 | + version: |
| 67 | + value: ${{ jobs.update_changelog.outputs.version }} |
| 68 | + |
| 69 | +permissions: |
| 70 | + contents: write |
| 71 | + pull-requests: write |
| 72 | + |
| 73 | +jobs: |
| 74 | + update_changelog: |
| 75 | + runs-on: ubuntu-latest |
| 76 | + outputs: |
| 77 | + pr_url: ${{ steps.create_pr.outputs.pull-request-url }} |
| 78 | + pr_number: ${{ steps.create_pr.outputs.pull-request-number }} |
| 79 | + version: ${{ steps.prepare.outputs.version }} |
| 80 | + steps: |
| 81 | + - name: Create GitHub App token |
| 82 | + id: app_token |
| 83 | + if: ${{ inputs.create_pr }} |
| 84 | + uses: actions/create-github-app-token@v3 |
| 85 | + with: |
| 86 | + app-id: ${{ secrets.APP_ID }} |
| 87 | + private-key: ${{ secrets.APP_PRIVATE_KEY }} |
| 88 | + owner: ${{ github.repository_owner }} |
| 89 | + permission-contents: write |
| 90 | + permission-pull-requests: write |
| 91 | + |
| 92 | + - name: Checkout base branch |
| 93 | + uses: actions/checkout@v7 |
| 94 | + with: |
| 95 | + ref: ${{ inputs.base_branch }} |
| 96 | + fetch-depth: 1 |
| 97 | + persist-credentials: false |
| 98 | + |
| 99 | + - name: Install changelog tools |
| 100 | + run: | |
| 101 | + sudo apt-get update |
| 102 | + sudo apt-get install -y devscripts |
| 103 | +
|
| 104 | + - name: Prepare changelog data |
| 105 | + id: prepare |
| 106 | + shell: python |
| 107 | + env: |
| 108 | + INPUT_VERSION: ${{ inputs.version }} |
| 109 | + INPUT_BASE_BRANCH: ${{ inputs.base_branch }} |
| 110 | + INPUT_DISTRIBUTION: ${{ inputs.distribution }} |
| 111 | + GITHUB_TOKEN: ${{ steps.app_token.outputs.token || github.token }} |
| 112 | + GITHUB_REPOSITORY: ${{ github.repository }} |
| 113 | + run: | |
| 114 | + import json |
| 115 | + import os |
| 116 | + import pathlib |
| 117 | + import re |
| 118 | + import subprocess |
| 119 | + import urllib.error |
| 120 | + import urllib.parse |
| 121 | + import urllib.request |
| 122 | +
|
| 123 | + def output_line(name, value): |
| 124 | + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: |
| 125 | + fh.write(f"{name}={value}\n") |
| 126 | +
|
| 127 | + def get_current_version(changelog_path): |
| 128 | + try: |
| 129 | + result = subprocess.run( |
| 130 | + ["dpkg-parsechangelog", "-l", str(changelog_path), "-S", "Version"], |
| 131 | + capture_output=True, |
| 132 | + text=True, |
| 133 | + check=True, |
| 134 | + ) |
| 135 | + return result.stdout.strip() |
| 136 | + except Exception: |
| 137 | + first_line = changelog_path.read_text(encoding="utf-8").splitlines()[0].strip() |
| 138 | + match = re.match(r"^[^(]+\(([^)]+)\)", first_line) |
| 139 | + if not match: |
| 140 | + raise RuntimeError(f"cannot parse version from {changelog_path}") |
| 141 | + return match.group(1) |
| 142 | +
|
| 143 | + def bump_version(version): |
| 144 | + epoch = "" |
| 145 | + value = version |
| 146 | + if ":" in value: |
| 147 | + epoch_part, value = value.split(":", 1) |
| 148 | + if epoch_part.isdigit(): |
| 149 | + epoch = epoch_part + ":" |
| 150 | + if "-" in value: |
| 151 | + value = value.rsplit("-", 1)[0] |
| 152 | + parts = value.split(".") |
| 153 | + if len(parts) < 2 or any(not part.isdigit() for part in parts): |
| 154 | + raise RuntimeError( |
| 155 | + "cannot auto bump complex Debian version " |
| 156 | + f"{version!r}; pass the workflow input 'version' explicitly" |
| 157 | + ) |
| 158 | + parts[-1] = str(int(parts[-1]) + 1) |
| 159 | + return epoch + ".".join(parts) |
| 160 | +
|
| 161 | + def github_get(url): |
| 162 | + headers = { |
| 163 | + "Accept": "application/vnd.github+json", |
| 164 | + "X-GitHub-Api-Version": "2022-11-28", |
| 165 | + "User-Agent": "deepin-autopack-update-changelog", |
| 166 | + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", |
| 167 | + } |
| 168 | + req = urllib.request.Request(url, headers=headers) |
| 169 | + try: |
| 170 | + with urllib.request.urlopen(req, timeout=30) as resp: |
| 171 | + body = resp.read().decode("utf-8") |
| 172 | + data = json.loads(body) if body else None |
| 173 | + resp_headers = {k.lower(): v for k, v in resp.headers.items()} |
| 174 | + return data, resp_headers |
| 175 | + except urllib.error.HTTPError as exc: |
| 176 | + body = exc.read().decode("utf-8", errors="replace") |
| 177 | + if len(body) > 2000: |
| 178 | + body = body[:2000] + "...<truncated>" |
| 179 | + raise RuntimeError( |
| 180 | + f"GitHub API request failed: status={exc.code}, url={url}, body={body}" |
| 181 | + ) from exc |
| 182 | +
|
| 183 | + def parse_link_header(value): |
| 184 | + links = {} |
| 185 | + if not value: |
| 186 | + return links |
| 187 | + for item in value.split(","): |
| 188 | + item = item.strip() |
| 189 | + match = re.match(r'<([^>]+)>;\s*rel="([^"]+)"', item) |
| 190 | + if match: |
| 191 | + links[match.group(2)] = match.group(1) |
| 192 | + return links |
| 193 | +
|
| 194 | + def commit_titles_since_changelog(owner, repo, base_branch, head_sha): |
| 195 | + base_url = f"https://api.github.com/repos/{owner}/{repo}" |
| 196 | + commits_url = f"{base_url}/commits?{urllib.parse.urlencode({'sha': base_branch, 'path': 'debian/changelog', 'per_page': 1})}" |
| 197 | + commits, _ = github_get(commits_url) |
| 198 | + if not commits: |
| 199 | + raise RuntimeError(f"no commit found for debian/changelog on {base_branch}") |
| 200 | +
|
| 201 | + base_sha = commits[0]["sha"] |
| 202 | + compare_url = f"{base_url}/compare/{urllib.parse.quote(base_sha, safe='')}...{urllib.parse.quote(head_sha, safe='')}?per_page=100" |
| 203 | + titles = [] |
| 204 | + next_url = compare_url |
| 205 | + while next_url: |
| 206 | + page, page_headers = github_get(next_url) |
| 207 | + for item in page.get("commits", []): |
| 208 | + message = item.get("commit", {}).get("message", "") |
| 209 | + title = message.splitlines()[0].strip() |
| 210 | + if title: |
| 211 | + titles.append(title) |
| 212 | + next_url = parse_link_header(page_headers.get("link")).get("next") |
| 213 | + return base_sha, titles |
| 214 | +
|
| 215 | + repo_root = pathlib.Path(os.environ["GITHUB_WORKSPACE"]) |
| 216 | + changelog_path = repo_root / "debian" / "changelog" |
| 217 | + if not changelog_path.exists(): |
| 218 | + raise RuntimeError(f"missing changelog: {changelog_path}") |
| 219 | +
|
| 220 | + requested_version = os.environ.get("INPUT_VERSION", "").strip() |
| 221 | + distribution = os.environ.get("INPUT_DISTRIBUTION", "").strip() or "unstable" |
| 222 | + base_branch = os.environ.get("INPUT_BASE_BRANCH", "").strip() or "master" |
| 223 | + owner, repo = os.environ["GITHUB_REPOSITORY"].split("/", 1) |
| 224 | + head_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() |
| 225 | +
|
| 226 | + current_version = get_current_version(changelog_path) |
| 227 | + version = requested_version or bump_version(current_version) |
| 228 | + if not version: |
| 229 | + raise RuntimeError(f"unable to determine version from {current_version!r}") |
| 230 | +
|
| 231 | + base_sha, titles = commit_titles_since_changelog(owner, repo, base_branch, head_sha) |
| 232 | + safe_version = re.sub(r"[^A-Za-z0-9._-]+", "-", version).strip("-") |
| 233 | +
|
| 234 | + output_line("version", version) |
| 235 | + output_line("safe_version", safe_version) |
| 236 | + output_line("distribution", distribution) |
| 237 | + output_line("base_branch", base_branch) |
| 238 | + output_line("base_sha", base_sha) |
| 239 | + output_line("head_sha", head_sha) |
| 240 | + output_line("titles_json", json.dumps(titles, ensure_ascii=False)) |
| 241 | +
|
| 242 | + - name: Generate changelog |
| 243 | + shell: python |
| 244 | + env: |
| 245 | + VERSION: ${{ steps.prepare.outputs.version }} |
| 246 | + DISTRIBUTION: ${{ steps.prepare.outputs.distribution }} |
| 247 | + TITLES_JSON: ${{ steps.prepare.outputs.titles_json }} |
| 248 | + DEBFULLNAME: ${{ inputs.name }} |
| 249 | + DEBEMAIL: ${{ inputs.email }} |
| 250 | + TZ: Asia/Shanghai |
| 251 | + run: | |
| 252 | + import json |
| 253 | + import os |
| 254 | + import subprocess |
| 255 | +
|
| 256 | + version = os.environ["VERSION"].strip() |
| 257 | + distribution = os.environ["DISTRIBUTION"].strip() or "unstable" |
| 258 | + titles = json.loads(os.environ["TITLES_JSON"]) |
| 259 | + if not titles: |
| 260 | + titles = [f"Release {version}"] |
| 261 | +
|
| 262 | + subprocess.run( |
| 263 | + ["dch", "-v", version, "-D", distribution, titles[0]], |
| 264 | + check=True, |
| 265 | + ) |
| 266 | + for title in titles[1:]: |
| 267 | + subprocess.run(["dch", "-a", title], check=True) |
| 268 | +
|
| 269 | + - name: Show changelog diff |
| 270 | + if: ${{ !inputs.create_pr }} |
| 271 | + run: git diff -- debian/changelog |
| 272 | + |
| 273 | + - name: Upload generated changelog |
| 274 | + if: ${{ !inputs.create_pr }} |
| 275 | + uses: actions/upload-artifact@v4 |
| 276 | + with: |
| 277 | + name: generated-changelog |
| 278 | + path: debian/changelog |
| 279 | + |
| 280 | + - name: Create pull request |
| 281 | + id: create_pr |
| 282 | + if: ${{ inputs.create_pr }} |
| 283 | + uses: peter-evans/create-pull-request@v6 |
| 284 | + with: |
| 285 | + token: ${{ steps.app_token.outputs.token }} |
| 286 | + branch: automation/update-changelog/${{ steps.prepare.outputs.safe_version }} |
| 287 | + title: "chore: update changelog to ${{ steps.prepare.outputs.version }}" |
| 288 | + commit-message: "chore: update changelog to ${{ steps.prepare.outputs.version }}" |
| 289 | + body: | |
| 290 | + Automated changelog update. |
| 291 | +
|
| 292 | + - Version: `${{ steps.prepare.outputs.version }}` |
| 293 | + - Base branch: `${{ steps.prepare.outputs.base_branch }}` |
| 294 | + - Base changelog commit: `${{ steps.prepare.outputs.base_sha }}` |
| 295 | + - Head commit: `${{ steps.prepare.outputs.head_sha }}` |
| 296 | + add-paths: | |
| 297 | + debian/changelog |
| 298 | + delete-branch: true |
| 299 | + |
| 300 | + - name: Show pull request result |
| 301 | + if: ${{ inputs.create_pr }} |
| 302 | + run: | |
| 303 | + echo "Version: ${{ steps.prepare.outputs.version }}" |
| 304 | + echo "Pull Request Number: ${{ steps.create_pr.outputs.pull-request-number }}" |
| 305 | + echo "Pull Request URL: ${{ steps.create_pr.outputs.pull-request-url }}" |
0 commit comments