mirror of
https://github.com/MG1937/ASC.git
synced 2026-10-02 00:25:28 +08:00
Add gated source releases and archive integration tests
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -18,3 +18,4 @@ artifacts/reference/
|
||||
|
||||
.performance-base/
|
||||
artifacts/comparison/
|
||||
dist/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user