diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26687cb..eb3b6ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,10 @@ jobs: - uses: gradle/actions/setup-gradle@v6 with: gradle-version: "9.8.1" - - name: Compile and run tests - run: gradle --no-daemon --stacktrace :launcher-core:test :launcher-desktop:compileKotlin + - name: Compile and run all tests + run: gradle --no-daemon --stacktrace test build + - name: Test Build Explorer publisher + run: python3 -m unittest discover -s tests -p 'test_*.py' - name: Upload test evidence if: always() uses: actions/upload-artifact@v7 @@ -97,3 +99,24 @@ jobs: path: syntaxlauncher-${{ matrix.artifact }}.tar.gz if-no-files-found: error retention-days: 14 + + + publish-build-explorer: + name: Publish all platforms to Build Explorer + needs: [verify, distributables] + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + pattern: syntaxlauncher-*-${{ github.sha }} + path: dist + merge-multiple: true + - name: Publish complete portable build set + env: + BUILD_EXPLORER_TOKEN: ${{ secrets.BUILD_EXPLORER_TOKEN }} + run: python3 scripts/publish_build_explorer.py diff --git a/.gitignore b/.gitignore index 43c1853..e47f562 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ cache/ # Local environment .env *.local + +# Python publisher validation +__pycache__/ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8fe432a..7d1ecec 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -314,3 +314,31 @@ Before merging or finishing a task: - [ ] `docs/ROADMAP.md` updated precisely. - [ ] `docs/WORKLOG.md` entry appended. - [ ] Remaining limitations/follow-ups documented. + + +## Build Explorer publication + +The existing build workflow verifies both core and desktop tests and builds portable +applications for Windows x64, macOS ARM64 and Linux x64. A dependent Linux job +publishes all three archives to the `syntaxlauncher` project only after the complete +matrix succeeds. Pull requests never publish or receive the publishing secret. + +Repository secret: `BUILD_EXPLORER_TOKEN`. The server stores only its SHA-256 hash +and limits it to this project, application formats and 90 MiB per file. It cannot +manage retention or publish to other projects. Rotate with the miniPORTAL CLI +`bin/issue-build-ci-token.php syntaxlauncher NEW_SECRET_FILE --rotate`, replace +the repository secret and remove the transfer file. Never commit the token. + +Pushes to `main` publish Snapshot builds using the Gradle product version. Tags +`vX.Y.Z` publish Release builds; prerelease tags publish Snapshot builds. The shared +GitHub run ID groups platforms into one release; rerunning the same run replaces +its platform artifacts. Publication is serial, validates all three files first, +and includes SHA-256, size, commit and OS/architecture metadata. Server-side +visibility is immediate for each successfully uploaded file; a network failure +midway can leave a partial release until the job is rerun. + +Run `python3 -m unittest discover -s tests -p 'test_*.py'` for publisher tests. +For metadata-only checks, download the three workflow archives into `dist/` and +run `python3 scripts/publish_build_explorer.py --dry-run` with the standard GitHub +run environment. Native installer signing and clean-machine gameplay acceptance +remain separate roadmap items. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4210412..e74341d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -172,6 +172,8 @@ Goal: launch a selected vanilla Minecraft version using an offline account and a ## Build and CI validation +- [ ] 🚧 IN PROGRESS — publish all three portable applications to Build Explorer v2 using a project-scoped CI secret; implementation and local publisher tests pass, real publication pending. + - [x] GitHub Actions run 39125171338 passed `:launcher-core:test` and desktop Kotlin compilation with pinned Gradle 9.4.0 and Temurin JDK 21 on Linux. - [x] GitHub Actions run 39125171338 built Compose portable app images for Linux x64, Windows x64 and macOS ARM64, and published permission-preserving platform tar.gz preview artifacts. - [x] Official Gradle wrapper 9.4.0 (`gradlew`, `gradlew.bat`, properties, official JAR) generated, executed and committed by a restricted bootstrap GitHub Actions workflow (03d2650). diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 755c099..0399678 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -8,6 +8,39 @@ Every code/build/documentation/CI task must add an entry according to `AGENTS.md --- +## 2026-10-19 — Publish portable applications to Build Explorer + +**Milestone:** v0.1 — Build and CI validation (explicit maintainer request) +**Author/Agent:** Codex +**Status:** Partial — local verification passed; cross-platform publication pending + +### Completed +- Extended the existing three-platform workflow with a dependent publication job. +- Included desktop tests in the verification gate; pull requests never publish. +- Added serial uploads with integrity metadata, shared run identity and strict validation of the complete file set. +- Provisioned the portal project and a repository secret with project-scoped server permissions. + +### Changed files +- `.github/workflows/build.yml` — full tests and publication after the complete platform matrix. +- `scripts/publish_build_explorer.py` — bounded, authenticated v2 publisher. +- `tests/test_build_explorer_publish.py` — grouping, channel, integrity, missing/corrupt/oversized input tests. +- `docs/DEVELOPMENT.md`, `docs/ROADMAP.md`, `docs/WORKLOG.md` — operation and truthful progress. + +### Verification +- `./gradlew test --console=plain build` — PASS; existing compiler/deprecation warnings. +- `python3 -m unittest discover -s tests -p 'test_*.py'` — PASS, five tests. +- Manual verification — no UI changes; real CI publication pending. + +### Roadmap updates +- `[ ] v0.1 / Build and CI validation / Build Explorer publication` — real matrix/publication pending. +- Existing native OS acceptance, installer signing and gameplay gates remain open. + +### Follow-ups / known issues +- No automatic launcher self-update or native installer work is included. +- Each upload becomes visible individually; interrupted publication is recovered by rerunning its CI job. + +--- + ## 2026-10-19 — Make the launcher logo respond to measured layout space **Milestone:** v0.1 — UI visual foundation diff --git a/scripts/publish_build_explorer.py b/scripts/publish_build_explorer.py new file mode 100644 index 0000000..0da075f --- /dev/null +++ b/scripts/publish_build_explorer.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Publish the three tested portable applications as one Build Explorer v2 release.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + +PLATFORMS = ('windows-x64', 'macos-arm64', 'linux-x64') +MAX_BYTES = 90 * 1024 * 1024 +ENDPOINT = 'https://syntaxdevteam.pl/api/v2/builds/ci/syntaxlauncher' + + +def prepare(directory, env): + run_id = env['GITHUB_RUN_ID'] + run_number = env['GITHUB_RUN_NUMBER'] + sha = env['GITHUB_SHA'] + if not run_id.isdecimal() or int(run_id) < 1 or not run_number.isdecimal(): + raise ValueError('Invalid workflow run identity') + if not re.fullmatch('[a-f0-9]{40}', sha): + raise ValueError('Expected full source commit SHA') + ref = env['GITHUB_REF'] + if ref.startswith('refs/tags/v'): + version = ref.removeprefix('refs/tags/v') + if not re.fullmatch(r'\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', version): + raise ValueError('Tag must be a v-prefixed semantic version') + channel = 'snapshot' if '-' in version else 'release' + elif ref == 'refs/heads/main': + match = re.search(r'version\s*=\s*"([A-Za-z0-9.+-]+)"', Path('build.gradle.kts').read_text()) + if not match: + raise ValueError('Cannot read launcher product version') + version, channel = match.group(1), 'snapshot' + else: + raise ValueError('Publication is restricted to main and version tags') + timestamp = datetime.now(timezone.utc).isoformat() + result = [] + for platform in PLATFORMS: + path = directory / f'syntaxlauncher-{platform}.tar.gz' + if not path.is_file() or path.is_symlink(): + raise ValueError(f'Missing portable application: {path.name}') + size = path.stat().st_size + if not 0 < size <= MAX_BYTES: + raise ValueError(f'{path.name} exceeds the 90 MiB project limit or is empty') + digest = hashlib.sha256() + with path.open('rb') as stream: + if stream.read(3) != b'\x1f\x8b\x08': + raise ValueError(f'{path.name} is not gzip') + stream.seek(0) + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + result.append((path, { + 'id': int(run_id), 'time': timestamp, 'channel': channel, + 'platform': platform, 'platform_family': 'other', 'game_versions': [], + 'version': version, 'build_number': run_number, + 'branch': env.get('GITHUB_REF_NAME', ''), 'commit_sha': sha, + 'filename': f'SyntaxLauncher-{version}-{platform}.tar.gz', + 'sha256': digest.hexdigest(), 'size': size, 'commits': [], + })) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--directory', type=Path, default=Path('dist')) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + releases = prepare(args.directory, os.environ) # Validate every file before the first upload. + if args.dry_run: + print(json.dumps([metadata for _, metadata in releases], indent=2)) + return + token = os.environ.get('BUILD_EXPLORER_TOKEN', '') + if not re.fullmatch('[a-f0-9]{64}', token): + raise ValueError('BUILD_EXPLORER_TOKEN secret is missing or malformed') + with tempfile.TemporaryDirectory(prefix='build-explorer-') as temporary: + for path, metadata in releases: + metadata_file = Path(temporary) / 'metadata.json' + metadata_file.write_text(json.dumps(metadata)) + response = subprocess.run([ + 'curl', '--config', '-', '--silent', '--show-error', '--fail-with-body', + '--proto', '=https', '--connect-timeout', '30', '--max-time', '600', + '--retry', '3', '--retry-delay', '5', + '--form', f'metadata=@{metadata_file};type=application/json', + '--form', f'artifact=@{path};type=application/gzip', ENDPOINT, + ], input=f'header = "X-Build-Token: {token}"\n', text=True, capture_output=True, check=True) + result = json.loads(response.stdout) + if result.get('status') != 'ok' or result.get('platform') != metadata['platform']: + raise ValueError('Build Explorer did not confirm publication') + print(f"Published {metadata['platform']}: release {result['release_id']}, artifact {result['artifact_id']}") + + +if __name__ == '__main__': + main() diff --git a/tests/test_build_explorer_publish.py b/tests/test_build_explorer_publish.py new file mode 100644 index 0000000..0a169de --- /dev/null +++ b/tests/test_build_explorer_publish.py @@ -0,0 +1,60 @@ +import gzip +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('publisher', 'scripts/publish_build_explorer.py') +publisher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publisher) + + +class PublicationTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.env = {'GITHUB_RUN_ID': '123', 'GITHUB_RUN_NUMBER': '4', 'GITHUB_SHA': 'a' * 40, + 'GITHUB_REF': 'refs/heads/main', 'GITHUB_REF_NAME': 'main'} + for platform in publisher.PLATFORMS: + (self.directory / f'syntaxlauncher-{platform}.tar.gz').write_bytes(gzip.compress(b'test fixture')) + + def test_three_platforms_share_release_and_include_integrity(self): + releases = publisher.prepare(self.directory, self.env) + self.assertEqual(3, len(releases)) + self.assertEqual({123}, {metadata['id'] for _, metadata in releases}) + for path, metadata in releases: + self.assertEqual('other', metadata['platform_family']) + self.assertEqual('snapshot', metadata['channel']) + self.assertEqual(publisher.hashlib.sha256(path.read_bytes()).hexdigest(), metadata['sha256']) + self.assertEqual(path.stat().st_size, metadata['size']) + + def test_release_tag(self): + self.env['GITHUB_REF'] = 'refs/tags/v0.3.0' + self.assertEqual('release', publisher.prepare(self.directory, self.env)[0][1]['channel']) + self.assertEqual('0.3.0', publisher.prepare(self.directory, self.env)[0][1]['version']) + + def test_missing_platform_rejects_whole_batch(self): + next(self.directory.iterdir()).unlink() + with self.assertRaises(ValueError): + publisher.prepare(self.directory, self.env) + + def test_untrusted_branch_and_invalid_format_rejected(self): + self.env['GITHUB_REF'] = 'refs/pull/10/merge' + with self.assertRaises(ValueError): + publisher.prepare(self.directory, self.env) + self.env['GITHUB_REF'] = 'refs/heads/main' + next(self.directory.iterdir()).write_bytes(b'not gzip') + with self.assertRaises(ValueError): + publisher.prepare(self.directory, self.env) + + def test_size_limit(self): + path = next(self.directory.iterdir()) + with path.open('wb') as stream: + stream.truncate(publisher.MAX_BYTES + 1) + with self.assertRaises(ValueError): + publisher.prepare(self.directory, self.env) + + +if __name__ == '__main__': + unittest.main()