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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,42 @@ Only the trusted signing workflow creates the immutable GitHub Release. See
[VERIFICATION.md](VERIFICATION.md), [CONTRIBUTING.md](CONTRIBUTING.md), and
[SECURITY.md](SECURITY.md) for project processes.

## GitHub Action

`action.yml` is a composite action so any repository can build a package on a
macOS runner without hand-rolling install-and-invoke. It installs the swiftpkg
release, optionally lints, builds with `--output-format json`, and exposes the
result as step outputs.

```yaml
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- id: pkg
uses: codecarton/swiftpkg@v1
with:
project-path: packages/my-project
version: ${{ github.ref_name }}
lint: true
verify: true
- run: echo "Built ${{ steps.pkg.outputs.pkg-path }} (${{ steps.pkg.outputs.sha256 }})"
```

Inputs: `project-path` (required), `version` (→ `--pkg-version`), `output-dir`,
`swiftpkg-version`, `swiftpkg-sha256`, `expected-team-id`, `lint`, `verify`,
`provenance`, `extra-args`. Outputs: `pkg-path`, `version`, `sha256`. Requires a
swiftpkg release that includes the CI flags (`--output-format`, `--output-dir`,
`--pkg-version`, `--lint`, `--verify`, `--provenance`).

The action installs a release package as root, so it checks what it downloaded
first: the asset must match the release's `SHA256SUMS` and must be signed by the
`expected-team-id` Developer Team, and `spctl` must accept it. `swiftpkg-version`
defaults to a pinned tag rather than `latest`. GitHub release assets can be
replaced without moving the tag, so a build that must be reproducible byte for
byte should also set `swiftpkg-sha256` to the checksum it expects.

## Marketing site

The static marketing site lives in [`site/`](site/) and publishes to
Expand Down
169 changes: 169 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: 'swiftpkg build'
description: 'Build an Apple installer package from a swiftpkg project directory'
author: 'codecarton'

inputs:
project-path:
description: 'Path to the swiftpkg package project directory'
required: true
version:
description: 'Override the build-info version (e.g. a git tag). Maps to --pkg-version.'
required: false
default: ''
output-dir:
description: 'Directory to write the built package into. Maps to --output-dir.'
required: false
default: 'dist'
swiftpkg-version:
description: 'Release tag of swiftpkg to install. "latest" is accepted but makes builds depend on whatever ships next.'
required: false
default: 'v0.3.1'
swiftpkg-sha256:
description: 'Expected SHA-256 of the installer asset. Set it to pin the exact bytes; release assets are mutable, so a tag alone does not.'
required: false
default: ''
expected-team-id:
description: 'Apple Developer Team ID the installer must be signed by.'
required: false
default: 'DPXY7JLK67'
lint:
description: 'If "true", run `swiftpkg --lint` before building and fail on lint errors.'
required: false
default: 'false'
verify:
description: 'If "true", pass --verify so a signed/notarized build is checked after building.'
required: false
default: 'false'
provenance:
description: 'If "true", pass --provenance to write a <pkg>.provenance.json sidecar.'
required: false
default: 'false'
extra-args:
description: 'Additional arguments appended to the swiftpkg build invocation.'
required: false
default: ''

outputs:
pkg-path:
description: 'Path to the built package'
value: ${{ steps.build.outputs.pkg-path }}
version:
description: 'Version of the built package'
value: ${{ steps.build.outputs.version }}
sha256:
description: 'SHA-256 of the built package'
value: ${{ steps.build.outputs.sha256 }}

runs:
using: composite
steps:
- name: Install swiftpkg
shell: bash
env:
SWIFTPKG_VERSION: ${{ inputs.swiftpkg-version }}
SWIFTPKG_SHA256: ${{ inputs.swiftpkg-sha256 }}
EXPECTED_TEAM_ID: ${{ inputs.expected-team-id }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
runner_tmp="${RUNNER_TEMP:-/tmp}"
# Download into a fresh directory so a stale or unexpected package left
# in the runner temp can't be picked up.
download_dir="$(mktemp -d "$runner_tmp/swiftpkg-install.XXXXXX")"
# A release publishes swiftpkg-<version>-cli.pkg (the CLI alone),
# swiftpkg-<version>-combined.pkg (CLI + Swiftpkgr), and SHA256SUMS. CI
# wants the CLI. The version is embedded in the filename, so match it
# with a pattern rather than a fixed URL.
args=(--repo codecarton/swiftpkg --pattern 'swiftpkg-*-cli.pkg' --pattern 'SHA256SUMS' --dir "$download_dir")
if [ "$SWIFTPKG_VERSION" = "latest" ]; then
gh release download "${args[@]}"
else
gh release download "$SWIFTPKG_VERSION" "${args[@]}"
fi
# Require exactly one matching asset, so an ambiguous release can't cause
# a surprising package to be installed as root.
shopt -s nullglob
pkgs=("$download_dir"/swiftpkg-*-cli.pkg)
if [ "${#pkgs[@]}" -ne 1 ]; then
echo "Expected exactly one swiftpkg installer, found ${#pkgs[@]}: ${pkgs[*]:-none}" >&2
exit 1
fi
pkg="${pkgs[0]}"

# Three checks, each covering what the others cannot. Only the caller's
# own swiftpkg-sha256 pins the bytes against a release asset being
# replaced in place; SHA256SUMS ships from the same release, so it
# catches a truncated or corrupted download but moves with the release;
# and the signature is what makes a substituted asset unusable, since
# forging it requires the publisher's Developer ID certificate.
actual="$(shasum -a 256 "$pkg" | awk '{print $1}')"
if [ -n "$SWIFTPKG_SHA256" ] && [ "$actual" != "$SWIFTPKG_SHA256" ]; then
echo "Installer SHA-256 does not match swiftpkg-sha256: expected $SWIFTPKG_SHA256, got $actual" >&2
exit 1
fi
published="$(awk -v name="$(basename "$pkg")" '$2 == name { print $1 }' "$download_dir/SHA256SUMS")"
if [ -z "$published" ]; then
echo "SHA256SUMS has no entry for $(basename "$pkg")" >&2
exit 1
fi
if [ "$actual" != "$published" ]; then
echo "Installer SHA-256 does not match SHA256SUMS: expected $published, got $actual" >&2
exit 1
fi

# Assess before running the installer as root. spctl establishes that
# Apple notarized it; the Team ID establishes who signed it, which
# notarization alone does not.
signature="$(pkgutil --check-signature "$pkg")"
printf '%s\n' "$signature"
case "$signature" in
*"($EXPECTED_TEAM_ID)"*) ;;
*) echo "Installer is not signed by Team ID $EXPECTED_TEAM_ID" >&2; exit 1 ;;
esac
spctl --assess --type install -vv "$pkg"

sudo installer -pkg "$pkg" -target /
swiftpkg --version

- name: Lint
if: ${{ inputs.lint == 'true' }}
shell: bash
env:
PROJECT_PATH: ${{ inputs.project-path }}
run: swiftpkg --lint "$PROJECT_PATH"

- name: Build
id: build
shell: bash
env:
PROJECT_PATH: ${{ inputs.project-path }}
PKG_VERSION: ${{ inputs.version }}
OUTPUT_DIR: ${{ inputs.output-dir }}
DO_VERIFY: ${{ inputs.verify }}
DO_PROVENANCE: ${{ inputs.provenance }}
EXTRA_ARGS: ${{ inputs.extra-args }}
run: |
set -euo pipefail
# extra-args first so the action's required flags (json output,
# output-dir) always win and can't be overridden into a shape that
# breaks jq parsing.
args=()
if [ -n "$EXTRA_ARGS" ]; then
read -ra extra <<< "$EXTRA_ARGS"
args+=("${extra[@]}")
fi
args+=(--output-format json --output-dir "$OUTPUT_DIR")
if [ -n "$PKG_VERSION" ]; then
args+=(--pkg-version "$PKG_VERSION")
fi
if [ "$DO_VERIFY" = "true" ]; then
args+=(--verify)
fi
if [ "$DO_PROVENANCE" = "true" ]; then
args+=(--provenance)
fi
result="$(swiftpkg "${args[@]}" "$PROJECT_PATH")"
echo "$result"
echo "pkg-path=$(echo "$result" | jq -r '.pkg_path')" >> "$GITHUB_OUTPUT"
echo "version=$(echo "$result" | jq -r '.version')" >> "$GITHUB_OUTPUT"
echo "sha256=$(echo "$result" | jq -r '.sha256')" >> "$GITHUB_OUTPUT"
185 changes: 185 additions & 0 deletions azure-pipelines/swiftpkg-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Azure DevOps steps template that builds an Apple installer package from a
# swiftpkg project directory — the ADO equivalent of this repo's GitHub
# composite action (action.yml). It installs the swiftpkg release package,
# optionally lints, builds with --output-format json, and exposes the result
# as output variables on the 'build' step.
#
# Usage from a pipeline in another repo. Reference swiftpkg as a repository
# resource, then include this template:
#
# resources:
# repositories:
# - repository: swiftpkg
# type: github
# name: codecarton/swiftpkg
# endpoint: <your GitHub service connection>
#
# steps:
# - template: azure-pipelines/swiftpkg-build.yml@swiftpkg
# parameters:
# projectPath: packages/my-package-project
# version: $(Build.SourceBranchName)
# lint: true
#
# Outputs (from the step named 'build'):
# $(build.pkgPath) $(build.version) $(build.sha256)
#
# Must run on a macOS agent (pool: { vmImage: 'macOS-latest' }).

parameters:
- name: projectPath
type: string
- name: version
type: string
default: ''
- name: outputDir
type: string
default: 'dist'
- name: swiftpkgVersion
type: string
default: 'v0.3.1'
- name: swiftpkgSha256
type: string
default: ''
- name: expectedTeamId
type: string
default: 'DPXY7JLK67'
- name: lint
type: boolean
default: false
- name: verify
type: boolean
default: false
- name: provenance
type: boolean
default: false
- name: extraArgs
type: string
default: ''

steps:
- bash: |
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
echo "##vso[task.logissue type=error]jq is required but was not found. Install it (e.g. 'brew install jq')."
exit 1
fi
repo="codecarton/swiftpkg"
if [ "$SWIFTPKG_VERSION" = "latest" ]; then
api="https://api.github.com/repos/$repo/releases/latest"
else
api="https://api.github.com/repos/$repo/releases/tags/$SWIFTPKG_VERSION"
fi
# A release publishes swiftpkg-<version>-cli.pkg (the CLI alone),
# swiftpkg-<version>-combined.pkg (CLI + Swiftpkgr), and SHA256SUMS. CI
# wants the CLI. The name embeds the version, so resolve the asset URL from
# the release metadata rather than guessing the filename. Select the first
# match inside jq — piping to `head` under pipefail can SIGPIPE jq and fail
# the step when more than one asset matches.
release="$(curl -fsSL "$api")"
url="$(printf '%s' "$release" | jq -er 'first(.assets[] | select(.name | test("swiftpkg-.*-cli\\.pkg$")) | .browser_download_url)')" || url=""
if [ -z "$url" ]; then
echo "##vso[task.logissue type=error]Could not find a swiftpkg CLI package asset for '$SWIFTPKG_VERSION'."
exit 1
fi
sums_url="$(printf '%s' "$release" | jq -er 'first(.assets[] | select(.name == "SHA256SUMS") | .browser_download_url)')" || sums_url=""
if [ -z "$sums_url" ]; then
echo "##vso[task.logissue type=error]Release '$SWIFTPKG_VERSION' publishes no SHA256SUMS."
exit 1
fi
asset="$(basename "$url")"
dest="$AGENT_TEMPDIRECTORY/$asset"
sums="$AGENT_TEMPDIRECTORY/SHA256SUMS"
curl -fsSL --retry 3 --retry-delay 2 -o "$dest" "$url"
curl -fsSL --retry 3 --retry-delay 2 -o "$sums" "$sums_url"

# Three checks, each covering what the others cannot. Only the caller's own
# swiftpkgSha256 pins the bytes against a release asset being replaced in
# place; SHA256SUMS ships from the same release, so it catches a truncated
# or corrupted download but moves with the release; and the signature is
# what makes a substituted asset unusable, since forging it requires the
# publisher's Developer ID certificate.
actual="$(shasum -a 256 "$dest" | awk '{print $1}')"
if [ -n "$SWIFTPKG_SHA256" ] && [ "$actual" != "$SWIFTPKG_SHA256" ]; then
echo "##vso[task.logissue type=error]Installer SHA-256 does not match swiftpkgSha256: expected $SWIFTPKG_SHA256, got $actual"
exit 1
fi
published="$(awk -v name="$asset" '$2 == name { print $1 }' "$sums")"
if [ -z "$published" ]; then
echo "##vso[task.logissue type=error]SHA256SUMS has no entry for $asset."
exit 1
fi
if [ "$actual" != "$published" ]; then
echo "##vso[task.logissue type=error]Installer SHA-256 does not match SHA256SUMS: expected $published, got $actual"
exit 1
fi

# Assess before running the installer as root. spctl establishes that Apple
# notarized it; the Team ID establishes who signed it, which notarization
# alone does not.
signature="$(pkgutil --check-signature "$dest")"
printf '%s\n' "$signature"
case "$signature" in
*"($EXPECTED_TEAM_ID)"*) ;;
*)
echo "##vso[task.logissue type=error]Installer is not signed by Team ID $EXPECTED_TEAM_ID."
exit 1
;;
esac
spctl --assess --type install -vv "$dest"

sudo installer -pkg "$dest" -target /
swiftpkg --version
displayName: 'Install swiftpkg'
env:
SWIFTPKG_VERSION: ${{ parameters.swiftpkgVersion }}
SWIFTPKG_SHA256: ${{ parameters.swiftpkgSha256 }}
EXPECTED_TEAM_ID: ${{ parameters.expectedTeamId }}

- ${{ if eq(parameters.lint, true) }}:
- bash: swiftpkg --lint "$PROJECT_PATH"
displayName: 'Lint package project'
env:
PROJECT_PATH: ${{ parameters.projectPath }}

- bash: |
set -euo pipefail
# extra-args first so the template's required flags always win and can't be
# overridden into a shape that breaks jq parsing.
args=()
if [ -n "$EXTRA_ARGS" ]; then
read -ra extra <<< "$EXTRA_ARGS"
args+=("${extra[@]}")
fi
args+=(--output-format json --output-dir "$OUTPUT_DIR")
if [ -n "$PKG_VERSION" ]; then
args+=(--pkg-version "$PKG_VERSION")
fi
# Azure renders booleans as True/False; accept any case.
if [ "$(echo "$DO_VERIFY" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
args+=(--verify)
fi
if [ "$(echo "$DO_PROVENANCE" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
args+=(--provenance)
fi
result="$(swiftpkg "${args[@]}" "$PROJECT_PATH")"
echo "$result"
# Extract with `jq -er` so a null/missing field fails the step rather than
# silently publishing an empty output variable, and strip any CR/LF so the
# value can't inject a second Azure logging command.
emit() { printf '%s' "$1" | tr -d '\r\n'; }
pkg_path="$(printf '%s' "$result" | jq -er '.pkg_path')"
version="$(printf '%s' "$result" | jq -er '.version')"
sha256="$(printf '%s' "$result" | jq -er '.sha256')"
echo "##vso[task.setvariable variable=pkgPath;isOutput=true]$(emit "$pkg_path")"
echo "##vso[task.setvariable variable=version;isOutput=true]$(emit "$version")"
echo "##vso[task.setvariable variable=sha256;isOutput=true]$(emit "$sha256")"
name: build
displayName: 'Build package'
env:
PROJECT_PATH: ${{ parameters.projectPath }}
PKG_VERSION: ${{ parameters.version }}
OUTPUT_DIR: ${{ parameters.outputDir }}
DO_VERIFY: ${{ parameters.verify }}
DO_PROVENANCE: ${{ parameters.provenance }}
EXTRA_ARGS: ${{ parameters.extraArgs }}