From 53240ed2ecac17afd69410ffb314a877014be217 Mon Sep 17 00:00:00 2001 From: Weston Blieden Date: Fri, 21 Aug 2026 12:43:08 +0200 Subject: [PATCH] Add upstream-tracking release pipeline; extract build.sh --- .acap.json | 22 +++ .github/workflows/build.yml | 319 +++++++++++++++--------------------- build.sh | 126 ++++++++++++++ ci/apply-version.sh | 95 +++++++++++ ci/build-packages.sh | 43 +++++ ci/release-notes.sh | 59 +++++++ ci/resolve-version.sh | 144 ++++++++++++++++ ci/upstream-version.sh | 21 +++ 8 files changed, 641 insertions(+), 188 deletions(-) create mode 100644 .acap.json create mode 100755 build.sh create mode 100755 ci/apply-version.sh create mode 100755 ci/build-packages.sh create mode 100755 ci/release-notes.sh create mode 100755 ci/resolve-version.sh create mode 100755 ci/upstream-version.sh diff --git a/.acap.json b/.acap.json new file mode 100644 index 0000000..446058d --- /dev/null +++ b/.acap.json @@ -0,0 +1,22 @@ +{ + "app": "Tailscale_VPN", + "friendlyName": "Tailscale VPN", + "versionPolicy": "mirror", + "upstream": { + "type": "script", + "script": "ci/upstream-version.sh", + "name": "Tailscale", + "changesUrl": "https://tailscale.com/changelog" + }, + "build": { + "command": "./build.sh", + "env": {} + }, + "pins": [], + "signing": { + "skip": [ + "*_acap3.eap", + "*_root.eap" + ] + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1785486..1885234 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,205 +1,148 @@ -name: Auto Build & Release Tailscale ACAP +--- +# GENERATED by acap-ci.sh from Axis_Cam_Template/ci/build.yml.tmpl +# Per-repo settings live in .acap.json. Do not edit this file directly. +# +# Upstream release -> build -> DRAFT release holding unsigned .eap files. +# Signing is manual (Axis has no signing API); ../acap-sign.sh uploads the +# signed packages and publishes the release. The acap-ops repo notifies. +name: Build on: + push: + branches: [main] + pull_request: schedule: - - cron: "0 0 * * *" # Every Monday at 03:00 UTC + - cron: "0 3 * * *" workflow_dispatch: + inputs: + version: + description: "Version to build. Empty resolves from upstream." + required: false + force: + description: "Rebuild and re-cut the draft even if unchanged." + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: acap-release-${{ github.ref }} + cancel-in-progress: false jobs: - build-and-release: + check: runs-on: ubuntu-latest + outputs: + build: ${{ steps.decide.outputs.build }} + release: ${{ steps.decide.outputs.release }} + version: ${{ steps.decide.outputs.version }} + upstream: ${{ steps.decide.outputs.upstream }} steps: + - uses: actions/checkout@v4 + - id: decide + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ github.event.inputs.version }} + INPUT_FORCE: ${{ github.event.inputs.force }} + EVENT_NAME: ${{ github.event_name }} + run: ./ci/resolve-version.sh - # 1. Checkout repo - - uses: actions/checkout@v3 - with: - persist-credentials: true - fetch-depth: 0 + build: + needs: check + if: needs.check.outputs.build == 'true' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.check.outputs.version }} + UPSTREAM_VERSION: ${{ needs.check.outputs.upstream }} + steps: + - uses: actions/checkout@v4 - # 2. Get latest Tailscale version - - name: Get latest Tailscale version - id: tailscale_version - run: | - # 1. Start from GitHub latest - GH_TAG=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | jq -r .tag_name) - GH_VERSION=${GH_TAG#v} + - name: Apply version and upstream pins + run: ./ci/apply-version.sh "$VERSION" "$UPSTREAM_VERSION" - echo "GitHub latest: $GH_VERSION" + - name: Build packages + run: ./ci/build-packages.sh - # 2. See if static ARM build exists for that version - if curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" > /dev/null; then - VERSION="$GH_VERSION" - echo "Using GitHub latest (has ARM package): $VERSION" - else - echo "No ARM package for $GH_VERSION, falling back to latest version on pkgs.tailscale.com" - # 3. Derive latest version that actually has an ARM tarball - VERSION=$( - curl -s https://pkgs.tailscale.com/stable/ \ - | grep -o 'tailscale_[0-9.]*_arm\.tgz' \ - | sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' \ - | sort -V | tail -n1 - ) - echo "Fallback version: $VERSION" - fi - - echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Get current repo version - id: current - run: | - CURRENT=$(find . -path "*/app/manifest.json" -exec jq -r '.acapPackageConf.setup.version' {} \; | sort -u | head -n1) - echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_ENV - echo "Current repo version: $CURRENT" - - - name: Compare versions - id: compare - run: | - echo "Repo version: $CURRENT_VERSION" - echo "Latest Tailscale version: $RELEASE_VERSION" - echo "Trigger: ${{ github.event_name }}" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "build_needed=true" >> $GITHUB_ENV - echo "Manual trigger — building regardless of version." - elif [ "$CURRENT_VERSION" = "$RELEASE_VERSION" ]; then - echo "build_needed=false" >> $GITHUB_ENV - echo "Already up to date. Skipping build." - else - echo "build_needed=true" >> $GITHUB_ENV - echo "New version detected. Will build." - fi - - # 3. Download Tailscale binaries - - name: Download Tailscale binaries - if: env.build_needed == 'true' - run: | - mkdir -p tailscale_bins - curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm.tgz" -o tailscale_arm.tgz - tar -xzf tailscale_arm.tgz -C tailscale_bins --strip-components=1 - mv tailscale_bins/tailscale tailscale_bins/tailscale_arm - mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm - - curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm64.tgz" -o tailscale_arm64.tgz - tar -xzf tailscale_arm64.tgz -C tailscale_bins --strip-components=1 - mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64 - mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64 - - # 4. Strip binaries to reduce package size - - name: Strip binaries - if: env.build_needed == 'true' - run: | - # Install cross-architecture strip tools - sudo apt-get update - sudo apt-get install -y binutils-aarch64-linux-gnu binutils-arm-linux-gnueabihf - - # Strip debug info and symbol tables (zero runtime/memory cost) - aarch64-linux-gnu-strip -s tailscale_bins/tailscale_arm64 - aarch64-linux-gnu-strip -s tailscale_bins/tailscaled_arm64 - arm-linux-gnueabihf-strip -s tailscale_bins/tailscale_arm - arm-linux-gnueabihf-strip -s tailscale_bins/tailscaled_arm - - ls -lh tailscale_bins/ - - # 5. Build each folder, update manifest, and copy .eap files - - name: Build all folders - if: env.build_needed == 'true' - run: | - mkdir -p build - rm -rf releases - mkdir -p releases - - for folder in */ ; do - FOLDER_NAME="${folder%/}" # remove trailing slash - [[ "$FOLDER_NAME" == "common" ]] && continue - [[ ! -d "$folder/app" ]] && continue - echo "Processing folder $FOLDER_NAME" - - # aarch64/arm/aarch64_ROOT/arm_ROOT share their C source, run script, - # HTML, and Makefile via common/app/ (see Dockerfile COPY layers); - # only arm_acap3 still carries its own self-contained app/ tree. - case "$FOLDER_NAME" in - aarch64|arm|aarch64_ROOT|arm_ROOT) APP_LIB_DIR="common/app/lib" ;; - *) APP_LIB_DIR="$folder/app/lib" ;; - esac - mkdir -p "$APP_LIB_DIR" - - # Detect architecture - if [[ "$FOLDER_NAME" == arm* ]]; then - cp tailscale_bins/tailscale_arm "$APP_LIB_DIR/tailscale" - cp tailscale_bins/tailscaled_arm "$APP_LIB_DIR/tailscaled" - else - cp tailscale_bins/tailscale_arm64 "$APP_LIB_DIR/tailscale" - cp tailscale_bins/tailscaled_arm64 "$APP_LIB_DIR/tailscaled" + - name: Verify packages + run: | + set -euo pipefail + shopt -s nullglob + packages=(releases/*.eap) + if [ ${#packages[@]} -eq 0 ]; then + echo "no .eap produced" >&2 + exit 1 fi - - # Detect variant suffix for .eap naming - if [[ "$FOLDER_NAME" == *_ROOT ]]; then - VARIANT="_root" - elif [[ "$FOLDER_NAME" == *_acap3 ]]; then - VARIANT="_acap3" - else - VARIANT="" - fi - - # Update version — manifest.json for ACAP 4, package.conf for ACAP 3 - if [[ -f "$folder/app/manifest.json" ]]; then - sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json" - elif [[ -f "$folder/app/package.conf" ]]; then - IFS='.' read -r MAJOR MINOR MICRO <<< "${RELEASE_VERSION}" - sed -i "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" "$folder/app/package.conf" - sed -i "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" "$folder/app/package.conf" - sed -i "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" "$folder/app/package.conf" - fi - - # Docker build - TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean - echo "Building $TAG_NAME" - docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" . - - # Extract .eap files into build folder - EAP_OUTPUT="./build/${TAG_NAME}" - mkdir -p "$EAP_OUTPUT" - CID=$(docker create "$TAG_NAME") - docker cp "$CID":/opt/app "$EAP_OUTPUT" - docker rm "$CID" >/dev/null - - # Move all .eap files to releases folder, append variant if needed - find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do - BASENAME=$(basename "$eap" .eap) - if [[ -n "$VARIANT" ]]; then - mv "$eap" "releases/${BASENAME}${VARIANT}.eap" - else - mv "$eap" "releases/${BASENAME}.eap" - fi + for package in "${packages[@]}"; do + echo "== $package" + tar tzf "$package" >/dev/null done - done - # Clean up - rm -rf build tailscale_bins *.tgz + - uses: actions/upload-artifact@v4 + with: + name: packages + path: releases/*.eap + if-no-files-found: error - # 6. Commit updated manifests and .eap files directly to main - - name: Commit updates to main - if: env.build_needed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - # Only commit manifests and ACAP 3 package.conf; do not track release artifacts - git add */app/manifest.json arm_acap3/app/package.conf - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "Update Tailscale to v${RELEASE_VERSION}" - git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Mo3he/Axis_Cam_Tailscale.git main - fi + # Only after a successful build, so a failed upstream jump leaves main clean. + - name: Commit version bump + if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A ':!releases' + if git diff --cached --quiet; then + echo "nothing to commit" + exit 0 + fi + git commit -m "Update to $VERSION" + # The remote can move while a long build runs, so rebase and retry. + for attempt in 1 2 3; do + if git push; then + exit 0 + fi + echo "push rejected, rebasing (attempt $attempt)" + git pull --rebase --autostash origin main + done + echo "could not push the version bump" >&2 + exit 1 - # 7. Create GitHub Release with all new .eap files - - name: Create GitHub Release - if: env.build_needed == 'true' - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ env.RELEASE_VERSION }} - name: "Tailscale VPN ${{ env.RELEASE_VERSION }}" - files: releases/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + release: + needs: [check, build] + if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.check.outputs.version }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + name: packages + path: releases + + # Stays a DRAFT: unsigned packages must never reach users, and an + # already-published release must never be overwritten with unsigned ones. + - name: Create or refresh draft release + run: | + set -euo pipefail + tag="v$VERSION" + ./ci/release-notes.sh "$VERSION" "${{ needs.check.outputs.upstream }}" > /tmp/notes.md + cat /tmp/notes.md + if gh release view "$tag" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx true; then + gh release upload "$tag" releases/*.eap --clobber + gh release edit "$tag" --notes-file /tmp/notes.md + elif gh release view "$tag" >/dev/null 2>&1; then + echo "release $tag is already published; refusing to touch it" >&2 + exit 1 + else + gh release create "$tag" releases/*.eap \ + --draft \ + --title "Tailscale VPN $VERSION" \ + --notes-file /tmp/notes.md + fi diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..27b8415 --- /dev/null +++ b/build.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env sh +# Build the Tailscale ACAP variants. +# +# ./build.sh # build every variant +# ./build.sh aarch64 arm # build only the named variant folders +# +# Downloads the prebuilt Tailscale binaries, strips them, then builds each +# variant folder that contains an app/ directory. Variant folders map to the +# .eap suffixes used in releases: *_ROOT -> _root, *_acap3 -> _acap3. +# +# Override the container runtime with RUNTIME=docker|podman. +# TAILSCALE_VERSION pins the upstream binaries; it defaults to whatever +# ci/upstream-version.sh resolves. +set -eu + +REPO_ROOT=$(cd -P "$(dirname "$0")" && pwd) +cd "$REPO_ROOT" + +if [ -z "${RUNTIME:-}" ]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + RUNTIME=docker + elif command -v podman >/dev/null 2>&1; then + RUNTIME=podman + else + echo 'Error: neither docker nor podman found in PATH' >&2 + exit 1 + fi +fi +echo "==> Using container runtime: ${RUNTIME}" + +VERSION="${TAILSCALE_VERSION:-$(sh ci/upstream-version.sh)}" +[ -n "$VERSION" ] || { echo 'Error: could not resolve a Tailscale version' >&2; exit 1; } +echo "==> Tailscale version: ${VERSION}" + +# --- fetch and strip upstream binaries --------------------------------------- + +BINS="${REPO_ROOT}/tailscale_bins" +rm -rf "$BINS" +mkdir -p "$BINS" + +fetch_arch() { + tgz_arch=$1 + suffix=$2 + echo "==> Downloading tailscale ${VERSION} (${tgz_arch})" + curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${VERSION}_${tgz_arch}.tgz" \ + -o "${BINS}/ts_${suffix}.tgz" + tar -xzf "${BINS}/ts_${suffix}.tgz" -C "$BINS" --strip-components=1 + mv "${BINS}/tailscale" "${BINS}/tailscale_${suffix}" + mv "${BINS}/tailscaled" "${BINS}/tailscaled_${suffix}" + rm -f "${BINS}/ts_${suffix}.tgz" +} + +fetch_arch arm arm +fetch_arch arm64 arm64 + +# Stripping is optional: it only shrinks the package, so a missing cross +# binutils on a dev machine must not fail the build. +strip_with() { + tool=$1 + suffix=$2 + command -v "$tool" >/dev/null 2>&1 || return 0 + "$tool" -s "${BINS}/tailscale_${suffix}" || true + "$tool" -s "${BINS}/tailscaled_${suffix}" || true +} +strip_with aarch64-linux-gnu-strip arm64 +strip_with arm-linux-gnueabihf-strip arm + +# --- build variants ----------------------------------------------------------- + +echo '==> Cleaning old .eap files...' +rm -f "${REPO_ROOT}"/*.eap +rm -rf "${REPO_ROOT}/build" + +build_variant() { + folder=${1%/} + [ -d "${folder}/app" ] || return 0 + [ "$folder" = common ] && return 0 + + # aarch64/arm/aarch64_ROOT/arm_ROOT share sources via common/app; only + # arm_acap3 carries its own self-contained app tree. + case "$folder" in + aarch64 | arm | aarch64_ROOT | arm_ROOT) lib_dir="common/app/lib" ;; + *) lib_dir="${folder}/app/lib" ;; + esac + mkdir -p "$lib_dir" + + case "$folder" in + arm*) src=arm ;; + *) src=arm64 ;; + esac + cp "${BINS}/tailscale_${src}" "${lib_dir}/tailscale" + cp "${BINS}/tailscaled_${src}" "${lib_dir}/tailscaled" + + case "$folder" in + *_ROOT) variant="_root" ;; + *_acap3) variant="_acap3" ;; + *) variant="" ;; + esac + + tag=$(echo "$folder" | tr '[:upper:]' '[:lower:]' | tr '/ ' '__') + echo "==> Building ${folder}" + "$RUNTIME" build -f "${folder}/Dockerfile" --tag "$tag" . + + out="${REPO_ROOT}/build/${tag}" + mkdir -p "$out" + cid=$("$RUNTIME" create "$tag") + "$RUNTIME" cp "${cid}:/opt/app" "$out" + "$RUNTIME" rm "$cid" >/dev/null + + find "$out" -type f -name '*.eap' | while read -r eap; do + base=$(basename "$eap" .eap) + mv "$eap" "${REPO_ROOT}/${base}${variant}.eap" + done +} + +if [ "$#" -eq 0 ]; then + set -- */ +fi +for v in "$@"; do + build_variant "$v" +done + +rm -rf "${REPO_ROOT}/build" "$BINS" + +echo '==> Done!' +ls -lh "${REPO_ROOT}"/*.eap 2>/dev/null || true diff --git a/ci/apply-version.sh b/ci/apply-version.sh new file mode 100755 index 0000000..d73749a --- /dev/null +++ b/ci/apply-version.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Write a version into every place this repo records it and refresh the +# upstream pins declared in .acap.json. +# +# Usage: ci/apply-version.sh [upstream-version] + +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION=${1:?version required} +UPSTREAM=${2:-} + +CONFIG=.acap.json +cfg() { jq -r "$1" "$CONFIG"; } + +IFS='.' read -r MAJOR MINOR MICRO <<<"$VERSION" + +while IFS= read -r manifest; do + [ -n "$manifest" ] || continue + tmp=$(mktemp) + jq --arg v "$VERSION" '.acapPackageConf.setup.version = $v' "$manifest" >"$tmp" + mv "$tmp" "$manifest" + echo "version $VERSION -> $manifest" +done < <(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort) + +while IFS= read -r conf; do + [ -n "$conf" ] || continue + sed -i.bak -E \ + -e "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" \ + -e "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" \ + -e "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" \ + -e "s/^VERSION=.*/VERSION=${VERSION}/" \ + "$conf" + rm -f "$conf.bak" + echo "version $VERSION -> $conf" +done < <(find . -path '*/app/package.conf' | sort) + +pin_count=$(cfg '.pins | length') +for ((i = 0; i < pin_count; i++)); do + file=$(cfg ".pins[$i].file") + arg=$(cfg ".pins[$i].arg") + prefix=$(cfg ".pins[$i].prefix // empty") + sha_url=$(cfg ".pins[$i].sha256Url // empty") + [ -f "$file" ] || { + echo "pin target missing: $file" >&2 + continue + } + + if [ -n "$sha_url" ]; then + # Checksum pins track the version pin, so the tarball is fetched and + # hashed rather than substituted. + url=${sha_url//\$\{VERSION\}/${UPSTREAM:-$VERSION}} + echo "hashing $url" + value=$(curl -fsSL "$url" | sha256sum | awk '{print $1}') + else + value="${prefix}${UPSTREAM:-$VERSION}" + fi + + sed -i.bak -E "s|^ARG ${arg}=.*|ARG ${arg}=${value}|" "$file" + rm -f "$file.bak" + echo "pin ${arg}=${value} -> $file" +done + +module=$(cfg '.upstream.module // empty') +gomod=$(cfg '.upstream.goMod // empty') +if [ -n "$module" ] && [ -n "$UPSTREAM" ] && [ -f "$gomod" ]; then + (cd "$(dirname "$gomod")" && go get "${module}@${UPSTREAM}" && go mod tidy) + echo "go module ${module}@${UPSTREAM}" +fi + +if [ -f CHANGELOG.md ] && ! grep -qE "^## \[?${VERSION}\]?" CHANGELOG.md; then + first_heading=$(grep -n -m1 '^## ' CHANGELOG.md | cut -d: -f1 || true) + tmp=$(mktemp) + { + if [ -n "$first_heading" ]; then + head -n "$((first_heading - 1))" CHANGELOG.md + else + cat CHANGELOG.md + echo + fi + echo "## ${VERSION} - $(date +%Y-%m-%d)" + echo + if [ -n "$UPSTREAM" ]; then + echo "- Update to upstream ${UPSTREAM}." + else + echo "- Release ${VERSION}." + fi + echo + [ -n "$first_heading" ] && tail -n +"$first_heading" CHANGELOG.md + } >"$tmp" + mv "$tmp" CHANGELOG.md + echo "changelog entry added for $VERSION" +fi diff --git a/ci/build-packages.sh b/ci/build-packages.sh new file mode 100755 index 0000000..024b92a --- /dev/null +++ b/ci/build-packages.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Run the repo's build and collect every .eap into releases/. +# The build command and extra env come from .acap.json. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +CONFIG=.acap.json +cfg() { jq -r "$1" "$CONFIG"; } + +COMMAND=$(cfg '.build.command') + +while IFS=$'\t' read -r key value; do + [ -n "$key" ] || continue + value=${value//\$\{VERSION\}/${VERSION:-}} + export "$key=$value" + echo "env $key=$value" +done < <(cfg '.build.env | to_entries[]? | [.key, .value] | @tsv') + +rm -rf releases +mkdir -p releases + +echo "== $COMMAND" +eval "$COMMAND" + +# Repos drop packages in the root, build/, build_/ or releases/ depending +# on the repo, so collect from anywhere except releases/ itself. +found=0 +while IFS= read -r package; do + [ -n "$package" ] || continue + mv "$package" releases/ + found=$((found + 1)) +done < <(find . -name '*.eap' -not -path './releases/*' -not -path './.git/*') + +[ "$found" -gt 0 ] || { + echo "no .eap produced" >&2 + exit 1 +} + +echo "collected $found package(s):" +ls -lh releases/ diff --git a/ci/release-notes.sh b/ci/release-notes.sh new file mode 100755 index 0000000..87d9654 --- /dev/null +++ b/ci/release-notes.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Generate release notes for a draft release. +# +# Usage: ci/release-notes.sh [upstream-version] > notes.md + +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION=${1:?version required} +UPSTREAM=${2:-} + +CONFIG=.acap.json +cfg() { jq -r "$1" "$CONFIG"; } + +FRIENDLY=$(cfg '.friendlyName') +UPSTREAM_NAME=$(cfg '.upstream.name // .upstream.repo // .upstream.module // empty') +CHANGES_URL=$(cfg '.upstream.changesUrl // empty') +CHANGES_URL=${CHANGES_URL//\$\{UPSTREAM\}/$UPSTREAM} + +# Previous tag, so the compare link points somewhere useful. +PREVIOUS=$(git tag --list 'v*' --sort=-v:refname | grep -v "^v${VERSION}$" | head -1 || true) + +printf '%s %s\n\n' "$FRIENDLY" "$VERSION" + +if [ -n "$UPSTREAM" ] && [ -n "$UPSTREAM_NAME" ]; then + printf 'Packages **%s `%s`**.\n\n' "$UPSTREAM_NAME" "$UPSTREAM" +fi + +if [ -n "$CHANGES_URL" ]; then + printf '### Upstream changes\n\n%s\n\n' "$CHANGES_URL" +fi + +if [ -f CHANGELOG.md ]; then + # Pull just this version's section out of the changelog. + section=$(awk -v v="$VERSION" ' + $0 ~ "^## \\[?" v "\\]?" { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md | sed '/^[[:space:]]*$/d') + if [ -n "$section" ]; then + printf '### Changes\n\n%s\n\n' "$section" + fi +fi + +cat <<'EOF' +### Packages + +Install the `signed_*.eap` matching your device architecture. Packages ending +`_acap3` or `_root` are published unsigned by design: ACAP 3 packages use +manifest schema 1.x, which the Axis signing service does not accept. + +EOF + +if [ -n "$PREVIOUS" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then + printf '**Full changelog**: https://github.com/%s/compare/%s...v%s\n' \ + "$GITHUB_REPOSITORY" "$PREVIOUS" "$VERSION" +fi diff --git a/ci/resolve-version.sh b/ci/resolve-version.sh new file mode 100755 index 0000000..0e1941c --- /dev/null +++ b/ci/resolve-version.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# Decide which version this repo should build, from .acap.json. +# Writes build/release/version/upstream to GITHUB_OUTPUT under CI, and always +# prints the decision so it can be run locally to preview. +# +# Policies: +# mirror the ACAP version follows the upstream version exactly. +# patch upstream is tracked through a pin; our own last digit is bumped. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +CONFIG=.acap.json +[ -f "$CONFIG" ] || { + echo "missing $CONFIG" >&2 + exit 1 +} + +cfg() { jq -r "$1" "$CONFIG"; } + +POLICY=$(cfg '.versionPolicy') +UPSTREAM_TYPE=$(cfg '.upstream.type') +EVENT_NAME=${EVENT_NAME:-manual} +INPUT_VERSION=${INPUT_VERSION:-} +INPUT_FORCE=${INPUT_FORCE:-false} + +current_version() { + local manifest conf + manifest=$(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort | head -1) + if [ -n "$manifest" ]; then + jq -r '.acapPackageConf.setup.version' "$manifest" + return + fi + conf=$(find . -path '*/app/package.conf' | sort | head -1) + [ -n "$conf" ] && sed -n 's/^VERSION=//p' "$conf" | head -1 +} + +# Current value of the first pin, used by "patch" to detect upstream movement. +pin_value() { + local file arg gomod module + file=$(cfg '.pins[0].file // empty') + arg=$(cfg '.pins[0].arg // empty') + if [ -n "$file" ] && [ -n "$arg" ] && [ -f "$file" ]; then + sed -n "s/^ARG ${arg}=//p" "$file" | head -1 + return + fi + gomod=$(cfg '.upstream.goMod // empty') + module=$(cfg '.upstream.module // empty') + if [ -n "$gomod" ] && [ -f "$gomod" ]; then + # The module may appear as "require mod ver" or as "mod ver" inside a + # require block, so take the field after the module name wherever it is. + awk -v m="$module" '{ for (i = 1; i < NF; i++) if ($i == m) { print $(i + 1); exit } }' "$gomod" + fi +} + +# FFmpeg and openvpn3 publish no releases, and their tag lists contain names +# that are not versions, hence the explicit pattern per repo. +upstream_version() { + case "$UPSTREAM_TYPE" in + github-release) + local tag + tag=$(gh api "repos/$(cfg '.upstream.repo')/releases/latest" --jq '.tag_name') + [ "$(cfg '.upstream.stripV // false')" = true ] && tag=${tag#v} + printf '%s\n' "$tag" + ;; + github-tag) + gh api "repos/$(cfg '.upstream.repo')/tags?per_page=100" --paginate --jq '.[].name' | + grep -E "$(cfg '.upstream.tagPattern')" | + sed "s|^$(cfg '.upstream.strip // empty')||" | + sort -V | tail -1 + ;; + go-module) + curl -fsSL "https://proxy.golang.org/$(cfg '.upstream.module')/@latest" | jq -r '.Version' + ;; + script) + bash "$(cfg '.upstream.script')" + ;; + *) + echo '' + ;; + esac +} + +bump_patch() { + local major minor patch + IFS='.' read -r major minor patch <<<"$1" + printf '%s.%s.%s\n' "${major:-0}" "${minor:-0}" "$((${patch:-0} + 1))" +} + +CURRENT=$(current_version) +UPSTREAM=$(upstream_version || true) + +BUILD=false +RELEASE=false +TARGET="$CURRENT" + +if [ -n "$INPUT_VERSION" ]; then + TARGET=${INPUT_VERSION#v} + BUILD=true + RELEASE=true +elif [ "$POLICY" = mirror ]; then + if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$CURRENT" ]; then + TARGET="$UPSTREAM" + BUILD=true + RELEASE=true + fi +elif [ "$POLICY" = patch ]; then + if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$(pin_value)" ]; then + TARGET=$(bump_patch "$CURRENT") + BUILD=true + RELEASE=true + fi +fi + +# Pull requests build for validation but never release. +if [ "$EVENT_NAME" = pull_request ]; then + BUILD=true + RELEASE=false +fi + +if [ "$INPUT_FORCE" = true ]; then + BUILD=true + RELEASE=true +fi + +cat <>"$GITHUB_OUTPUT" +fi diff --git a/ci/upstream-version.sh b/ci/upstream-version.sh new file mode 100755 index 0000000..9ddc521 --- /dev/null +++ b/ci/upstream-version.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +# Resolve the Tailscale version to package. +# +# Tailscale's GitHub "latest" release sometimes lands before the static ARM +# tarballs are published, so fall back to the newest version that actually has +# an ARM package on pkgs.tailscale.com. +set -eu + +GH_VERSION=$(curl -fsS https://api.github.com/repos/tailscale/tailscale/releases/latest \ + | sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1) + +if [ -n "${GH_VERSION}" ] && \ + curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" >/dev/null 2>&1; then + printf '%s\n' "${GH_VERSION}" + exit 0 +fi + +curl -fsS https://pkgs.tailscale.com/stable/ \ + | grep -o 'tailscale_[0-9.]*_arm\.tgz' \ + | sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' \ + | sort -V | tail -1