diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml new file mode 100644 index 0000000..bed5412 --- /dev/null +++ b/.github/actions/setup-build/action.yml @@ -0,0 +1,21 @@ +name: Set up preview build tools +description: Use Node 24 and a checksum-verified XcodeGen binary. +runs: + using: composite + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install XcodeGen 2.46.0 + shell: bash + run: | + tool_directory="$RUNNER_TEMP/xcodegen" + mkdir -p "$tool_directory" + curl --fail --location --retry 5 --connect-timeout 20 \ + https://github.com/yonaskolb/XcodeGen/releases/download/2.46.0/xcodegen.zip \ + -o "$tool_directory/xcodegen.zip" + echo '4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 xcodegen.zip' \ + | (cd "$tool_directory" && shasum -a 256 -c -) + ditto -x -k "$tool_directory/xcodegen.zip" "$tool_directory" + echo "$tool_directory/xcodegen/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..4a4556c --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,42 @@ +name: Validate Preview Build + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: preview-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + runs-on: macos-26 + timeout-minutes: 60 + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-build + - name: Test release recovery and version allocation + run: python3 -m unittest discover -s Scripts/tests -v + - name: Check shell syntax + run: bash -n Scripts/build_preview.sh + - name: Test the browser extension + run: npm --prefix BrowserExtension test + - name: Test the native app + run: | + xcodegen generate + xcodebuild -project SmartShot.xcodeproj -scheme SmartShot \ + -configuration Debug -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$RUNNER_TEMP/TestDerivedData" test \ + CODE_SIGNING_ALLOWED=NO REGISTER_WITH_LAUNCH_SERVICES=NO + - name: Build and verify packages without publishing + run: | + read -r version build < <(python3 Scripts/release_artifacts.py version .) + bash Scripts/build_preview.sh "$version" "$build" "$RUNNER_TEMP/preview-check" diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000..3d9a2ee --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,88 @@ +name: Publish Preview + +on: + pull_request_target: + branches: [main] + types: [closed] + workflow_dispatch: + inputs: + pull_request: + description: Number of the merged PR to release or retry + required: true + type: string + +permissions: + contents: read + +# Preserve pending merges; GitHub's default queue keeps only one pending run. +concurrency: + group: smartshot-preview-release + queue: max + +env: + PYTHONDONTWRITEBYTECODE: '1' + +jobs: + publish: + if: >- + (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + runs-on: macos-26 + timeout-minutes: 60 + permissions: + contents: write + pull-requests: read + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request }} + steps: + # This is the trusted base revision. Never check out an unmerged PR head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set temporary paths + run: | + echo "SOURCE_DIRECTORY=$RUNNER_TEMP/release-source" >> "$GITHUB_ENV" + echo "PLAN_FILE=$RUNNER_TEMP/release-plan.json" >> "$GITHUB_ENV" + echo "ASSET_DIRECTORY=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV" + - name: Resolve and validate the merged PR + id: merged + env: + GH_TOKEN: ${{ github.token }} + run: python3 Scripts/preview_release.py resolve --repository "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" + - name: Check out the exact merged source + env: + MERGE_COMMIT: ${{ steps.merged.outputs.commit }} + run: git worktree add --detach "$SOURCE_DIRECTORY" "$MERGE_COMMIT" + - uses: ./.github/actions/setup-build + - name: Test release recovery and version allocation + run: python3 -m unittest discover -s Scripts/tests -v + - name: Test the browser extension + run: npm --prefix "$SOURCE_DIRECTORY/BrowserExtension" test + - name: Test the native app + run: | + (cd "$SOURCE_DIRECTORY" && xcodegen generate) + xcodebuild -project "$SOURCE_DIRECTORY/SmartShot.xcodeproj" -scheme SmartShot \ + -configuration Debug -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$RUNNER_TEMP/TestDerivedData" test \ + CODE_SIGNING_ALLOWED=NO REGISTER_WITH_LAUNCH_SERVICES=NO + - name: Reserve or reuse a preview version + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 Scripts/preview_release.py reserve --repository "$GITHUB_REPOSITORY" \ + --pr "$PR_NUMBER" --source "$SOURCE_DIRECTORY" --plan "$PLAN_FILE" + - name: Build and verify universal packages + if: steps.release.outputs.needs_build == 'true' + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_BUILD: ${{ steps.release.outputs.build }} + run: bash Scripts/build_preview.sh "$RELEASE_VERSION" "$RELEASE_BUILD" "$ASSET_DIRECTORY" "$SOURCE_DIRECTORY" + - name: Upload all assets and publish the preview + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 Scripts/preview_release.py publish --repository "$GITHUB_REPOSITORY" \ + --plan "$PLAN_FILE" --assets "$ASSET_DIRECTORY" diff --git a/Docs/PREVIEW_RELEASES.md b/Docs/PREVIEW_RELEASES.md new file mode 100644 index 0000000..6e4fd85 --- /dev/null +++ b/Docs/PREVIEW_RELEASES.md @@ -0,0 +1,45 @@ +# Automatic preview releases + +Every PR merged into `main` triggers **Publish Preview**. Closing an unmerged PR or pushing directly to `main` does not publish a release. **Validate Preview Build** runs the tests and packaging on PRs with read-only permissions, without creating tags or releases. + +The release workflow uses `pull_request_target: closed` so merged contributions from forks can also publish. It runs only after merge, checks the PR's repository, base branch and merge commit against `main`, and builds that exact commit in a separate worktree. It never builds an unmerged PR head with a write token. Manual runs are accepted only from `main`. + +## Pipeline + +1. Run release-script tests, browser extension tests, and native Swift tests on `macos-26` with Xcode 26.6, Node 24 and XcodeGen 2.46.0. +2. Reserve a version in a draft release. Starting after `v0.2.3`, the next version is `v0.2.4`; subsequent releases increase the patch number. Existing tags and draft releases are included when allocating versions. A higher `MARKETING_VERSION` in `project.yml` starts a new version line. Build numbers increase from the source value or the highest automated release's build number. +3. Copy sources to a temporary directory and set the same version in `project.yml`, the browser manifest and its package metadata. Generate the Xcode project, then build the app, Safari extension and both command-line helpers for `arm64` and `x86_64`. +4. Verify the architecture of all four executables, nested signatures, app/extension versions, hardened runtime, required entitlements, and the absence of `get-task-allow`. Create and verify the DMG, app ZIP, browser extension ZIP and `SHA256SUMS.txt`. +5. Create the version tag at the PR's merge commit, upload all four assets to the draft, check their sizes and available server digests, then publish it as a prerelease. Notes are generated from the previous published preview. + +The workflow uses the repository's `GITHUB_TOKEN` with `contents: write` and `pull-requests: read`; no personal access token or signing certificate is required. GitHub Actions must be enabled and repository/organization policies must permit these permissions and release tag creation. No source version commit is pushed to `main`: the release tag and its hidden metadata record the source commit, final version and build number. Versions in the checkout remain defaults for local development. + +Releases remain ad-hoc signed previews, without Developer ID signing or notarization. They do not use the Safari development build script or its debugging entitlements. Publishing a release does not install it on any developer's Mac. + +## Queue and recovery + +The shared concurrency group uses [GitHub's `queue: max`](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency), which allows up to 100 pending runs without replacing earlier pending merges. Each run allocates its version while holding that group. Versions follow reservation order; GitHub does not guarantee merge-event ordering. + +- A test failure creates no draft or tag. A build failure leaves a draft reservation, and an interrupted upload keeps the release private. Fix the failure, then rerun the workflow. +- A rerun finds the reservation by PR number, keeps its version/build and replaces incomplete draft assets. A complete published release is left intact. A tag pointing to another commit stops the run instead of being moved. +- In **Actions → Publish Preview → Run workflow**, select `main` and enter the merged PR number to retry a missed run. This also bootstraps the PR that first introduces the workflow if its merge event does not start a run. Manual runs use the current workflow and release scripts but build the specified PR's exact merged source. +- Use **Re-run all jobs** for a transient failure. To use a repaired release script after a later PR, use the manual entry point. Do not remove the `smartshot-release` metadata comment from a draft or published release; it is the retry record. Do not manually publish incomplete drafts or move release tags. + +Download previews from the [Releases page](https://github.com/infinityf4p/SmartShot/releases). GitHub's `/releases/latest` endpoint excludes prereleases. + +## Local validation + +With full Xcode, Node.js, Python 3.9+ and XcodeGen 2.46.0 installed: + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s Scripts/tests -v +npm --prefix BrowserExtension test +xcodegen generate +xcodebuild -project SmartShot.xcodeproj -scheme SmartShot \ + -configuration Debug -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath /tmp/SmartShotTests test \ + CODE_SIGNING_ALLOWED=NO REGISTER_WITH_LAUNCH_SERVICES=NO +bash Scripts/build_preview.sh 0.2.4 12 /tmp/SmartShotPreviewPackages +``` + +The packaging output directory must be empty. Packaging uses disposable sources and DerivedData, cleans them on exit, and leaves only the four distributable files. Remove the test DerivedData and output directory after validation. No app is launched or installed. The Python tests use a simulated GitHub API to exercise version allocation, retry recovery, checksum failures and tag conflicts without publishing anything. diff --git a/README.md b/README.md index 1ef686e..4fc43ac 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SmartShot combines smart block selection, region and long screenshots, an image [English](README.md) | [简体中文](README.zh-CN.md) -macOS 14+ · Swift 6 · Preview version 0.2.3 +macOS 14+ · Swift 6 · Preview releases ## Features @@ -25,12 +25,12 @@ macOS 14+ · Swift 6 · Preview version 0.2.3 ## Download -[SmartShot 0.2.3 Preview](https://github.com/infinityf4p/SmartShot/releases/tag/v0.2.3) supports Apple Silicon and Intel Macs in one universal application. +[Download the newest SmartShot Preview](https://github.com/infinityf4p/SmartShot/releases). Each release supports Apple Silicon and Intel Macs in one universal application. Choose its assets below: -- [macOS DMG](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-0.2.3-universal.dmg) -- [macOS ZIP](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-0.2.3-universal.zip) -- [Browser extension ZIP](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-Web-Selector-0.2.3.zip) -- [SHA-256 checksums](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SHA256SUMS.txt) +- macOS DMG: `SmartShot--universal.dmg` +- macOS ZIP: `SmartShot--universal.zip` +- Browser extension ZIP: `SmartShot-Web-Selector-.zip` +- SHA-256 checksums: `SHA256SUMS.txt` This preview is ad-hoc signed, without a Developer ID certificate or Apple notarization. macOS may block a downloaded copy until you explicitly approve it in Finder or Privacy & Security. The packaged app has no `get-task-allow` debugging entitlement. Safari development enablement and the pending acceptance cases below still apply. @@ -128,5 +128,6 @@ The destination above targets Apple Silicon. Use the appropriate macOS architect - [Test plan and evidence](Docs/TEST_PLAN.md) - [Browser extension](BrowserExtension/README.md) - [Safari development builds](Docs/SAFARI_DEVELOPMENT_BUILD.md) +- [Automatic preview releases](Docs/PREVIEW_RELEASES.md) Maintained by [infinityf4p](https://github.com/infinityf4p). diff --git a/README.zh-CN.md b/README.zh-CN.md index b769adc..2621975 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ SmartShot 将智能选区、区域截图、长截图、图片编辑、本地 OCR [English](README.md) | [简体中文](README.zh-CN.md) -macOS 14+ · Swift 6 · 预览版本 0.2.3 +macOS 14+ · Swift 6 · 预览版 ## 功能 @@ -25,12 +25,12 @@ macOS 14+ · Swift 6 · 预览版本 0.2.3 ## 下载 -[SmartShot 0.2.3 预览版](https://github.com/infinityf4p/SmartShot/releases/tag/v0.2.3) 提供同时包含 Apple Silicon 和 Intel 架构的通用应用。 +[下载最新 SmartShot 预览版](https://github.com/infinityf4p/SmartShot/releases)。每版均提供同时包含 Apple Silicon 和 Intel 架构的通用应用,请在对应版本的附件中选择: -- [macOS DMG](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-0.2.3-universal.dmg) -- [macOS ZIP](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-0.2.3-universal.zip) -- [浏览器扩展 ZIP](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SmartShot-Web-Selector-0.2.3.zip) -- [SHA-256 校验文件](https://github.com/infinityf4p/SmartShot/releases/download/v0.2.3/SHA256SUMS.txt) +- macOS DMG:`SmartShot--universal.dmg` +- macOS ZIP:`SmartShot--universal.zip` +- 浏览器扩展 ZIP:`SmartShot-Web-Selector-.zip` +- SHA-256 校验文件:`SHA256SUMS.txt` 该预览版使用 ad-hoc 签名,没有 Developer ID 证书或 Apple 公证。macOS 可能会阻止下载的应用,需要你在 Finder 或“隐私与安全性”中明确允许打开。安装包不含 `get-task-allow` 调试权限;Safari 开发启用条件及下方待验收范围仍然适用。 @@ -128,5 +128,6 @@ npm --prefix BrowserExtension test - [测试计划与记录](Docs/TEST_PLAN.md) - [浏览器扩展](BrowserExtension/README.md) - [Safari 开发构建](Docs/SAFARI_DEVELOPMENT_BUILD.md) +- [自动发布预览版](Docs/PREVIEW_RELEASES.md) 维护者:[infinityf4p](https://github.com/infinityf4p)。 diff --git a/Scripts/build_preview.sh b/Scripts/build_preview.sh new file mode 100755 index 0000000..69d346a --- /dev/null +++ b/Scripts/build_preview.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail + +# Builds in a disposable source copy; only the four packages survive in OUTPUT. +if [[ $# -lt 3 || $# -gt 4 ]]; then + echo "Usage: $0 VERSION BUILD OUTPUT [SOURCE]" >&2 + exit 1 +fi +script_directory="$(cd -- "$(dirname -- "$0")" && pwd -P)" +version="$1" +build="$2" +mkdir -p "$3" +output="$(cd -- "$3" && pwd -P)" +source_directory="${4:-$script_directory/..}" +if [[ -n "$(ls -A "$output")" ]]; then + echo "Output directory must be empty: $output" >&2 + exit 1 +fi +scratch="$(mktemp -d "${TMPDIR:-/private/tmp}/SmartShotPreview.XXXXXX")" +app="$scratch/DerivedData/Build/Products/Release/SmartShot.app" +cleanup() { + if [[ -d "$app" ]]; then + /usr/bin/pluginkit -r "$app/Contents/PlugIns/SmartShot Safari Extension.appex" >/dev/null 2>&1 || true + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -u "$app" >/dev/null 2>&1 || true + fi + rm -rf -- "$scratch" +} +trap cleanup EXIT + +python3 "$script_directory/release_artifacts.py" stage "$source_directory" \ + --destination "$scratch/source" --version "$version" --build "$build" +( + cd "$scratch/source" + xcodegen generate +) +xcodebuild -project "$scratch/source/SmartShot.xcodeproj" -scheme SmartShot \ + -configuration Release -destination 'generic/platform=macOS' \ + -derivedDataPath "$scratch/DerivedData" build \ + CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= \ + CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES AD_HOC_CODE_SIGNING_ALLOWED=YES \ + ENABLE_HARDENED_RUNTIME=YES REGISTER_WITH_LAUNCH_SERVICES=NO \ + ONLY_ACTIVE_ARCH=NO 'ARCHS=arm64 x86_64' +python3 "$script_directory/release_artifacts.py" verify "$app" --version "$version" --build "$build" + +ditto -c -k --sequesterRsrc --keepParent "$app" "$output/SmartShot-$version-universal.zip" +mkdir "$scratch/dmg" +ditto "$app" "$scratch/dmg/SmartShot.app" +ln -s /Applications "$scratch/dmg/Applications" +hdiutil create -volname "SmartShot $version" -srcfolder "$scratch/dmg" \ + -format UDZO "$output/SmartShot-$version-universal.dmg" +hdiutil verify "$output/SmartShot-$version-universal.dmg" + +mkdir "$scratch/extension" +for item in manifest.json background.js content.js shared icons README.md; do + ditto "$scratch/source/BrowserExtension/$item" "$scratch/extension/$item" +done +ditto -c -k "$scratch/extension" "$output/SmartShot-Web-Selector-$version.zip" +python3 "$script_directory/release_artifacts.py" checksums "$output" --version "$version" +echo "Verified preview packages: $output" diff --git a/Scripts/preview_release.py b/Scripts/preview_release.py new file mode 100755 index 0000000..4723f30 --- /dev/null +++ b/Scripts/preview_release.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Reserve and publish one preview per merged PR. Requires gh and its GH_TOKEN.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + + +VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") +MARKER = re.compile(r"") + + +def version_tuple(value): + if not VERSION.fullmatch(value): + raise ValueError(f"Invalid release version: {value}") + parts = tuple(map(int, value.split("."))) + if max(parts) > 65535: + raise ValueError("Version exceeds the browser extension's component limit") + return parts + + +def validate_plan(plan): + version_tuple(plan["version"]) + if not re.fullmatch(r"[0-9a-f]{40}", plan["commit"]): + raise ValueError("Invalid merge commit") + if type(plan["pr"]) is not int or plan["pr"] < 1: + raise ValueError("Invalid PR number") + if type(plan["build"]) is not int or not 1 <= plan["build"] <= 9999: + raise ValueError("Build number must be between 1 and 9999") + return plan + + +def metadata(release): + match = MARKER.search(release.get("body") or "") + if not match: + return None + plan = validate_plan(json.loads(match[1])) + if release["tag_name"] != "v" + plan["version"]: + raise ValueError("Release metadata does not match its tag") + return plan + + +def source_version(source): + project = (Path(source) / "project.yml").read_text() + version = re.search(r"^ MARKETING_VERSION: (\S+)\s*$", project, re.M)[1] + build = int(re.search(r"^ CURRENT_PROJECT_VERSION: (\d+)\s*$", project, re.M)[1]) + version_tuple(version) + return version, build + + +def asset_names(version): + return [f"SmartShot-{version}-universal.dmg", f"SmartShot-{version}-universal.zip", + f"SmartShot-Web-Selector-{version}.zip", "SHA256SUMS.txt"] + + +def check_assets(release, version, files=None): + assets = release.get("assets", []) + if sorted(asset["name"] for asset in assets) != sorted(asset_names(version)): + raise ValueError("Release must contain exactly the four expected assets") + for asset in assets: + if asset["state"] != "uploaded" or asset["size"] <= 0: + raise ValueError(f"Incomplete asset: {asset['name']}") + if files is not None: + data = files[asset["name"]].read_bytes() + digest = "sha256:" + hashlib.sha256(data).hexdigest() + if asset["size"] != len(data) or asset.get("digest") not in (None, digest): + raise ValueError(f"Uploaded asset differs: {asset['name']}") + + +def check_files(directory, version): + directory = Path(directory) + names = asset_names(version) + if sorted(path.name for path in directory.iterdir()) != sorted(names): + raise ValueError("Package directory must contain exactly the four release assets") + files = {name: directory / name for name in names} + expected = "".join(f"{hashlib.sha256(files[name].read_bytes()).hexdigest()} {name}\n" + for name in sorted(names[:-1])) + if files["SHA256SUMS.txt"].read_text() != expected: + raise ValueError("Release checksums do not match the packages") + if any(path.stat().st_size == 0 for path in files.values()): + raise ValueError("Empty release asset") + return files + + +class GitHub: + def __init__(self, repository): + self.repository = repository + + def api(self, path, data=None, method="GET", missing_ok=False): + command = ["gh", "api", f"repos/{self.repository}/{path}", "--method", method] + if data is not None: + command += ["--input", "-"] + result = subprocess.run(command, input=json.dumps(data) if data is not None else None, + text=True, capture_output=True) + if result.returncode: + if missing_ok and "(HTTP 404)" in result.stderr: + return None + raise RuntimeError(result.stderr.strip()) + return json.loads(result.stdout) + + def pages(self, path): + items = [] + page = 1 + while True: + batch = self.api(f"{path}?per_page=100&page={page}") + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + def upload(self, tag, files): + subprocess.run(["gh", "release", "upload", tag, *map(str, files.values()), + "--clobber", "--repo", self.repository], check=True) + + +def resolve_pr(github, number): + if not re.fullmatch(r"[1-9][0-9]*", str(number)): + raise ValueError("Provide a positive PR number") + pr = github.api(f"pulls/{number}") + if (not pr["merged"] or pr["base"]["ref"] != "main" + or pr["base"]["repo"]["full_name"] != github.repository): + raise ValueError("Only a PR already merged into this repository's main can be released") + commit = pr["merge_commit_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", commit or ""): + raise ValueError("The PR has no valid merge commit") + subprocess.run(["git", "merge-base", "--is-ancestor", commit, "origin/main"], check=True) + return commit + + +def reserve(github, number, commit, source): + releases = github.pages("releases") + plans = [(release, metadata(release)) for release in releases] + existing = [(release, plan) for release, plan in plans if plan and plan["pr"] == number] + if len(existing) > 1: + raise ValueError("Multiple release reservations exist for this PR") + if existing: + release, plan = existing[0] + if plan["commit"] != commit: + raise ValueError("The reserved release belongs to a different merge commit") + if not release["draft"]: + check_assets(release, plan["version"]) + return dict(plan, release_id=release["id"], needs_build=release["draft"]) + + floor_version, floor_build = source_version(source) + tags = [tag["name"] for tag in github.pages("tags")] + tags += [release["tag_name"] for release in releases] + versions = [version_tuple(tag[1:]) for tag in tags + if tag.startswith("v") and VERSION.fullmatch(tag[1:])] + latest = max(versions, default=(-1, -1, -1)) + next_version = version_tuple(floor_version) + if next_version <= latest: + next_version = (latest[0], latest[1], latest[2] + 1) + plan = validate_plan({"pr": number, "commit": commit, + "version": ".".join(map(str, next_version)), + "build": max([floor_build] + [p["build"] for _, p in plans if p]) + 1}) + body = ( + f"\n\n" + f"Preview from merged PR #{number}. Source commit: `{commit}`.\n\n" + "Includes a universal macOS app (Apple Silicon and Intel), a browser extension, " + "and SHA-256 checksums. Install SmartShot.app in /Applications.\n\n" + "This preview is ad-hoc signed, without Developer ID signing or Apple notarization. " + "macOS approval and renewed permissions may be required. Safari development " + "restrictions still apply; see the repository's Safari guide.\n\n" + ) + notes_request = {"tag_name": "v" + plan["version"], "target_commitish": commit} + published_tags = [r["tag_name"] for r in releases if not r["draft"] + and r["tag_name"].startswith("v") and VERSION.fullmatch(r["tag_name"][1:])] + if published_tags: + notes_request["previous_tag_name"] = max(published_tags, key=lambda tag: version_tuple(tag[1:])) + notes = github.api("releases/generate-notes", notes_request, method="POST") + release = github.api("releases", { + "tag_name": "v" + plan["version"], "target_commitish": commit, + "name": f"SmartShot {plan['version']} Preview", "body": body + notes["body"], + "draft": True, "prerelease": True, "make_latest": "false", + }, method="POST") + return dict(plan, release_id=release["id"], needs_build=True) + + +def ensure_tag(github, plan, create): + tag = "v" + plan["version"] + reference = github.api(f"git/ref/tags/{tag}", missing_ok=True) + if reference is None: + if not create: + raise ValueError("Published release tag is missing") + github.api("git/refs", {"ref": "refs/tags/" + tag, "sha": plan["commit"]}, method="POST") + return + obj = reference["object"] + while obj["type"] == "tag": + obj = github.api("git/tags/" + obj["sha"])["object"] + if obj["type"] != "commit" or obj["sha"] != plan["commit"]: + raise ValueError("Refusing to move a release tag that points to another commit") + + +def publish(github, plan, directory): + validate_plan(plan) + release_path = f"releases/{plan['release_id']}" + release = github.api(release_path) + expected = {key: plan[key] for key in ("pr", "commit", "version", "build")} + if metadata(release) != expected: + raise ValueError("Release reservation changed during the build") + if not release["draft"]: + check_assets(release, plan["version"]) + ensure_tag(github, plan, create=False) + return release["html_url"] + files = check_files(directory, plan["version"]) + ensure_tag(github, plan, create=True) + github.upload("v" + plan["version"], files) + check_assets(github.api(release_path), plan["version"], files) + published = github.api(release_path, {"draft": False, "prerelease": True, + "make_latest": "false"}, method="PATCH") + return published["html_url"] + + +def outputs(values): + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for name, value in values.items(): + output.write(f"{name}={str(value).lower() if isinstance(value, bool) else value}\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("resolve", "reserve", "publish")) + parser.add_argument("--repository", required=True) + parser.add_argument("--pr") + parser.add_argument("--source", type=Path, default=Path.cwd()) + parser.add_argument("--plan", type=Path) + parser.add_argument("--assets", type=Path) + args = parser.parse_args() + github = GitHub(args.repository) + if args.command == "resolve": + commit = resolve_pr(github, args.pr) + outputs({"commit": commit}) + print(commit) + elif args.command == "reserve": + commit = resolve_pr(github, args.pr) + plan = reserve(github, int(args.pr), commit, args.source) + args.plan.write_text(json.dumps(plan, indent=2) + "\n") + outputs(plan) + print(json.dumps(plan, indent=2)) + else: + url = publish(github, json.loads(args.plan.read_text()), args.assets) + print(url) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write(f"Published preview: {url}\n") + + +if __name__ == "__main__": + main() diff --git a/Scripts/release_artifacts.py b/Scripts/release_artifacts.py new file mode 100755 index 0000000..4419006 --- /dev/null +++ b/Scripts/release_artifacts.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Stage versioned sources and verify the macOS preview before packaging.""" + +import argparse +import hashlib +import json +from pathlib import Path +import plistlib +import re +import shutil +import subprocess + +from preview_release import asset_names, check_files, source_version, version_tuple + + +def stage(source, destination, version, build): + version_tuple(version) + if not 1 <= build <= 9999: + raise ValueError("Build number must be between 1 and 9999") + destination.mkdir() + for name in ("Sources", "Resources", "BrowserExtension", "Tests"): + shutil.copytree(source / name, destination / name, + ignore=shutil.ignore_patterns("node_modules", "__pycache__", ".DS_Store")) + project = (source / "project.yml").read_text() + for key, value in (("MARKETING_VERSION", version), ("CURRENT_PROJECT_VERSION", build)): + project, count = re.subn(rf"^ {key}: .+$", f" {key}: {value}", project, flags=re.M) + if count != 1: + raise ValueError(f"Expected one {key} in project.yml") + (destination / "project.yml").write_text(project) + for name in ("manifest.json", "package.json"): + path = destination / "BrowserExtension" / name + data = json.loads(path.read_text()) + data["version"] = version + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") + + +def verify_app(app, version, build): + extension = app / "Contents/PlugIns/SmartShot Safari Extension.appex" + for bundle in (app, extension): + info = plistlib.loads((bundle / "Contents/Info.plist").read_bytes()) + if (info["CFBundleShortVersionString"], info["CFBundleVersion"]) != (version, str(build)): + raise ValueError(f"Incorrect version in {bundle}") + manifest = json.loads((extension / "Contents/Resources/manifest.json").read_text()) + if manifest["version"] != version: + raise ValueError("Embedded browser extension version differs from the app") + subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True) + binaries = [app / "Contents/MacOS/SmartShot", + extension / "Contents/MacOS/SmartShot Safari Extension", + app / "Contents/Helpers/SmartShotNativeHost", app / "Contents/Helpers/smartshot"] + for binary in binaries: + architectures = subprocess.check_output(["/usr/bin/lipo", "-archs", str(binary)], text=True).split() + if set(architectures) != {"arm64", "x86_64"}: + raise ValueError(f"Expected arm64 and x86_64 in {binary}, found {architectures}") + signature = subprocess.run(["codesign", "-d", "--verbose=4", str(binary)], + check=True, capture_output=True, text=True).stderr + if "Signature=adhoc" not in signature or "runtime" not in signature: + raise ValueError(f"Expected an ad-hoc signature with hardened runtime: {binary}") + data = subprocess.run(["codesign", "-d", "--entitlements", ":-", str(binary)], + check=True, capture_output=True).stdout + entitlements = plistlib.loads(data) if data else {} + if entitlements.get("com.apple.security.get-task-allow"): + raise ValueError(f"Development entitlement found in {binary}") + required = None + if binary == binaries[0]: + required = "com.apple.security.device.audio-input" + elif binary == binaries[1]: + required = "com.apple.security.app-sandbox" + if required and entitlements.get(required) is not True: + raise ValueError(f"Missing {required} in {binary}") + + +def checksums(directory, version): + names = asset_names(version)[:-1] + content = "".join(f"{hashlib.sha256((directory / name).read_bytes()).hexdigest()} {name}\n" + for name in sorted(names)) + (directory / "SHA256SUMS.txt").write_text(content) + check_files(directory, version) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("stage", "verify", "checksums", "version")) + parser.add_argument("path", type=Path) + parser.add_argument("--destination", type=Path) + parser.add_argument("--version") + parser.add_argument("--build", type=int) + args = parser.parse_args() + if args.command == "stage": + stage(args.path, args.destination, args.version, args.build) + elif args.command == "verify": + verify_app(args.path, args.version, args.build) + elif args.command == "checksums": + checksums(args.path, args.version) + else: + print(*source_version(args.path)) + + +if __name__ == "__main__": + main() diff --git a/Scripts/tests/test_preview_release.py b/Scripts/tests/test_preview_release.py new file mode 100644 index 0000000..c43009c --- /dev/null +++ b/Scripts/tests/test_preview_release.py @@ -0,0 +1,263 @@ +import copy +import hashlib +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from preview_release import (asset_names, check_files, metadata, publish, reserve, resolve_pr, + validate_plan) +from release_artifacts import checksums, stage + + +class FakeGitHub: + repository = "owner/SmartShot" + + def __init__(self): + self.releases = [] + self.tags = {"v0.2.3": "0" * 40} + self.uploads = 0 + self.fail_upload = False + self.fail_after_publish = False + self.corrupt_upload = False + self.notes_requests = [] + self.pr = {"merged": True, "base": {"ref": "main", "repo": {"full_name": self.repository}}, + "merge_commit_sha": "a" * 40} + + def pages(self, path): + if path == "releases": + return copy.deepcopy(self.releases) + if path == "tags": + return [{"name": tag} for tag in self.tags] + raise AssertionError(path) + + def api(self, path, data=None, method="GET", missing_ok=False): + if path.startswith("pulls/"): + return copy.deepcopy(self.pr) + if path == "releases/generate-notes": + self.notes_requests.append(data) + return {"body": "## Changes\nGenerated notes.\n"} + if path == "releases" and method == "POST": + release = dict(data, id=len(self.releases) + 1, assets=[], html_url="https://example.org/release") + self.releases.append(release) + return copy.deepcopy(release) + if path.startswith("releases/"): + release = next(r for r in self.releases if r["id"] == int(path.split("/")[1])) + if method == "PATCH": + release.update(data) + if self.fail_after_publish: + self.fail_after_publish = False + raise RuntimeError("Connection lost after publication") + return copy.deepcopy(release) + if path.startswith("git/ref/tags/"): + tag = path.removeprefix("git/ref/tags/") + if tag not in self.tags: + assert missing_ok + return None + return {"object": {"type": "commit", "sha": self.tags[tag]}} + if path == "git/refs" and method == "POST": + tag = data["ref"].removeprefix("refs/tags/") + assert tag not in self.tags + self.tags[tag] = data["sha"] + return {} + raise AssertionError((path, data, method)) + + def upload(self, tag, files): + self.uploads += 1 + release = next(r for r in self.releases if r["tag_name"] == tag) + release["assets"] = [] + for name, path in files.items(): + data = path.read_bytes() + release["assets"].append({"name": name, "size": len(data), "state": "uploaded", + "digest": "sha256:" + hashlib.sha256(data).hexdigest()}) + if self.fail_upload: + self.fail_upload = False + raise RuntimeError("Connection lost during upload") + if self.corrupt_upload: + release["assets"][0]["digest"] = "sha256:incorrect" + + +class PreviewReleaseTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "source" + self.source.mkdir() + (self.source / "project.yml").write_text( + "settings:\n base:\n MARKETING_VERSION: 0.2.3\n CURRENT_PROJECT_VERSION: 11\n") + self.github = FakeGitHub() + + def reserve(self, pr=3, commit="a" * 40): + return reserve(self.github, pr, commit, self.source) + + def packages(self, plan): + assets = self.root / ("assets-" + plan["version"]) + assets.mkdir(exist_ok=True) + for name in asset_names(plan["version"])[:-1]: + (assets / name).write_bytes(name.encode()) + checksums(assets, plan["version"]) + return assets + + def test_allocates_patch_and_build_after_latest_preview(self): + plan = self.reserve() + self.assertEqual((plan["version"], plan["build"]), ("0.2.4", 12)) + self.assertTrue(self.github.releases[0]["draft"]) + self.assertNotIn("v0.2.4", self.github.tags) + + def test_compares_numeric_versions_and_ignores_non_release_tags(self): + self.github.tags.update({"v0.2.10": "b" * 40, "v0.2.9": "c" * 40, "v9.0.0-beta": "d" * 40}) + self.assertEqual(self.reserve()["version"], "0.2.11") + + def test_honors_higher_source_version(self): + project = self.source / "project.yml" + project.write_text(project.read_text().replace("0.2.3", "0.3.0")) + self.assertEqual(self.reserve()["version"], "0.3.0") + + def test_first_release_can_use_source_floor(self): + self.github.tags.clear() + self.assertEqual(self.reserve()["version"], "0.2.3") + + def test_draft_reserves_version_for_the_next_merge(self): + first = self.reserve() + second = self.reserve(pr=4, commit="b" * 40) + self.assertEqual((second["version"], second["build"]), ("0.2.5", 13)) + self.assertEqual(self.reserve(), first) + self.assertEqual(len(self.github.releases), 2) + + def test_rejects_changed_commit_for_reserved_pr(self): + self.reserve() + with self.assertRaisesRegex(ValueError, "different merge commit"): + self.reserve(commit="b" * 40) + + def test_rejects_duplicate_pr_reservations(self): + self.reserve() + self.github.releases.append(copy.deepcopy(self.github.releases[0])) + with self.assertRaisesRegex(ValueError, "Multiple"): + self.reserve() + + def test_recovers_from_interrupted_upload_without_a_second_release(self): + plan = self.reserve() + assets = self.packages(plan) + self.github.fail_upload = True + with self.assertRaisesRegex(RuntimeError, "during upload"): + publish(self.github, plan, assets) + self.assertTrue(self.github.releases[0]["draft"]) + retry = self.reserve() + self.assertEqual(retry, plan) + publish(self.github, retry, assets) + self.assertFalse(self.github.releases[0]["draft"]) + self.assertEqual(len(self.github.releases), 1) + self.assertEqual(self.github.tags["v0.2.4"], plan["commit"]) + + def test_recovers_when_publish_succeeds_but_response_is_lost(self): + plan = self.reserve() + self.github.fail_after_publish = True + with self.assertRaisesRegex(RuntimeError, "after publication"): + publish(self.github, plan, self.packages(plan)) + retry = self.reserve() + self.assertFalse(retry["needs_build"]) + publish(self.github, retry, None) + self.assertEqual(self.github.uploads, 1) + self.assertEqual(len(self.github.releases), 1) + + def test_published_retry_does_not_replace_assets(self): + plan = self.reserve() + publish(self.github, plan, self.packages(plan)) + publish(self.github, self.reserve(), None) + self.assertEqual(self.github.uploads, 1) + + def test_refuses_to_overwrite_an_existing_tag(self): + plan = self.reserve() + self.github.tags["v0.2.4"] = "b" * 40 + with self.assertRaisesRegex(ValueError, "Refusing to move"): + publish(self.github, plan, self.packages(plan)) + self.assertEqual(self.github.uploads, 0) + + def test_corrupt_local_package_is_rejected_before_creating_tag(self): + plan = self.reserve() + assets = self.packages(plan) + (assets / asset_names(plan["version"])[0]).write_bytes(b"corrupted") + with self.assertRaisesRegex(ValueError, "checksums"): + publish(self.github, plan, assets) + self.assertNotIn("v0.2.4", self.github.tags) + self.assertEqual(self.github.uploads, 0) + + def test_unexpected_or_missing_package_is_rejected(self): + plan = self.reserve() + assets = self.packages(plan) + (assets / "unexpected.txt").touch() + with self.assertRaisesRegex(ValueError, "exactly"): + check_files(assets, plan["version"]) + (assets / "unexpected.txt").unlink() + (assets / asset_names(plan["version"])[0]).unlink() + with self.assertRaisesRegex(ValueError, "exactly"): + check_files(assets, plan["version"]) + + def test_bad_uploaded_digest_keeps_release_private(self): + plan = self.reserve() + self.github.corrupt_upload = True + with self.assertRaisesRegex(ValueError, "Uploaded asset differs"): + publish(self.github, plan, self.packages(plan)) + self.assertTrue(self.github.releases[0]["draft"]) + + def test_rejects_incomplete_published_release(self): + plan = self.reserve() + publish(self.github, plan, self.packages(plan)) + self.github.releases[0]["assets"].pop() + with self.assertRaisesRegex(ValueError, "four expected"): + self.reserve() + + def test_release_notes_start_at_previous_published_preview(self): + first = self.reserve() + publish(self.github, first, self.packages(first)) + self.reserve(pr=4, commit="b" * 40) + self.assertEqual(self.github.notes_requests[-1]["previous_tag_name"], "v0.2.4") + + def test_rejects_version_metadata_that_disagrees_with_tag(self): + self.reserve() + self.github.releases[0]["tag_name"] = "v0.2.9" + with self.assertRaisesRegex(ValueError, "does not match"): + metadata(self.github.releases[0]) + + def test_rejects_invalid_version_or_build_before_release_creation(self): + plan = self.reserve() + for value in ("0.2.3;echo unsafe", "01.2.3", "0.2.65536"): + with self.subTest(value=value), self.assertRaises(ValueError): + validate_plan(dict(plan, version=value)) + with self.assertRaises(ValueError): + validate_plan(dict(plan, build=10000)) + + @patch("preview_release.subprocess.run") + def test_resolves_only_a_merged_pr_on_main_and_checks_ancestry(self, run): + self.assertEqual(resolve_pr(self.github, "3"), "a" * 40) + run.assert_called_once_with(["git", "merge-base", "--is-ancestor", "a" * 40, "origin/main"], check=True) + for field, value in (("merged", False), ("base", {"ref": "other", "repo": {"full_name": self.github.repository}})): + original = copy.deepcopy(self.github.pr) + self.github.pr[field] = value + with self.assertRaisesRegex(ValueError, "already merged"): + resolve_pr(self.github, "3") + self.github.pr = original + with self.assertRaises(ValueError): + resolve_pr(self.github, "3;false") + + def test_stages_consistent_versions_without_modifying_checkout(self): + for name in ("Sources", "Resources", "Tests", "BrowserExtension"): + (self.source / name).mkdir() + for name in ("manifest.json", "package.json"): + (self.source / "BrowserExtension" / name).write_text('{"version": "0.2.3", "name": "SmartShot"}') + destination = self.root / "staged" + stage(self.source, destination, "0.2.4", 12) + self.assertIn("MARKETING_VERSION: 0.2.4", (destination / "project.yml").read_text()) + self.assertIn("CURRENT_PROJECT_VERSION: 12", (destination / "project.yml").read_text()) + self.assertIn("MARKETING_VERSION: 0.2.3", (self.source / "project.yml").read_text()) + for name in ("manifest.json", "package.json"): + self.assertEqual(json.loads((destination / "BrowserExtension" / name).read_text())["version"], "0.2.4") + self.assertEqual(json.loads((self.source / "BrowserExtension" / name).read_text())["version"], "0.2.3") + + +if __name__ == "__main__": + unittest.main()