diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d8d301 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e99f9c5..211fe7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index f45b247..e49dde8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ artifacts/reference/ .performance-base/ artifacts/comparison/ +dist/ diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..686d8ae --- /dev/null +++ b/docs/RELEASING.md @@ -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. diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..9eea4bf --- /dev/null +++ b/scripts/build_release.py @@ -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)) diff --git a/tests/run_tests.py b/tests/run_tests.py index 7c065f1..9bd6b54 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -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') diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..218324f --- /dev/null +++ b/tests/test_release.py @@ -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)