diff --git a/.github/workflows/apt-publish.yml b/.github/workflows/apt-publish.yml new file mode 100644 index 0000000..ce32022 --- /dev/null +++ b/.github/workflows/apt-publish.yml @@ -0,0 +1,125 @@ +# Reusable APT publication: dispatches package-updated to an APT repository. +# +# channel: unstable — call from main.yml after stage-release.yml, passing its +# prerelease-tag. +# channel: stable — call from release.yml on `release: published`. The job +# skips pre-releases and requires a v+ tag. +# +# Either way the release must carry assets, so a release staged without +# packages is never dispatched. +# +# The APT repository fetches the .deb assets from this repository's releases. + +name: APT publish + +on: + workflow_call: + inputs: + channel: + description: 'unstable or stable' + required: true + type: string + apt-repository: + description: 'APT repository to dispatch to (e.g. halos-org/apt.halos.fi)' + required: true + type: string + apt-distro: + description: 'APT distribution (e.g. trixie, any)' + required: true + type: string + apt-component: + description: 'APT component (e.g. main, hatlabs)' + required: true + type: string + prerelease-tag: + description: 'Unstable channel: the pre-release tag from release-version.yml' + required: false + default: '' + type: string + runs-on: + description: 'Runner to use' + required: false + default: 'ubuntu-latest' + type: string + secrets: + APT_REPO_PAT: + description: 'Token allowed to send repository_dispatch to apt-repository' + required: true + +permissions: + contents: read + +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + +jobs: + apt-publish: + if: ${{ !(github.event_name == 'release' && github.event.release.prerelease) }} + runs-on: ${{ inputs.runs-on }} + env: + CHANNEL: ${{ inputs.channel }} + PRERELEASE_TAG: ${{ inputs.prerelease-tag }} + steps: + - name: Validate channel + env: + EVENT: ${{ github.event_name }} + run: | + case "$CHANNEL" in + unstable) + if [ -z "$PRERELEASE_TAG" ]; then + echo "::error::The unstable channel needs prerelease-tag" + exit 1 + fi + ;; + stable) + if [ "$EVENT" != release ]; then + echo "::error::The stable channel is published from a release event, not $EVENT" + exit 1 + fi + ;; + *) + echo "::error::channel must be unstable or stable, got '$CHANNEL'" + exit 1 + ;; + esac + + - name: Verify release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + # Tag formats written by release-version.yml. + if [ "$CHANNEL" = stable ]; then + TAG=$RELEASE_TAG + PATTERN='^v([0-9][0-9A-Za-z.~-]*)\+([0-9]+)$' + else + TAG=$PRERELEASE_TAG + PATTERN='^v([0-9][0-9A-Za-z.~-]*)\+([0-9]+)_pre$' + fi + if ! [[ $TAG =~ $PATTERN ]]; then + echo "::error::Release tag '$TAG' does not match the $CHANNEL tag format" + exit 1 + fi + echo "Debian version: ${BASH_REMATCH[1]}-${BASH_REMATCH[2]}" + ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length') + if [ "$ASSET_COUNT" -eq 0 ]; then + echo "::error::Release $TAG has no assets; pass build-deb.yml's artifact output to stage-release.yml" + exit 1 + fi + echo "Release $TAG has $ASSET_COUNT asset(s)" + + - name: Dispatch to APT repository + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.APT_REPO_PAT }} + repository: ${{ inputs.apt-repository }} + event-type: package-updated + client-payload: | + { + "repository": "${{ github.repository }}", + "distro": "${{ inputs.apt-distro }}", + "channel": "${{ inputs.channel }}", + "component": "${{ inputs.apt-component }}" + } diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 166baff..c4e8180 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -4,8 +4,19 @@ # on every package found in the repository root or build/. A build that # produces no package fails. # +# Check mode (PRs): call without release inputs. Nothing is uploaded. +# +# Release mode (main): pass revision and the other release inputs, normally +# from release-version.yml. The workflow then generates debian/changelog +# before building, renames packages with a ++ suffix, and +# uploads them as the artifact named in the `artifact` output. +# # Requirements: # - .github/actions/build-deb/action.yml in the caller repository +# +# Optional caller scripts: +# - .github/scripts/generate-changelog.sh --upstream --revision +# - .github/scripts/rename-packages.sh --version --distro --component name: Build Debian package @@ -22,16 +33,129 @@ on: required: false default: true type: boolean + revision: + description: 'Release mode: revision N. Leave empty for check mode.' + required: false + default: '' + type: string + upstream-version: + description: 'Release mode: upstream version' + required: false + default: '' + type: string + package-name: + description: 'Release mode: Debian package name used in the changelog and default rename' + required: false + default: '' + type: string + apt-distro: + description: 'Release mode: APT distribution for the package name suffix (e.g. trixie, any)' + required: false + default: '' + type: string + apt-component: + description: 'Release mode: APT component for the package name suffix (e.g. main, hatlabs)' + required: false + default: '' + type: string + maintainer-name: + description: 'Release mode: maintainer name for debian/changelog' + required: false + default: '' + type: string + maintainer-email: + description: 'Release mode: maintainer email for debian/changelog' + required: false + default: '' + type: string + outputs: + artifact: + description: 'Release mode: name of the uploaded package artifact. Empty in check mode.' + value: ${{ jobs.build-deb.outputs.artifact }} permissions: contents: read +env: + ARTIFACT_NAME: deb-packages + +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + jobs: build-deb: runs-on: ${{ inputs.runs-on }} + outputs: + artifact: ${{ steps.upload.outputs.artifact-id && env.ARTIFACT_NAME || '' }} + env: + RELEASE_MODE: ${{ inputs.revision != '' }} + REVISION: ${{ inputs.revision }} + UPSTREAM: ${{ inputs.upstream-version }} + PACKAGE_NAME: ${{ inputs.package-name }} + APT_DISTRO: ${{ inputs.apt-distro }} + APT_COMPONENT: ${{ inputs.apt-component }} + MAINTAINER_NAME: ${{ inputs.maintainer-name }} + MAINTAINER_EMAIL: ${{ inputs.maintainer-email }} steps: + - name: Validate inputs + run: | + given=() missing=() + for name in UPSTREAM PACKAGE_NAME APT_DISTRO APT_COMPONENT MAINTAINER_NAME MAINTAINER_EMAIL; do + if [ -n "${!name}" ]; then given+=("$name"); else missing+=("$name"); fi + done + if [ "$RELEASE_MODE" = true ] && [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Release mode (revision set) also needs: ${missing[*]}" + exit 1 + fi + if [ "$RELEASE_MODE" != true ] && [ "${#given[@]}" -gt 0 ]; then + echo "::error::Release inputs given without revision: ${given[*]}. Pass revision for release mode, or none of them for check mode." + exit 1 + fi + echo "Release mode: $RELEASE_MODE" + - name: Checkout code uses: actions/checkout@v4 + with: + # Release mode reads tags for the changelog. + fetch-depth: ${{ inputs.revision == '' && 1 || 0 }} + persist-credentials: false + + - name: Generate debian/changelog + if: inputs.revision != '' + run: | + if [ -f .github/scripts/generate-changelog.sh ]; then + echo "Using local generate-changelog.sh" + .github/scripts/generate-changelog.sh --upstream "$UPSTREAM" --revision "$REVISION" + exit 0 + fi + + # lintian's debian-changelog-line-too-long warning is fatal, so fold + # commit subjects into bullets that fit in 80 columns. + wrap_changes() { + while IFS= read -r subject; do + [ -z "$subject" ] && continue + printf '%s\n' "$subject" \ + | fold -s -w 76 \ + | sed -e 's/[[:space:]]*$//' -e '1s/^/ * /' -e '1!s/^/ /' + done + } + + LAST_TAG=$(git tag --merged HEAD -l "v*" --sort=-version:refname | grep -v "_pre" | head -n1 || true) + if [ -n "$LAST_TAG" ]; then + CHANGES=$(git log "${LAST_TAG}..HEAD" --pretty=tformat:"%s" --no-merges -- | wrap_changes) + else + CHANGES=$(git log -10 --pretty=tformat:"%s" --no-merges | wrap_changes) + fi + [ -z "$CHANGES" ] && CHANGES=" * Build ${REVISION}" + + { + printf '%s (%s-%s) unstable; urgency=medium\n\n' "$PACKAGE_NAME" "$UPSTREAM" "$REVISION" + printf '%s\n\n' "$CHANGES" + printf ' -- %s <%s> %s\n' "$MAINTAINER_NAME" "$MAINTAINER_EMAIL" "$(date -R)" + } > debian/changelog + cat debian/changelog - name: Build .deb package uses: ./.github/actions/build-deb @@ -73,3 +197,36 @@ jobs: echo "::error::Lintian found issues. To suppress specific tags, create debian/.lintian-overrides" exit 1 fi + + - name: Rename packages with distro and component suffix + if: inputs.revision != '' + run: | + DEBIAN_VERSION="${UPSTREAM}-${REVISION}" + if [ -f .github/scripts/rename-packages.sh ]; then + echo "Using local rename-packages.sh" + .github/scripts/rename-packages.sh --version "$DEBIAN_VERSION" --distro "$APT_DISTRO" --component "$APT_COMPONENT" + exit 0 + fi + + OLD_NAME="${PACKAGE_NAME}_${DEBIAN_VERSION}_all.deb" + NEW_NAME="${PACKAGE_NAME}_${DEBIAN_VERSION}_all+${APT_DISTRO}+${APT_COMPONENT}.deb" + for dir in . build; do + if [ -f "$dir/$OLD_NAME" ]; then + mv "$dir/$OLD_NAME" "$dir/$NEW_NAME" + echo "Renamed $dir/$OLD_NAME -> $dir/$NEW_NAME" + exit 0 + fi + done + echo "::error::Expected package $OLD_NAME not found in the repository root or build/" + exit 1 + + - name: Upload packages + id: upload + if: inputs.revision != '' + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: | + *.deb + build/*.deb + if-no-files-found: error diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9960012..17d1c34 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,6 +21,11 @@ on: permissions: contents: read +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + jobs: tests: runs-on: ${{ inputs.runs-on }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a25125..b892ca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Shellcheck - run: shellcheck scripts/*.sh tests/*.sh + run: shellcheck -x scripts/*.sh tests/*.sh tests/lib/*.sh tests/lib/fake-gh - name: Run script tests run: | diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml new file mode 100644 index 0000000..366a7a9 --- /dev/null +++ b/.github/workflows/release-version.yml @@ -0,0 +1,123 @@ +# Reusable release version calculation. +# +# Reads VERSION and finds the revision N for this commit: the revision of a +# v+N_pre tag that already points at HEAD (a rerun resumes that +# release), otherwise the highest N over existing v+N and +# v+N_pre tags plus one, or 1 when none exist. +# +# Releases are cut only from the default branch, because the tags +# stage-release.yml creates point at the commit this run built. Callers must +# serialize main runs with a concurrency group, or two runs can compute the +# same N; stage-release.yml then fails the second one. A run for a commit +# older than an already released commit fails. + +name: Release version + +on: + workflow_call: + inputs: + version-file: + description: 'Path to the VERSION file' + required: false + default: 'VERSION' + type: string + runs-on: + description: 'Runner to use' + required: false + default: 'ubuntu-latest' + type: string + outputs: + upstream-version: + description: 'Upstream version from the VERSION file, without a v prefix' + value: ${{ jobs.version.outputs.upstream-version }} + revision: + description: 'Revision number N for this commit' + value: ${{ jobs.version.outputs.revision }} + debian-version: + description: '-' + value: ${{ jobs.version.outputs.debian-version }} + prerelease-tag: + description: 'v+_pre' + value: ${{ jobs.version.outputs.prerelease-tag }} + stable-tag: + description: 'v+' + value: ${{ jobs.version.outputs.stable-tag }} + +permissions: + contents: read + +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + +jobs: + version: + runs-on: ${{ inputs.runs-on }} + outputs: + upstream-version: ${{ steps.version.outputs.upstream }} + revision: ${{ steps.version.outputs.revision }} + debian-version: ${{ steps.version.outputs.debian_version }} + prerelease-tag: ${{ steps.version.outputs.prerelease_tag }} + stable-tag: ${{ steps.version.outputs.stable_tag }} + steps: + - name: Require the default branch + env: + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Releases are cut only from $DEFAULT_BRANCH; this run is on $REF" + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Calculate version and revision + id: version + env: + VERSION_FILE: ${{ inputs.version-file }} + run: | + UPSTREAM=$(tr -d '[:space:]' < "$VERSION_FILE") + UPSTREAM="${UPSTREAM#v}" + # Debian upstream-version characters, without + and _ which the tags use. + if ! [[ $UPSTREAM =~ ^[0-9][0-9A-Za-z.~-]*$ ]]; then + echo "::error::$VERSION_FILE must hold a version such as 1.2.3, got '$UPSTREAM'" + exit 1 + fi + + # A run for a commit older than an existing release (for example a + # re-run of an old main run) would release stale code under a + # higher revision. + NEWER_TAG=$(comm -23 <(git tag --contains HEAD -l "v*+*" | sort) \ + <(git tag --points-at HEAD -l "v*+*" | sort) | head -n1) + if [ -n "$NEWER_TAG" ]; then + echo "::error::A newer commit is already released as $NEWER_TAG; this run is for an older commit" + exit 1 + fi + + RELEASED_TAG=$(git tag --points-at HEAD -l "v${UPSTREAM}+*_pre" | sort -V | tail -n1) + if [[ $RELEASED_TAG =~ \+([0-9]+)_pre$ ]]; then + REVISION="${BASH_REMATCH[1]}" + echo "This commit already has $RELEASED_TAG; resuming that release" + else + MAX_REVISION=0 + while IFS= read -r tag; do + if [[ $tag =~ \+([0-9]+)(_.*)?$ ]] && [ "${BASH_REMATCH[1]}" -gt "$MAX_REVISION" ]; then + MAX_REVISION="${BASH_REMATCH[1]}" + fi + done < <(git tag -l "v${UPSTREAM}+*") + REVISION=$((MAX_REVISION + 1)) + fi + + { + echo "upstream=$UPSTREAM" + echo "revision=$REVISION" + echo "debian_version=${UPSTREAM}-${REVISION}" + echo "prerelease_tag=v${UPSTREAM}+${REVISION}_pre" + echo "stable_tag=v${UPSTREAM}+${REVISION}" + } | tee -a "$GITHUB_OUTPUT" diff --git a/.github/workflows/stage-release.yml b/.github/workflows/stage-release.yml new file mode 100644 index 0000000..b2c6841 --- /dev/null +++ b/.github/workflows/stage-release.yml @@ -0,0 +1,228 @@ +# Reusable release staging: a pre-release and a draft stable release. +# +# Creates v+_pre as a published pre-release and v+ +# as a draft, both targeting the commit this run built, and deletes every +# other draft release. A rerun completes what an earlier run of the same +# commit left unfinished: an existing release for this commit is kept when it +# is complete and replaced when it is not. The workflow fails instead when a +# release with a higher version already exists or a release with these tags +# belongs to another commit. Publishing the draft starts the caller's +# release.yml. +# +# Pass `artifact` to attach the .deb packages uploaded by build-deb.yml; the +# workflow then fails if the artifact holds no package. Without it, releases +# carry notes only. +# +# Optional caller script: +# - .github/scripts/generate-release-notes.sh prerelease|draft +# writing release_notes.md + +name: Stage release + +on: + workflow_call: + inputs: + package-name: + description: 'Package name shown in the release notes' + required: true + type: string + package-description: + description: 'Short description for the stable release notes' + required: false + default: '' + type: string + debian-version: + description: '-, from release-version.yml' + required: true + type: string + prerelease-tag: + description: 'v+_pre, from release-version.yml' + required: true + type: string + stable-tag: + description: 'v+, from release-version.yml' + required: true + type: string + artifact: + description: 'Artifact with .deb packages to attach, from build-deb.yml. Empty for notes-only releases.' + required: false + default: '' + type: string + apt-repository: + description: 'APT repository (owner/name, named after its host) mentioned in the notes when packages are attached' + required: false + default: '' + type: string + apt-distro: + description: 'APT distribution mentioned in the notes' + required: false + default: '' + type: string + apt-component: + description: 'APT component mentioned in the notes' + required: false + default: '' + type: string + runs-on: + description: 'Runner to use' + required: false + default: 'ubuntu-latest' + type: string + +permissions: + contents: write + +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + +jobs: + stage-release: + runs-on: ${{ inputs.runs-on }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PACKAGE_NAME: ${{ inputs.package-name }} + PACKAGE_DESCRIPTION: ${{ inputs.package-description }} + DEBIAN_VERSION: ${{ inputs.debian-version }} + PRERELEASE_TAG: ${{ inputs.prerelease-tag }} + STABLE_TAG: ${{ inputs.stable-tag }} + APT_REPOSITORY: ${{ inputs.apt-repository }} + APT_DISTRO: ${{ inputs.apt-distro }} + APT_COMPONENT: ${{ inputs.apt-component }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download packages + if: inputs.artifact != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: ${{ runner.temp }}/release-assets + + - name: List assets + id: assets + env: + ARTIFACT: ${{ inputs.artifact }} + ASSET_DIR: ${{ runner.temp }}/release-assets + run: | + : > "$RUNNER_TEMP/assets.txt" + if [ -z "$ARTIFACT" ]; then + echo "Notes-only release" + echo "has_packages=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + find "$ASSET_DIR" -name '*.deb' -type f | sort > "$RUNNER_TEMP/assets.txt" + if [ ! -s "$RUNNER_TEMP/assets.txt" ]; then + echo "::error::Artifact $ARTIFACT contains no .deb" + exit 1 + fi + cat "$RUNNER_TEMP/assets.txt" + echo "has_packages=true" >> "$GITHUB_OUTPUT" + + - name: Write release notes + env: + HAS_PACKAGES: ${{ steps.assets.outputs.has_packages }} + run: | + write_notes() { # + local tag=$1 kind=$2 out=$3 + if [ -f .github/scripts/generate-release-notes.sh ]; then + .github/scripts/generate-release-notes.sh "$DEBIAN_VERSION" "$tag" "$kind" + mv release_notes.md "$out" + return + fi + + local last_tag changes + last_tag=$(gh release list --limit 100 --json tagName,isPrerelease,isDraft \ + --jq '.[] | select(.isDraft == false and .isPrerelease == false) | .tagName' | head -n1 || true) + if [ -n "$last_tag" ]; then + changes=$(git log "${last_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges --) + else + changes=$(git log -10 --pretty=format:"- %s (%h)" --no-merges) + fi + + { + if [ "$kind" = prerelease ]; then + printf '## %s %s (Pre-release)\n\n' "$PACKAGE_NAME" "$tag" + printf '> **This is a pre-release build from the main branch. Use for testing only.**\n\n' + printf -- '- Commit: `%s`\n- Built: %s\n\n' "$GITHUB_SHA" "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + printf '### Recent Changes\n\n%s\n' "$changes" + else + printf '## %s %s\n\n' "$PACKAGE_NAME" "$tag" + [ -n "$PACKAGE_DESCRIPTION" ] && printf '%s\n\n' "$PACKAGE_DESCRIPTION" + printf '### Changes\n\n%s\n' "$changes" + fi + + if [ "$HAS_PACKAGES" = true ] && [ -n "$APT_REPOSITORY" ]; then + local host=${APT_REPOSITORY#*/} channel=stable + [ "$kind" = prerelease ] && channel=unstable + printf '\n### Installation\n\n' + printf 'Available from [%s](https://%s) (distribution `%s`, channel `%s`, component `%s`). ' \ + "$host" "$host" "$APT_DISTRO" "$channel" "$APT_COMPONENT" + printf 'See that page for repository setup, then:\n\n' + printf '```bash\nsudo apt update\nsudo apt install %s\n```\n' "$PACKAGE_NAME" + fi + } > "$out" + } + + write_notes "$PRERELEASE_TAG" prerelease "$RUNNER_TEMP/prerelease-notes.md" + write_notes "$STABLE_TAG" draft "$RUNNER_TEMP/draft-notes.md" + cat "$RUNNER_TEMP/prerelease-notes.md" "$RUNNER_TEMP/draft-notes.md" + + - name: Stage releases + run: | + mapfile -t ASSETS < "$RUNNER_TEMP/assets.txt" + + # Release tags with a revision, _pre suffix removed. + ALL_RELEASES=$(gh release list --limit 1000 --json tagName,isDraft) + NEWEST=$(jq -r '.[].tagName | select(test("^v.+\\+[0-9]+(_pre)?$")) | sub("_pre$"; "")' <<< "$ALL_RELEASES" \ + | { cat; echo "$STABLE_TAG"; } | sort -V | tail -n1) + if [ "$NEWEST" != "$STABLE_TAG" ]; then + echo "::error::Release $NEWEST is newer than $STABLE_TAG; refusing to stage an older build" + exit 1 + fi + + # ensure_release + ensure_release() { + local tag=$1 kind=$2 notes=$3 info err target draft assets + err=$(mktemp) + if info=$(gh release view "$tag" --json isDraft,targetCommitish,assets 2> "$err"); then + target=$(jq -r .targetCommitish <<< "$info") + draft=$(jq -r .isDraft <<< "$info") + assets=$(jq '.assets | length' <<< "$info") + if [ "$target" != "$GITHUB_SHA" ]; then + echo "::error::Release $tag targets $target, not this commit $GITHUB_SHA" + return 1 + fi + if [ "$assets" -eq "${#ASSETS[@]}" ] && { [ "$kind" = draft ] || [ "$draft" = false ]; }; then + echo "Release $tag is already complete" + return 0 + fi + echo "Release $tag is incomplete (draft=$draft, assets=$assets); replacing it" + gh release delete "$tag" --yes + elif ! grep -q "release not found" "$err"; then + cat "$err" >&2 + return 1 + fi + + local flags=(--draft --title "$tag") + [ "$kind" = prerelease ] && flags=(--prerelease --title "$tag (Pre-release)") + gh release create "$tag" "${flags[@]}" --target "$GITHUB_SHA" --notes-file "$notes" "${ASSETS[@]}" + } + + ensure_release "$PRERELEASE_TAG" prerelease "$RUNNER_TEMP/prerelease-notes.md" + + DRAFTS=$(gh release list --limit 1000 --json tagName,isDraft \ + | jq -r --arg keep "$STABLE_TAG" '.[] | select(.isDraft and .tagName != $keep) | .tagName') + while IFS= read -r tag; do + [ -z "$tag" ] && continue + echo "Deleting draft release $tag" + gh release delete "$tag" --yes + done <<< "$DRAFTS" + + ensure_release "$STABLE_TAG" draft "$RUNNER_TEMP/draft-notes.md" diff --git a/examples/deb/.github/workflows/main.yml b/examples/deb/.github/workflows/main.yml new file mode 100644 index 0000000..ef28a3e --- /dev/null +++ b/examples/deb/.github/workflows/main.yml @@ -0,0 +1,69 @@ +name: Main branch release + +on: + push: + branches: [main] + workflow_dispatch: + +# Required: release-version.yml computes the next revision from existing tags, +# so two concurrent runs would compute the same one. +concurrency: + group: ${{ github.repository }}-main-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Optional: omit for repositories without tests. + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/run-tests + + version: + needs: tests + uses: halos-org/shared-workflows/.github/workflows/release-version.yml@v1 + + build-deb: + needs: version + uses: halos-org/shared-workflows/.github/workflows/build-deb.yml@v1 + with: + package-name: my-package + upstream-version: ${{ needs.version.outputs.upstream-version }} + revision: ${{ needs.version.outputs.revision }} + apt-distro: trixie + apt-component: main + maintainer-name: Hat Labs Oy + maintainer-email: info@hatlabs.fi + + stage-release: + needs: [version, build-deb] + permissions: + contents: write + uses: halos-org/shared-workflows/.github/workflows/stage-release.yml@v1 + with: + package-name: my-package + package-description: One-line description of my-package + debian-version: ${{ needs.version.outputs.debian-version }} + prerelease-tag: ${{ needs.version.outputs.prerelease-tag }} + stable-tag: ${{ needs.version.outputs.stable-tag }} + artifact: ${{ needs.build-deb.outputs.artifact }} + apt-repository: halos-org/apt.halos.fi + apt-distro: trixie + apt-component: main + + apt-publish: + needs: [version, stage-release] + uses: halos-org/shared-workflows/.github/workflows/apt-publish.yml@v1 + with: + channel: unstable + prerelease-tag: ${{ needs.version.outputs.prerelease-tag }} + apt-repository: halos-org/apt.halos.fi + apt-distro: trixie + apt-component: main + secrets: + APT_REPO_PAT: ${{ secrets.APT_REPO_PAT }} diff --git a/examples/deb/.github/workflows/release.yml b/examples/deb/.github/workflows/release.yml new file mode 100644 index 0000000..c39b5fd --- /dev/null +++ b/examples/deb/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: Publish stable release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + apt-publish: + uses: halos-org/shared-workflows/.github/workflows/apt-publish.yml@v1 + with: + channel: stable + apt-repository: halos-org/apt.halos.fi + apt-distro: trixie + apt-component: main + secrets: + APT_REPO_PAT: ${{ secrets.APT_REPO_PAT }} diff --git a/tests/apt-publish.test.sh b/tests/apt-publish.test.sh new file mode 100755 index 0000000..c4a4b5b --- /dev/null +++ b/tests/apt-publish.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Tests for .github/workflows/apt-publish.yml step scripts. +set -euo pipefail +# shellcheck source=tests/lib/step.sh +source "$(dirname "$0")/lib/step.sh" + +CHANNEL_STEP=$(extract_step apt-publish.yml apt-publish "Validate channel") +VERIFY_STEP=$(extract_step apt-publish.yml apt-publish "Verify release") +stub_gh + +test_channel_validation() { + in_temp_dir + CHANNEL=unstable EVENT=push PRERELEASE_TAG=v1.0.0+1_pre check_status "unstable from push passes" 0 run_step "$CHANNEL_STEP" + CHANNEL=unstable EVENT=push PRERELEASE_TAG='' check_status "unstable without prerelease-tag fails" 1 run_step "$CHANNEL_STEP" + CHANNEL=stable EVENT=release PRERELEASE_TAG='' check_status "stable from release passes" 0 run_step "$CHANNEL_STEP" + CHANNEL=stable EVENT=push PRERELEASE_TAG='' check_status "stable from push fails" 1 run_step "$CHANNEL_STEP" + CHANNEL=testing EVENT=push PRERELEASE_TAG='' check_status "unknown channel fails" 1 run_step "$CHANNEL_STEP" +} + +test_stable_tag_formats() { + in_temp_dir + local tag + for tag in v0.3.2+4 v1.0.7.2+12 v2026.08.20+1; do + CHANNEL=stable RELEASE_TAG=$tag GH_STDOUT=1 check_status "stable tag $tag passes" 0 run_step "$VERIFY_STEP" + done + for tag in v0.3.2+4_pre 0.3.2+4 v0.3.2 v0.3.2+x; do + CHANNEL=stable RELEASE_TAG=$tag GH_STDOUT=1 check_status "stable tag $tag fails" 1 run_step "$VERIFY_STEP" + done +} + +test_unstable_uses_prerelease_tag() { + in_temp_dir + CHANNEL=unstable PRERELEASE_TAG=v0.3.2+4_pre GH_STDOUT=2 check_status "unstable pre-release with assets passes" 0 run_step "$VERIFY_STEP" + CHANNEL=unstable PRERELEASE_TAG=v0.3.2+4 GH_STDOUT=2 check_status "unstable with a stable tag fails" 1 run_step "$VERIFY_STEP" +} + +test_release_needs_assets() { + in_temp_dir + CHANNEL=stable RELEASE_TAG=v0.3.2+4 GH_STDOUT=0 check_status "stable without assets fails" 1 run_step "$VERIFY_STEP" + CHANNEL=unstable PRERELEASE_TAG=v0.3.2+4_pre GH_STDOUT=0 check_status "unstable without assets fails" 1 run_step "$VERIFY_STEP" + CHANNEL=stable RELEASE_TAG=v0.3.2+4 GH_STDOUT=0 GH_EXIT=1 check_status "gh failure fails" 1 run_step "$VERIFY_STEP" +} + +run_tests diff --git a/tests/build-deb.test.sh b/tests/build-deb.test.sh new file mode 100755 index 0000000..a0a9e61 --- /dev/null +++ b/tests/build-deb.test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Tests for .github/workflows/build-deb.yml step scripts. +set -euo pipefail +# shellcheck source=tests/lib/step.sh +source "$(dirname "$0")/lib/step.sh" + +VALIDATE=$(extract_step build-deb.yml build-deb "Validate inputs") +CHANGELOG=$(extract_step build-deb.yml build-deb "Generate debian/changelog") +RENAME=$(extract_step build-deb.yml build-deb "Rename packages with distro and component suffix") + +release_env() { + export RELEASE_MODE=true REVISION=3 UPSTREAM=1.2.0 PACKAGE_NAME=pkg \ + APT_DISTRO=trixie APT_COMPONENT=main MAINTAINER_NAME="Example Org" \ + MAINTAINER_EMAIL=dev@example.com +} + +check_env() { + export RELEASE_MODE=false REVISION='' UPSTREAM='' PACKAGE_NAME='' \ + APT_DISTRO='' APT_COMPONENT='' MAINTAINER_NAME='' MAINTAINER_EMAIL='' +} + +test_validate_check_mode_without_inputs_passes() { + in_temp_dir + check_env + check_status "check mode passes" 0 run_step "$VALIDATE" +} + +test_validate_full_release_inputs_pass() { + in_temp_dir + release_env + check_status "release mode passes" 0 run_step "$VALIDATE" +} + +test_validate_release_mode_names_missing_input() { + in_temp_dir + release_env + export MAINTAINER_EMAIL='' + check_status "release mode without maintainer email fails" 1 run_step "$VALIDATE" + check "error names the input" 1 "$(grep -c 'MAINTAINER_EMAIL' "$STDOUT_FILE")" +} + +test_validate_release_input_without_revision_fails() { + in_temp_dir + check_env + export APT_DISTRO=trixie + check_status "release input in check mode fails" 1 run_step "$VALIDATE" + check "error names the input" 1 "$(grep -c 'APT_DISTRO' "$STDOUT_FILE")" +} + +repo_with_history() { + in_temp_dir + git init -q + git config user.email test@example.com + git config user.name test + mkdir debian + git commit -q --allow-empty -m "feat: before the last release" + git tag v1.2.0+2 + git tag v1.2.0+3_pre +} + +test_changelog_lists_commits_since_last_stable_tag() { + repo_with_history + git commit -q --allow-empty -m "fix: short subject" + git commit -q --allow-empty -m "feat: $(printf 'word %.0s' {1..30})" + release_env + run_step "$CHANGELOG" + check "header" "pkg (1.2.0-3) unstable; urgency=medium" "$(head -n1 debian/changelog)" + check "earlier commit excluded" 0 "$(grep -c 'before the last release' debian/changelog || true)" + check "short subject included" 1 "$(grep -c '^ \* fix: short subject$' debian/changelog)" + check "no line over 80 columns" 0 "$(awk 'length > 80' debian/changelog | wc -l | tr -d ' ')" + check "trailer" 1 "$(grep -c '^ -- Example Org ' debian/changelog)" +} + +test_changelog_without_new_commits_says_build() { + repo_with_history + release_env + run_step "$CHANGELOG" + check "build line" 1 "$(grep -c '^ \* Build 3$' debian/changelog)" +} + +test_changelog_ignores_stable_tags_outside_history() { + repo_with_history + local main + main=$(git branch --show-current) + git checkout -q --orphan side + git commit -q --allow-empty -m "unrelated history" + git tag v9.0.0+1 + git checkout -q "$main" + git commit -q --allow-empty -m "fix: on main" + release_env + run_step "$CHANGELOG" + check "commit since reachable tag listed" 1 "$(grep -c 'fix: on main' debian/changelog)" + check "commit before reachable tag excluded" 0 "$(grep -c 'before the last release' debian/changelog || true)" +} + +test_changelog_uses_local_script() { + repo_with_history + mkdir -p .github/scripts + printf '#!/usr/bin/env bash\necho "$@" > args.txt\n' > .github/scripts/generate-changelog.sh + chmod +x .github/scripts/generate-changelog.sh + release_env + run_step "$CHANGELOG" + check "override arguments" "--upstream 1.2.0 --revision 3" "$(cat args.txt)" +} + +test_rename_finds_package_in_root_or_build() { + local dir + for dir in . build; do + in_temp_dir + mkdir -p build + touch "$dir/pkg_1.2.0-3_all.deb" + release_env + run_step "$RENAME" + check "renamed in $dir" 1 "$(find "$dir" -maxdepth 1 -name 'pkg_1.2.0-3_all+trixie+main.deb' | wc -l | tr -d ' ')" + done +} + +test_rename_fails_without_expected_package() { + in_temp_dir + touch other_1.0-1_all.deb + release_env + check_status "missing package fails" 1 run_step "$RENAME" +} + +test_rename_uses_local_script() { + in_temp_dir + mkdir -p .github/scripts + printf '#!/usr/bin/env bash\necho "$@" > args.txt\n' > .github/scripts/rename-packages.sh + chmod +x .github/scripts/rename-packages.sh + release_env + run_step "$RENAME" + check "override arguments" "--version 1.2.0-3 --distro trixie --component main" "$(cat args.txt)" +} + +run_tests diff --git a/tests/lib/fake-gh b/tests/lib/fake-gh new file mode 100755 index 0000000..14607b1 --- /dev/null +++ b/tests/lib/fake-gh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Fake gh for release tests. Releases are JSON files in $FAKE_GH_STATE named +# after their tag; every call is appended to $FAKE_GH_STATE/calls.log. +set -euo pipefail + +STATE=${FAKE_GH_STATE:?} +echo "$*" >> "$STATE/calls.log" + +release_file() { printf '%s/%s.json' "$STATE" "$1"; } + +[ "$1" = release ] || { echo "fake gh: unsupported: $*" >&2; exit 2; } +shift +cmd=$1 +shift + +case "$cmd" in + list) + jq -s '.' "$STATE"/*.json 2>/dev/null || echo '[]' + ;; + view) + tag=$1 + if [ ! -f "$(release_file "$tag")" ]; then + echo "release not found" >&2 + exit 1 + fi + cat "$(release_file "$tag")" + ;; + delete) + rm -f "$(release_file "$1")" + ;; + create) + tag=$1 + shift + draft=false prerelease=false target='' assets=() + while [ $# -gt 0 ]; do + case "$1" in + --draft) draft=true ;; + --prerelease) prerelease=true ;; + --target) target=$2; shift ;; + --title|--notes-file) shift ;; + *) assets+=("$(basename "$1")") ;; + esac + shift + done + [ "${FAKE_GH_FAIL_CREATE:-}" = "$tag" ] && { echo "upload failed" >&2; exit 1; } + jq -n --arg tag "$tag" --argjson draft "$draft" --argjson pre "$prerelease" \ + --arg target "$target" '$ARGS.positional | {tagName: $tag, isDraft: $draft, + isPrerelease: $pre, targetCommitish: $target, assets: map({name: .})}' \ + --args "${assets[@]}" > "$(release_file "$tag")" + ;; + *) + echo "fake gh: unsupported: release $cmd" >&2 + exit 2 + ;; +esac diff --git a/tests/lib/step.sh b/tests/lib/step.sh new file mode 100644 index 0000000..9d90cf2 --- /dev/null +++ b/tests/lib/step.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Helpers for testing a workflow step's run script outside GitHub Actions. +# Source from a tests/*.test.sh file. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FAILURES=0 + +# extract_step -> path of the script file +extract_step() { + local out + out=$(mktemp) + NAME="$3" yq ".jobs[\"$2\"].steps[] | select(.name == strenv(NAME)) | .run" \ + "$ROOT/.github/workflows/$1" > "$out" + if [ ! -s "$out" ] || [ "$(cat "$out")" = null ]; then + echo "no step '$3' in job '$2' of $1" >&2 + exit 1 + fi + echo "$out" +} + +# run_step