Add gated source releases and archive integration tests

This commit is contained in:
ZacharyZcR
2026-09-10 22:59:29 +08:00
parent 07c4e5222f
commit 0c7106ea33
7 changed files with 200 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
baseline: ${{ steps.version.outputs.baseline }}
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
- name: Validate version and development ancestry
id: version
env:
TAG: ${{ github.ref_name }}
run: |
python scripts/build_release.py "$TAG" --output dist
git merge-base --is-ancestor HEAD origin/dev-0.1.0
echo "baseline=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT"
checks:
needs: prepare
uses: ./.github/workflows/tests.yml
with:
baseline_sha: ${{ needs.prepare.outputs.baseline }}
publish:
needs: checks
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.12'
- name: Build release assets
env:
TAG: ${{ github.ref_name }}
run: python scripts/build_release.py "$TAG" --output dist
- name: Publish validated source release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
if gh release view "$TAG" --json isDraft > release.json; then
python -c 'import json; assert json.load(open("release.json"))["isDraft"], "Refusing to overwrite published release"'
else
gh release create "$TAG" --verify-tag --draft --title "ASC $TAG" --generate-notes
fi
gh release upload "$TAG" dist/* --clobber
if [[ "$TAG" == *-* ]]; then
gh release edit "$TAG" --draft=false --prerelease
else
gh release edit "$TAG" --draft=false --prerelease=false
fi
+21 -2
View File
@@ -1,5 +1,14 @@
name: Tests
on: [push, pull_request, workflow_dispatch]
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
workflow_call:
inputs:
baseline_sha:
required: true
type: string
permissions:
contents: read
jobs:
@@ -16,7 +25,7 @@ jobs:
uses: actions/checkout@v7.0.1
with:
repository: ${{ github.event.pull_request.base.repo.full_name || (github.event_name == 'push' && github.ref_name == 'dev-0.1.0' && github.repository) || 'MG1937/ASC' }}
ref: ${{ github.event.pull_request.base.sha || (github.event_name == 'push' && github.ref_name == 'dev-0.1.0' && github.event.before) || 'dev-0.1.0' }}
ref: ${{ inputs.baseline_sha || github.event.pull_request.base.sha || (github.event_name == 'push' && github.ref_name == 'dev-0.1.0' && github.event.before) || 'dev-0.1.0' }}
path: .performance-base
- uses: actions/setup-python@v7.0.0
with:
@@ -44,3 +53,13 @@ jobs:
artifacts/reference/
artifacts/comparison/
if-no-files-found: warn
- name: Build source release preview
if: matrix.python-version == '3.12'
run: python scripts/build_release.py v0.1.0 --output dist
- name: Upload source release preview
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7.0.1
with:
name: source-release-preview
path: dist/
if-no-files-found: error
+1
View File
@@ -18,3 +18,4 @@ artifacts/reference/
.performance-base/
artifacts/comparison/
dist/
+35
View File
@@ -0,0 +1,35 @@
# Source releases
Python 3.11 or 3.12 is required. Extract `ASC-vX.Y.Z-source.zip`, enter the
extracted directory, and run:
```sh
python -m pip install -r requirements.txt
python main.py --help
python main.py getclass app.apk com.example.Main --debug
python main.py findrefs app.apk string token
python main.py app.apk --gui
```
The GUI additionally needs Tk (on Debian/Ubuntu: `python3-tk`). Dependencies
are installed separately; the archive is not a standalone executable.
`SHA256SUMS` verifies the downloaded ZIP; it does not change DEX signatures.
## Maintainers
Merge the PR into `dev-0.1.0` first, then tag the intended commit with
`vMAJOR.MINOR.PATCH` (or `-alpha.N`, `-beta.N`, `-rc.N`) and push that tag.
The Release workflow rejects tags outside the development branch and compares
performance against the tagged commit's first parent, by immutable SHA.
All unit/integration tests, paired performance checks, and absolute budgets
must pass on Python 3.11 and 3.12 before publication. A failed gate publishes
nothing. Prerelease tags create GitHub prereleases.
PR CI also builds the source package and tests its CLI after extraction outside
the checkout. Only tracked runtime files, requirements, README and docs enter
the deterministic ZIP; test DEX/APK fixtures and local scripts are excluded.
Local packaging: `python scripts/build_release.py v0.1.0 --output dist`.
The workflow creates a draft, uploads the ZIP and SHA256SUMS, then publishes it.
If upload fails, only a draft remains; rerunning replaces draft assets.
Published releases are never overwritten automatically.
+36
View File
@@ -0,0 +1,36 @@
"""Build a source distribution from tracked runtime files."""
import argparse
import hashlib
from pathlib import Path
import re
import subprocess
import zipfile
ROOT = Path(__file__).resolve().parents[1]
def build(version, output):
if not re.fullmatch(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc)\.[1-9]\d*)?', version):
raise ValueError('expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N (also alpha/beta)')
paths = subprocess.check_output(
['git', 'ls-files', '-z', '--', 'main.py', 'requirements.txt', 'README.md', 'src', 'docs'],
cwd=ROOT).decode().split('\0')
output.mkdir(parents=True, exist_ok=True)
archive = output / f'ASC-{version}-source.zip'
with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED) as package:
for path in sorted(filter(None, paths)):
info = zipfile.ZipInfo(f'ASC-{version}/{path}')
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
package.writestr(info, (ROOT / path).read_bytes())
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
(output / 'SHA256SUMS').write_text(f'{digest} {archive.name}\n', encoding='ascii')
return archive
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('version')
parser.add_argument('--output', type=Path, default=Path('dist'))
args = parser.parse_args()
print(build(args.version, args.output))
+2 -1
View File
@@ -24,7 +24,8 @@ if __name__ == '__main__':
else:
yield item
suite = unittest.TestSuite(test for test in tests(suite)
if ('test_decompiler.DecompilerTests.' in test.id())
if ('test_decompiler.DecompilerTests.' in test.id()
or test.id().endswith('test_archive_is_reproducible_and_runs_outside_checkout'))
== (args.suite == 'integration'))
if suite.countTestCases() == 0:
parser.error('selected suite contains no tests')
+43
View File
@@ -0,0 +1,43 @@
import hashlib
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import zipfile
from dex_fixture import make_dex
from scripts.build_release import build
class ReleaseTests(unittest.TestCase):
def test_invalid_version_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
for version in ('0.1.0', '../v0.1.0', 'v01.0.0', 'v0.1.0-rc.0'):
with self.subTest(version=version), self.assertRaises(ValueError):
build(version, Path(directory))
def test_archive_is_reproducible_and_runs_outside_checkout(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive = build('v0.1.0-rc.1', root)
original = archive.read_bytes()
self.assertEqual(build('v0.1.0-rc.1', root).read_bytes(), original)
self.assertEqual((root / 'SHA256SUMS').read_text().split()[0],
hashlib.sha256(original).hexdigest())
with zipfile.ZipFile(archive) as package:
names = package.namelist()
self.assertTrue(any(name.endswith('/requirements.txt') for name in names))
self.assertFalse(any(name.endswith(('.dex', '.apk', '/test.py', '/test_findrefs.py'))
or '/tests/' in name or '/.git/' in name for name in names))
package.extractall(root)
app = root / 'ASC-v0.1.0-rc.1'
apk = root / 'fixture.apk'
with zipfile.ZipFile(apk, 'w', compression=zipfile.ZIP_DEFLATED) as package:
package.writestr('classes.dex', make_dex())
for args, expected in ((['findrefs', str(apk), 'string', 'token'], 'token'),
(['getclass', str(apk), 'example.Test'], 'class Test')):
result = subprocess.run([sys.executable, str(app / 'main.py'), *args],
cwd=root, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(expected, result.stdout)