Skip to content

Commit 414011c

Browse files
committed
perfetto: add finalize-release workflow with LUCI artifact verification
Adds .github/workflows/finalize-release.yml — a manually-triggered workflow that runs after LUCI completes to pull all release artifacts from GCS, verify they match the expected manifest, upload them to the matching draft GitHub release, and publish it. Extends tools/release/package-github-release-artifacts with: * --yes flag to bypass the interactive prompt (for CI use). * A LUCI artifact manifest + post-rsync verification step that fails loudly on anything missing or unexpected under the GCS version dir. The manifest mirrors ARTIFACTS / platform list in infra/luci/recipes/perfetto.py; drift in either direction is a hard error because a silent mismatch would ship an incomplete release. Also drops the redundant re-download of SDK zips: rsync already pulls them under <version>/sdk/, so we just move them up to the staging dir root. Per RFC-0022 "LUCI -> GitHub bridge".
1 parent 9e55876 commit 414011c

2 files changed

Lines changed: 255 additions & 35 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright (C) 2026 The Android Open Source Project
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Finalizes a release: downloads LUCI-built binaries and SDK source zips
16+
# from GCS via tools/release/package-github-release-artifacts, verifies
17+
# the full expected manifest is present, uploads the packaged zips to the
18+
# matching draft GitHub release, and publishes it.
19+
#
20+
# Manual trigger after LUCI completes. Idempotent — safe to re-run if a
21+
# previous invocation failed partway. See RFC-0022 "LUCI → GitHub bridge".
22+
23+
name: Finalize release
24+
25+
on:
26+
workflow_dispatch:
27+
inputs:
28+
version:
29+
description: 'Release version to finalize (e.g. v54.0)'
30+
required: true
31+
type: string
32+
33+
permissions:
34+
contents: write # Required to upload assets and publish the release
35+
36+
jobs:
37+
finalize:
38+
runs-on: ubuntu-latest
39+
steps:
40+
- name: Validate input
41+
env:
42+
VERSION: ${{ inputs.version }}
43+
run: |
44+
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+$ ]]; then
45+
echo "::error::Version must match vX.Y (got: $VERSION)"
46+
exit 1
47+
fi
48+
49+
- name: Checkout tag
50+
uses: actions/checkout@v4
51+
with:
52+
ref: ${{ inputs.version }}
53+
fetch-depth: 0
54+
fetch-tags: true
55+
56+
- name: Verify draft release exists
57+
env:
58+
GH_TOKEN: ${{ github.token }}
59+
VERSION: ${{ inputs.version }}
60+
run: |
61+
if ! gh release view "$VERSION" --repo "${{ github.repository }}" \
62+
> /dev/null 2>&1; then
63+
echo "::error::No release found for tag $VERSION. Push the tag \
64+
first (via promote-stable.yml) to create the draft."
65+
exit 1
66+
fi
67+
68+
- name: Install gcloud / gsutil
69+
uses: google-github-actions/setup-gcloud@v2
70+
71+
- name: Download, verify, and package LUCI artifacts
72+
env:
73+
VERSION: ${{ inputs.version }}
74+
run: |
75+
tools/release/package-github-release-artifacts --yes "$VERSION"
76+
77+
- name: Upload assets to draft release
78+
env:
79+
GH_TOKEN: ${{ github.token }}
80+
VERSION: ${{ inputs.version }}
81+
run: |
82+
STAGING="/tmp/perfetto-${VERSION}-github-release"
83+
gh release upload "$VERSION" "$STAGING"/*.zip \
84+
--repo "${{ github.repository }}" --clobber
85+
86+
- name: Publish release
87+
env:
88+
GH_TOKEN: ${{ github.token }}
89+
VERSION: ${{ inputs.version }}
90+
run: |
91+
gh release edit "$VERSION" \
92+
--repo "${{ github.repository }}" --draft=false

tools/release/package-github-release-artifacts

Lines changed: 163 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,161 @@ All files will be placed into /tmp/perfetto-v20.0-github-release/ .
2727
"""
2828

2929
import argparse
30-
import subprocess
3130
import os
31+
import subprocess
3232
import sys
33-
import tempfile
34-
import shutil
33+
34+
# Expected LUCI artifact manifest. Must stay in sync with
35+
# infra/luci/recipes/perfetto.py — ARTIFACTS + the platform list in
36+
# RunSteps. Verified after rsync; a mismatch (missing OR extra files) is a
37+
# hard error because it means LUCI's output changed and this script needs to
38+
# be updated.
39+
#
40+
# On Windows, each binary is accompanied by a <name>.pdb (debug symbols),
41+
# expressed as 'windows_pdb': True per artifact. Platform filters match the
42+
# 'exclude_platforms' / 'include_platforms' keys in the recipe.
43+
_ARTIFACTS = [
44+
{
45+
'name': 'trace_processor_shell'
46+
},
47+
{
48+
'name': 'traceconv'
49+
},
50+
{
51+
'name': 'tracebox',
52+
'exclude_platforms': ['windows-amd64'],
53+
},
54+
{
55+
'name': 'perfetto'
56+
},
57+
{
58+
'name': 'traced'
59+
},
60+
{
61+
'name': 'traced_probes',
62+
'exclude_platforms': ['windows-amd64'],
63+
},
64+
{
65+
'name': 'heapprofd_glibc_preload',
66+
'file': 'libheapprofd_glibc_preload.so',
67+
'include_platforms': ['linux-amd64', 'linux-arm', 'linux-arm64'],
68+
},
69+
]
70+
71+
_PLATFORMS = [
72+
'android-arm',
73+
'android-arm64',
74+
'android-x64',
75+
'android-x86',
76+
'linux-amd64',
77+
'linux-arm',
78+
'linux-arm64',
79+
'mac-amd64',
80+
'mac-arm64',
81+
'windows-amd64',
82+
]
83+
84+
_SDK_ZIPS = [
85+
'perfetto-cpp-sdk-src.zip',
86+
'perfetto-c-sdk-src.zip',
87+
]
3588

3689

3790
def exec(*args):
3891
print(' '.join(args))
3992
subprocess.check_call(args)
4093

4194

42-
def get_repo_root():
43-
"""Returns the root directory of the Perfetto repository."""
44-
script_dir = os.path.dirname(os.path.abspath(__file__))
45-
return os.path.abspath(os.path.join(script_dir, '..', '..'))
95+
def _artifact_filename(artifact, platform):
96+
"""Returns the on-disk filename LUCI uploads for `artifact` on `platform`."""
97+
base = artifact.get('file', artifact['name'])
98+
if platform == 'windows-amd64' and 'file' not in artifact:
99+
return base + '.exe'
100+
return base
101+
46102

103+
def _expected_manifest():
104+
"""Returns {platform: set(filenames)} for all LUCI-produced binaries."""
105+
manifest = {p: set() for p in _PLATFORMS}
106+
for platform in _PLATFORMS:
107+
for artifact in _ARTIFACTS:
108+
if platform in artifact.get('exclude_platforms', []):
109+
continue
110+
include = artifact.get('include_platforms')
111+
if include is not None and platform not in include:
112+
continue
113+
fname = _artifact_filename(artifact, platform)
114+
manifest[platform].add(fname)
115+
if platform == 'windows-amd64':
116+
manifest[platform].add(fname + '.pdb')
117+
return manifest
118+
119+
120+
def verify_downloads(tmpdir):
121+
"""Verifies the rsynced tree matches the expected LUCI manifest.
122+
123+
Fails loudly on anything missing or unexpected — the manifest here must
124+
match LUCI, and drift in either direction should surface immediately.
125+
"""
126+
manifest = _expected_manifest()
127+
expected_dirs = set(manifest.keys()) | {'sdk'}
128+
actual_dirs = {
129+
d for d in os.listdir(tmpdir) if os.path.isdir(os.path.join(tmpdir, d))
130+
}
131+
132+
errors = []
133+
missing_dirs = expected_dirs - actual_dirs
134+
unexpected_dirs = actual_dirs - expected_dirs
135+
if missing_dirs:
136+
errors.append('Missing platform directories: %s' %
137+
', '.join(sorted(missing_dirs)))
138+
if unexpected_dirs:
139+
errors.append('Unexpected directories under GCS path: %s' %
140+
', '.join(sorted(unexpected_dirs)))
141+
142+
for platform, expected_files in manifest.items():
143+
pdir = os.path.join(tmpdir, platform)
144+
if not os.path.isdir(pdir):
145+
continue
146+
actual_files = set(os.listdir(pdir))
147+
missing = expected_files - actual_files
148+
extra = actual_files - expected_files
149+
if missing:
150+
errors.append('%s: missing binaries: %s' %
151+
(platform, ', '.join(sorted(missing))))
152+
if extra:
153+
errors.append('%s: unexpected binaries: %s' %
154+
(platform, ', '.join(sorted(extra))))
47155

48-
def verify_git_state(expected_version):
156+
sdk_dir = os.path.join(tmpdir, 'sdk')
157+
if os.path.isdir(sdk_dir):
158+
actual_sdk = set(os.listdir(sdk_dir))
159+
expected_sdk = set(_SDK_ZIPS)
160+
missing_sdk = expected_sdk - actual_sdk
161+
extra_sdk = actual_sdk - expected_sdk
162+
if missing_sdk:
163+
errors.append('sdk: missing zips: %s' % ', '.join(sorted(missing_sdk)))
164+
if extra_sdk:
165+
errors.append('sdk: unexpected files: %s' % ', '.join(sorted(extra_sdk)))
166+
167+
if errors:
168+
print('\n'.join('ERROR: ' + e for e in errors), file=sys.stderr)
169+
print(
170+
'\nThe LUCI artifact manifest in this script is out of sync with '
171+
'infra/luci/recipes/perfetto.py. Update _ARTIFACTS / _PLATFORMS / '
172+
'_SDK_ZIPS above and re-run.',
173+
file=sys.stderr)
174+
return False
175+
176+
print('✓ All expected LUCI artifacts present across %d platforms + sdk.' %
177+
len(_PLATFORMS))
178+
return True
179+
180+
181+
def verify_git_state(expected_version, assume_yes=False):
49182
"""Verifies git is on the correct tag with no uncommitted changes."""
50183
warnings = []
51184

52-
# Check for uncommitted changes
53185
try:
54186
result = subprocess.run(['git', 'status', '--porcelain'],
55187
capture_output=True,
@@ -60,7 +192,6 @@ def verify_git_state(expected_version):
60192
except Exception as e:
61193
warnings.append(f'Could not check git status: {e}')
62194

63-
# Check current tag
64195
try:
65196
result = subprocess.run(['git', 'describe', '--exact-match', '--tags'],
66197
capture_output=True,
@@ -79,63 +210,60 @@ def verify_git_state(expected_version):
79210
print('WARNING: SDK sources may not match the release tag:')
80211
for warning in warnings:
81212
print(f' - {warning}')
213+
if assume_yes:
214+
print('\n--yes passed; proceeding despite warnings.')
215+
return True
82216
return input('\nContinue anyway? [y/N] ').lower().strip() in ['y', 'yes']
83217

84218
print(f'✓ On tag {expected_version} with clean working directory')
85219
return True
86220

87221

88-
def download_sdk_sources(tmpdir, version):
89-
"""Downloads SDK source zips from GCS."""
90-
sdk_zips = [
91-
'perfetto-cpp-sdk-src.zip',
92-
'perfetto-c-sdk-src.zip',
93-
]
94-
95-
print('\n--- Downloading SDK amalgamated sources from GCS ---')
96-
for zip_name in sdk_zips:
97-
url = f'gs://perfetto-luci-artifacts/{version}/sdk/{zip_name}'
98-
exec('gsutil', 'cp', url, os.path.join(tmpdir, zip_name))
99-
100-
return sdk_zips
101-
102-
103222
def main():
104223
parser = argparse.ArgumentParser(epilog='Example: %s v19.0' % __file__)
105224
parser.add_argument('version', help='Version tag (e.g., v20.0)')
106-
225+
parser.add_argument(
226+
'--yes',
227+
action='store_true',
228+
help='Skip all interactive confirmations (for CI use).')
107229
args = parser.parse_args()
108230

109-
# Verify we're on the correct tag with no uncommitted changes
110-
if not verify_git_state(args.version):
231+
if not verify_git_state(args.version, assume_yes=args.yes):
111232
print('Aborted.')
112233
return 1
113234

114235
tmpdir = '/tmp/perfetto-%s-github-release' % args.version
115236
src = 'gs://perfetto-luci-artifacts/%s/' % args.version
116237
os.makedirs(tmpdir, exist_ok=True)
117238

118-
# Download and package prebuilts
119239
print('--- Downloading prebuilts from GCS ---')
120240
os.chdir(tmpdir)
121241
exec('gsutil', '-m', 'rsync', '-rc', src, tmpdir + '/')
122242

243+
print('\n--- Verifying artifact manifest ---')
244+
if not verify_downloads(tmpdir):
245+
return 1
246+
123247
zips = []
124-
for arch in os.listdir(tmpdir):
248+
for arch in sorted(os.listdir(tmpdir)):
125249
if arch == 'sdk' or not os.path.isdir(os.path.join(tmpdir, arch)):
126250
continue
127251
exec('zip', '-9r', '%s.zip' % arch, arch)
128252
zips.append(arch + '.zip')
129253

130-
# Download SDK source zips
131-
sdk_zips = download_sdk_sources(tmpdir, args.version)
132-
zips.extend(sdk_zips)
254+
# SDK zips already landed under sdk/ via the rsync above; just move them
255+
# up so everything ready to upload sits in tmpdir's root.
256+
for zip_name in _SDK_ZIPS:
257+
src_path = os.path.join(tmpdir, 'sdk', zip_name)
258+
dst_path = os.path.join(tmpdir, zip_name)
259+
os.rename(src_path, dst_path)
260+
zips.append(zip_name)
133261

134262
print('')
135263
print('=' * 70)
136264
print('%d zip files saved in %s' % (len(zips), tmpdir))
137-
print('Prebuilt binaries: %d' % (len(zips) - len(sdk_zips)))
138-
print('SDK sources: %d' % len(sdk_zips))
265+
print('Prebuilt binaries: %d' % (len(zips) - len(_SDK_ZIPS)))
266+
print('SDK sources: %d' % len(_SDK_ZIPS))
139267
print('Files: %s' % ', '.join(sorted(zips)))
140268
print('=' * 70)
141269

0 commit comments

Comments
 (0)