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
50 changes: 50 additions & 0 deletions .github/actions/discover-packages/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2026 Travis Lyons
# SPDX-License-Identifier: MIT

name: Discover packages
description: >
Lists package directories affected by the change between a base and head
commit and emits them as a JSON array. Requires a prior actions/checkout
with fetch-depth: 0 so both commits and their merge base are available.
inputs:
base-sha:
description: >-
Base commit for the diff. When it is missing, unreachable, or shares no
merge base with the head, every current package is selected unless
require-base is set.
required: false
default: ''
head-sha:
description: Head commit the diff is computed against.
required: true
require-base:
description: >-
Set to 'true' to fail instead of selecting every current package when the
base commit is unusable. Callers that publish must set this, because
selecting an unchanged package reproduces an already published filename.
required: false
default: 'false'
full-rebuild-paths:
description: >-
Newline-separated glob patterns for shared build inputs. When a changed
path matches one, every current package is selected instead of only the
changed directories. Callers that publish must leave this empty, because
selecting an unchanged package reproduces an already published filename.
required: false
default: ''
outputs:
packages:
description: JSON array of package directory names.
value: ${{ steps.diff.outputs.packages }}
runs:
using: composite
steps:
- id: diff
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
BASE_SHA: ${{ inputs.base-sha }}
HEAD_SHA: ${{ inputs.head-sha }}
FULL_REBUILD_PATHS: ${{ inputs.full-rebuild-paths }}
REQUIRE_BASE: ${{ inputs.require-base }}
run: '"$ACTION_PATH/discover.sh"'
67 changes: 67 additions & 0 deletions .github/actions/discover-packages/discover.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Travis Lyons
# SPDX-License-Identifier: MIT

set -euo pipefail

: "${HEAD_SHA:?head commit is required}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
base_sha=${BASE_SHA:-}
full_rebuild_paths=${FULL_REBUILD_PATHS:-}
require_base=${REQUIRE_BASE:-false}

empty_tree=$(git hash-object -t tree /dev/null)
diff_base=$empty_tree
unavailable=
if [[ -z $base_sha || $base_sha =~ ^0+$ ]] ||
! git cat-file -e "${base_sha}^{commit}" 2>/dev/null ||
! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
unavailable='the base commit is unavailable'
elif ! diff_base=$(git merge-base "$base_sha" "$HEAD_SHA"); then
unavailable='no merge base could be determined'
diff_base=$empty_tree
fi

if [[ -n $unavailable ]]; then
# Widening to every package is a safe over-approximation when the result is
# only checked, but never when it is published: republishing an unchanged
# package reproduces a filename that already exists in the repository.
if [[ $require_base == true ]]; then
echo "::error::Refusing to select packages because $unavailable; history was probably rewritten."
exit 1
fi
echo "::warning::Selecting every current package because $unavailable."
fi

mapfile -t changed_files < <(git diff --name-only "$diff_base" "$HEAD_SHA")
mapfile -t rebuild_patterns < <(printf '%s\n' "$full_rebuild_paths" | sed '/^[[:space:]]*$/d')

# A change to a shared build input invalidates every package, not just the
# directories touched by the same commit, so widen the diff to the whole tree.
for path in "${changed_files[@]}"; do
for pattern in "${rebuild_patterns[@]}"; do
# shellcheck disable=SC2053 # patterns are supplied as globs on purpose
if [[ $path == $pattern ]]; then
echo "::notice::$path matches '$pattern'; selecting every current package."
mapfile -t changed_files < <(git ls-tree -r --name-only "$HEAD_SHA")
break 2
fi
done
done

declare -A seen=()
packages=()
for path in "${changed_files[@]}"; do
[[ $path == */* ]] || continue
package=${path%%/*}
if [[ -z ${seen["$package"]+present} ]] &&
git cat-file -e "$HEAD_SHA:$package/PKGBUILD" 2>/dev/null; then
seen["$package"]=1
packages+=("$package")
fi
done

{
printf 'packages='
jq -cn '$ARGS.positional' --args "${packages[@]}"
} >> "$GITHUB_OUTPUT"
112 changes: 57 additions & 55 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,53 @@ concurrency:
cancel-in-progress: true

jobs:
plan:
name: Plan
# A force push rewrites history, so the pushed diff describes nothing that
# was reviewed and github.event.before may not even be reachable. Skipping
# plan skips build and publish with it; recover with an ordinary push.
if: ${{ !github.event.forced && !github.event.deleted }}
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.plan.outputs.packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

# full-rebuild-paths is deliberately unset and require-base deliberately
# set: both defaults widen the selection to every package, which here
# would rebuild already published filenames and trip the immutability
# guard below. Full-matrix validation is the pull request pipeline's job.
- id: plan
uses: ./.github/actions/discover-packages
with:
base-sha: ${{ github.event.before }}
head-sha: ${{ github.sha }}
require-base: 'true'

- name: Confirm main is current
env:
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail

head_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq .object.sha)
if [[ "$head_sha" != "$GITHUB_SHA" ]]; then
echo "Refusing to publish superseded commit $GITHUB_SHA; main is at $head_sha" >&2
exit 1
fi

build:
name: Build (${{ matrix.package }})
needs: plan
if: needs.plan.outputs.packages != '[]'
strategy:
fail-fast: false
max-parallel: 4
matrix:
package:
- 1password
- 1password-cli
- deja
- pkgdepot-bin
- scip
- spotify-bin
- spotatui
package: ${{ fromJSON(needs.plan.outputs.packages) }}
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:ee205c220399524a683cf495d411691b921baed8ab47cdc6d732efa782fae484
Expand Down Expand Up @@ -93,19 +126,25 @@ jobs:
retention-days: 1

publish:
name: Publish packages
needs: build
name: Publish (${{ matrix.package }})
needs: [plan, build]
if: needs.plan.outputs.packages != '[]'
strategy:
fail-fast: false
max-parallel: 4
matrix:
package: ${{ fromJSON(needs.plan.outputs.packages) }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
environment: packages.trly.dev
steps:
- name: Download package archives
- name: Download package archive
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: package-*
path: ${{ runner.temp }}/packages
name: package-${{ matrix.package }}
path: ${{ runner.temp }}/packages/${{ matrix.package }}

- name: Install pkgdepot
env:
Expand All @@ -124,6 +163,8 @@ jobs:
tar --extract --gzip --file "$archive" --directory "$install_dir" pkgdepot
printf '%s\n' "$install_dir" >> "$GITHUB_PATH"

# Re-checked here, not just in plan: the build runs between the two jobs,
# so the plan-time check no longer covers the moment of the write.
- name: Confirm main is current
env:
GITHUB_TOKEN: ${{ github.token }}
Expand All @@ -143,8 +184,6 @@ jobs:
PKGDEPOT_OAUTH_ISSUER: https://id.trly.dev
PKGDEPOT_OAUTH_CLIENT_ID: ${{ secrets.PKGDEPOT_OAUTH_CLIENT_ID }}
PKGDEPOT_OAUTH_CLIENT_SECRET: ${{ secrets.PKGDEPOT_OAUTH_CLIENT_SECRET }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -155,53 +194,16 @@ jobs:
exit 1
fi

declare -A local_filenames=()
for archive in "${archives[@]}"; do
filename=$(basename "$archive")
if [[ ${local_filenames["$filename"]+present} ]]; then
echo "Duplicate local package filename: $filename" >&2
exit 1
fi
local_filenames["$filename"]=1
done

changed_paths=$(gh api \
"repos/${GITHUB_REPOSITORY}/compare/${GITHUB_EVENT_BEFORE}...${GITHUB_SHA}" \
--jq '.files[]?.filename')
declare -A changed_packages=()
while IFS= read -r path; do
[[ "$path" == */* ]] || continue
changed_packages["${path%%/*}"]=1
done <<< "$changed_paths"

existing_packages=$(pkgdepot package list stable)
existing_filenames=$(jq -r '.[].filename' <<< "$existing_packages")
declare -A published_filenames=()
while IFS= read -r filename; do
published_filenames["$filename"]=1
done <<< "$existing_filenames"

conflicts=0
for archive in "${archives[@]}"; do
filename=$(basename "$archive")
package_dir=$(basename "$(dirname "$archive")")
package_dir=${package_dir#package-}
if [[ ${published_filenames["$filename"]+present} ]] && \
[[ ${changed_packages["$package_dir"]+present} ]]; then
echo "Rebuilt package conflicts with an existing remote filename: $filename" >&2
conflicts=1
fi
done
if (( conflicts )); then
exit 1
fi
done < <(pkgdepot package list stable | jq -r '.[].filename')

for archive in "${archives[@]}"; do
filename=$(basename "$archive")
if [[ ${published_filenames["$filename"]+present} ]]; then
echo "Skipping already published package $filename"
continue
echo "Refusing to overwrite published package $filename" >&2
exit 1
fi
pkgdepot package publish stable "$archive"
published_filenames["$filename"]=1
done
72 changes: 14 additions & 58 deletions .github/workflows/test-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,69 +40,25 @@ jobs:
name: Discover packages
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find.outputs.packages }}
packages: ${{ steps.plan.outputs.packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.sha }}

- name: Find changed packages
id: find
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
shell: bash
run: |
set -euo pipefail

empty_tree=$(git hash-object -t tree /dev/null)
diff_base=$empty_tree
if [[ -n "$BASE_SHA" && ! "$BASE_SHA" =~ ^0+$ ]] &&
git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
if merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA"); then
diff_base=$merge_base
else
echo '::warning::Unable to find the PR merge-base; rebuilding all current packages.'
fi
else
echo '::warning::PR base or head commit is unavailable; rebuilding all current packages.'
fi

mapfile -t changed_files < <(git diff --name-only "$diff_base" "$HEAD_SHA")

rebuild_all=false
for path in "${changed_files[@]}"; do
# Keep this list limited to root inputs that can change package CI/build behavior.
# create.sh only scaffolds new packages and is intentionally linted, not global.
case "$path" in
build.sh|lint.sh|.github/workflows/test-packages.yml)
rebuild_all=true
break
;;
esac
done

if [[ "$rebuild_all" == true ]]; then
packages=$(
for pkgbuild in */PKGBUILD; do
[[ -f "$pkgbuild" ]] && printf '%s\n' "${pkgbuild%%/*}"
done
)
else
packages=$(
for path in "${changed_files[@]}"; do
package=${path%%/*}
if [[ "$path" == */* && -f "$package/PKGBUILD" ]]; then
printf '%s\n' "$package"
fi
done | sort -u
)
fi

packages=$(jq -R -s -c 'split("\n") | map(select(. != ""))' <<< "$packages")
echo "packages=$packages" >> "$GITHUB_OUTPUT"
- id: plan
uses: ./.github/actions/discover-packages
with:
base-sha: ${{ github.event.pull_request.base.sha }}
head-sha: ${{ github.event.pull_request.head.sha }}
# Shared inputs that change how every package is checked or built, so
# a change to one has to be validated against the full matrix.
# create.sh only scaffolds new packages and is intentionally omitted.
full-rebuild-paths: |
build.sh
lint.sh
.github/workflows/test-packages.yml
.github/actions/discover-packages/*

build:
name: Check and build packages
Expand Down
Loading