Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ cache/
# Local environment
.env
*.local

# Python publisher validation
__pycache__/
28 changes: 28 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
33 changes: 33 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions scripts/publish_build_explorer.py
Original file line number Diff line number Diff line change
@@ -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()
60 changes: 60 additions & 0 deletions tests/test_build_explorer_publish.py
Original file line number Diff line number Diff line change
@@ -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()
Loading