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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Install Rust components
run: rustup component add rustfmt clippy
Expand All @@ -33,7 +33,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Install Rust components
run: rustup component add rustfmt clippy
Expand All @@ -54,7 +54,7 @@ jobs:
run: scripts/package-macos-app.sh

- name: Upload app package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: plainlink-macos-app
path: |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ This creates `dist/packages/PlainLink-<version>-macos-<arch>.zip` and a `.sha256

This zip is for developer-preview testing. macOS Gatekeeper will warn because the app is not signed with a Developer ID certificate or notarized by Apple.

For a preview-tagged artifact, pass the preview version explicitly:

```sh
PLAINLINK_RELEASE_VERSION=v0.1.0-preview.2 scripts/package-macos-app.sh
```

To compile a safe subset from an external source and write a manifest:

```sh
Expand Down
34 changes: 30 additions & 4 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ Current status: release automation exists, but this repository does not currentl
Use this for technical testers:

```sh
scripts/package-macos-app.sh
PLAINLINK_RELEASE_VERSION=v0.1.0-preview.2 scripts/package-macos-app.sh
```

This produces an unsigned zip and checksum in `dist/packages/`. macOS Gatekeeper will warn users because the app is not signed with a Developer ID certificate and is not notarized.

Preview artifacts include the preview suffix in the filename, for example:

```text
dist/packages/PlainLink-0.1.0-preview.2-macos-arm64.zip
dist/packages/PlainLink-0.1.0-preview.2-macos-arm64.zip.sha256
```

### Signed Regular-User Release

Use this only when a release machine has Apple Developer Program credentials:
Expand Down Expand Up @@ -69,6 +76,16 @@ The script:
- verifies Gatekeeper assessment,
- repackages the stapled app and writes a SHA-256 checksum.

## Version Source

Packaging scripts use `scripts/release-version.sh` as the single source of truth:

1. `PLAINLINK_RELEASE_VERSION`, if set.
2. The exact Git tag at `HEAD`, if one exists.
3. The Cargo package version as a local development fallback.

Use `PLAINLINK_RELEASE_VERSION` when building a preview artifact from a release branch or before checking out the preview tag. Use an exact tag checkout for final release builds.

## Publish GitHub Release

After the signed zip and checksum exist, create and push the tag:
Expand All @@ -79,10 +96,19 @@ git push origin v0.1.0
scripts/publish-github-release.sh v0.1.0
```

For preview releases, the publish script expects preview-named artifacts and falls back to the base version notes if preview-specific notes do not exist:

```sh
PLAINLINK_RELEASE_VERSION=v0.1.0-preview.2 scripts/package-macos-app.sh
git tag -a v0.1.0-preview.2 -m "PlainLink v0.1.0 Preview 2"
git push origin v0.1.0-preview.2
scripts/publish-github-release.sh v0.1.0-preview.2
```

The publish script creates a draft GitHub Release with:

- `dist/packages/PlainLink-0.1.0-macos-<arch>.zip`
- `dist/packages/PlainLink-0.1.0-macos-<arch>.zip.sha256`
- release notes from `docs/releases/v0.1.0.md`
- `dist/packages/PlainLink-<release-version>-macos-<arch>.zip`
- `dist/packages/PlainLink-<release-version>-macos-<arch>.zip.sha256`
- release notes from `docs/releases/<tag>.md`, or `docs/releases/v<base-version>.md` when preview-specific notes do not exist

Review the draft release, confirm the checksum, confirm whether the artifact is signed/notarized or explicitly labeled unsigned preview, then publish it.
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Stable user install/uninstall and doctor checks.
- Unsigned macOS release packaging for testers and CI artifacts.
- Signed/notarized release automation.
- Tag-aware preview artifact naming.
- Conservative ClearURLs rule importer.
- Import manifests and fixture verification for generated rules.
- Human-readable default rules.
Expand Down
2 changes: 1 addition & 1 deletion scripts/package-macos-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_NAME="PlainLink"
APP_DIR="$ROOT_DIR/dist/$APP_NAME.app"
PACKAGE_DIR="$ROOT_DIR/dist/packages"
VERSION=$(awk -F '=' '$1 ~ /^[[:space:]]*version[[:space:]]*$/ { gsub(/[ "]/, "", $2); print $2; exit }' "$ROOT_DIR/Cargo.toml")
VERSION=$("$ROOT_DIR/scripts/release-version.sh")
ARCH=$(uname -m)
ARTIFACT_NAME="$APP_NAME-$VERSION-macos-$ARCH.zip"
ZIP_PATH="$PACKAGE_DIR/$ARTIFACT_NAME"
Expand Down
5 changes: 5 additions & 0 deletions scripts/publish-github-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
TAG="${1:-v0.1.0}"
VERSION="${TAG#v}"
BASE_VERSION="${VERSION%%-*}"
APP_NAME="PlainLink"
ARCH=$(uname -m)
PACKAGE_DIR="$ROOT_DIR/dist/packages"
ZIP_PATH="$PACKAGE_DIR/$APP_NAME-$VERSION-macos-$ARCH.zip"
SHA_PATH="$ZIP_PATH.sha256"
NOTES_PATH="$ROOT_DIR/docs/releases/$TAG.md"

if [ ! -f "$NOTES_PATH" ]; then
NOTES_PATH="$ROOT_DIR/docs/releases/v$BASE_VERSION.md"
fi

command -v gh >/dev/null 2>&1 || {
echo "gh is required to publish a GitHub Release." >&2
exit 1
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-macos-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_NAME="PlainLink"
APP_DIR="$ROOT_DIR/dist/$APP_NAME.app"
PACKAGE_DIR="$ROOT_DIR/dist/packages"
VERSION=$(awk -F '=' '$1 ~ /^[[:space:]]*version[[:space:]]*$/ { gsub(/[ "]/, "", $2); print $2; exit }' "$ROOT_DIR/Cargo.toml")
VERSION=$("$ROOT_DIR/scripts/release-version.sh")
ARCH=$(uname -m)
ARTIFACT_NAME="$APP_NAME-$VERSION-macos-$ARCH.zip"
ZIP_PATH="$PACKAGE_DIR/$ARTIFACT_NAME"
Expand Down
16 changes: 16 additions & 0 deletions scripts/release-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu

ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)

if [ "${PLAINLINK_RELEASE_VERSION:-}" ]; then
printf '%s\n' "${PLAINLINK_RELEASE_VERSION#v}"
exit 0
fi

if TAG=$(git -C "$ROOT_DIR" describe --tags --exact-match 2>/dev/null); then
printf '%s\n' "${TAG#v}"
exit 0
fi

awk -F '=' '$1 ~ /^[[:space:]]*version[[:space:]]*$/ { gsub(/[ "]/, "", $2); print $2; exit }' "$ROOT_DIR/Cargo.toml"
3 changes: 3 additions & 0 deletions tests/macos_app_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ fn macos_menu_app_assets_are_wired() {
fs::read_to_string("scripts/release-macos-app.sh").expect("release script should exist");
let publish_script = fs::read_to_string("scripts/publish-github-release.sh")
.expect("publish release script should exist");
let release_version =
fs::read_to_string("scripts/release-version.sh").expect("release version should exist");
let release_docs = fs::read_to_string("docs/RELEASE.md").expect("release docs should exist");
let release_notes =
fs::read_to_string("docs/releases/v0.1.0.md").expect("v0.1.0 release notes should exist");
Expand Down Expand Up @@ -68,6 +70,7 @@ fn macos_menu_app_assets_are_wired() {
assert!(release_script.contains("spctl --assess"));
assert!(publish_script.contains("gh release create"));
assert!(publish_script.contains("--verify-tag"));
assert!(release_version.contains("git -C \"$ROOT_DIR\" describe"));
assert!(release_docs.contains("Developer ID Application"));
assert!(release_docs.contains("notarytool store-credentials"));
assert!(release_notes.contains("PlainLink v0.1.0"));
Expand Down
52 changes: 52 additions & 0 deletions tests/release_scripts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use std::fs;
use std::process::Command;

#[test]
fn release_scripts_share_tag_aware_versioning() {
let release_version = fs::read_to_string("scripts/release-version.sh")
.expect("release version helper should exist");
let package_script =
fs::read_to_string("scripts/package-macos-app.sh").expect("package script should exist");
let release_script =
fs::read_to_string("scripts/release-macos-app.sh").expect("release script should exist");
let publish_script = fs::read_to_string("scripts/publish-github-release.sh")
.expect("publish script should exist");

assert!(release_version.contains("PLAINLINK_RELEASE_VERSION"));
assert!(release_version.contains("git -C \"$ROOT_DIR\" describe --tags --exact-match"));
assert!(package_script.contains("VERSION=$(\"$ROOT_DIR/scripts/release-version.sh\")"));
assert!(release_script.contains("VERSION=$(\"$ROOT_DIR/scripts/release-version.sh\")"));
assert!(publish_script.contains("BASE_VERSION=\"${VERSION%%-*}\""));
assert!(publish_script.contains("docs/releases/$TAG.md"));
assert!(publish_script.contains("docs/releases/v$BASE_VERSION.md"));
}

#[test]
fn release_version_override_keeps_preview_suffixes() {
let output = Command::new("scripts/release-version.sh")
.env("PLAINLINK_RELEASE_VERSION", "v0.1.0-preview.2")
.output()
.expect("release-version helper should run");

assert!(
output.status.success(),
"release-version failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).expect("version should be UTF-8"),
"0.1.0-preview.2\n"
);
}

#[test]
fn ci_uses_current_github_action_versions() {
let workflow =
fs::read_to_string(".github/workflows/ci.yml").expect("CI workflow should exist");

assert!(workflow.contains("uses: actions/checkout@v7"));
assert!(workflow.contains("uses: actions/upload-artifact@v7"));
assert!(!workflow.contains("uses: actions/checkout@v4"));
assert!(!workflow.contains("uses: actions/upload-artifact@v4"));
}