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
18 changes: 17 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,26 @@ Release-impacting title types:

## Releases

GitHub Releases are the canonical changelog. The `Release` workflow is run manually from `main`, computes the next SemVer version from merged pull request titles, publishes Docker images to GHCR, creates the GitHub Release, and triggers a Cloudflare Pages rebuild so `cliparr.dev/changelog` mirrors the latest release notes.
GitHub Releases are the canonical changelog. The `Release` workflow is run manually from `main`, computes the next SemVer version from committed squash/merge titles, publishes Docker images to GHCR, creates the GitHub Release, and triggers a Cloudflare Pages rebuild so `cliparr.dev/changelog` mirrors the latest release notes.

Before running a real release, make sure `CLOUDFLARE_PAGES_DEPLOY_HOOK_URL` is configured as a repository secret. Cloudflare Pages builds require a read-only `GITHUB_TOKEN` or `GH_TOKEN` environment variable so the changelog mirror does not hit unauthenticated GitHub API rate limits. Use the workflow's dry-run mode first when validating a release.

### Upcoming v2.0.0

The next release targets v2.0.0. Keep `tools/release/notes/v2.0.0.md` up to date as additional features land, and document breaking changes with upgrade instructions. These notes are a working draft; preparing them does not publish a release. Review the final feature scope before running an RC or stable release.

### Release validation and recovery

Every workspace test script runs in CI and release validation, including the website. Job summaries show the embedded app version, tested commit (including the distinction between a PR head and its tested merge), actual runner/container Node versions, pnpm version, and validation outcomes.

Release planning uses immutable commit titles; editing a merged PR title no longer changes the version. RCs require new commits since the preceding candidate. Stable releases remain allowed without an RC, and summaries report whether the target matches the latest candidate. Stable notes include all changes since the previous stable release; RC notes use the previous candidate where available.

Dry runs build both architectures and smoke-test the local amd64 image, generate preview notes, and publish nothing. Real runs push a run-specific staging tag, smoke-test the resulting registry digest on amd64 and on arm64 through QEMU, create/update the GitHub release, then promote that tested digest to the version and channel tags. Staging tags use `run-<run-id>-<attempt>` and are diagnostic artifacts, not supported update channels.

If publication fails, **rerun the same workflow run** to reuse its saved release plan (retained for 30 days), rather than starting another dispatch that could calculate a different version. Existing GitHub releases are updated only after verifying their tag targets the planned commit. Conflicting tags and superseded stable plans stop recovery. A retry rebuilds and retests its image before promotion, so its digest can change. GitHub and GHCR publication is not atomic; the summary identifies partial publication and which stages completed.

The Cloudflare changelog refresh is a separate job. If only that job fails, rerun the failed job; the release is already published. To retry an older refresh independently, use the Sync Changelog workflow.

## Security

Do not include Plex tokens, Jellyfin credentials, server URLs, local media paths, or other private account details in issues, logs, screenshots, or pull requests.
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ on:
- main
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

Expand Down Expand Up @@ -39,21 +43,35 @@ jobs:
run: pnpm install --frozen-lockfile

- name: Format
id: format
run: pnpm format:check

- name: Lint
id: lint
run: pnpm lint

- name: Knip
id: knip
run: pnpm knip

- name: Test
id: test
run: pnpm test

- name: Docs check
id: docs
run: pnpm docs:check

- name: Build
id: build
env:
GITHUB_TOKEN: ${{ github.token }}
run: pnpm build

- name: Write CI summary
if: always()
env:
SUMMARY_STEPS: ${{ toJSON(steps) }}
JOB_STATUS: ${{ job.status }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node tools/ci/summary.mjs
64 changes: 27 additions & 37 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- main
workflow_dispatch:

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

Expand All @@ -22,6 +26,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Set up pnpm
uses: pnpm/action-setup@v6

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

Expand Down Expand Up @@ -78,45 +90,13 @@ jobs:
cache-from: type=gha

- name: Smoke test image
run: |
set -euo pipefail

health_file="${RUNNER_TEMP}/cliparr-health.json"
index_file="${RUNNER_TEMP}/cliparr-index.html"

docker run \
--detach \
--name cliparr-smoke \
--publish 7171:7171 \
--env APP_KEY="cliparr-smoke-test-key-with-at-least-32-characters" \
--volume cliparr-smoke-data:/data \
cliparr:smoke

for attempt in {1..30}; do
if curl --fail --silent --show-error http://127.0.0.1:7171/api/health > "${health_file}"; then
break
fi
sleep 1
done

cat "${health_file}"
grep -q '"status":"ok"' "${health_file}"
grep -q '"database":"ok"' "${health_file}"

curl --fail --silent --show-error http://127.0.0.1:7171/ > "${index_file}"
grep -q '<div id="root"></div>' "${index_file}"

- name: Show smoke-test logs
if: failure()
run: docker logs cliparr-smoke || true

- name: Stop smoke-test container
if: always()
run: |
docker rm -f cliparr-smoke || true
docker volume rm cliparr-smoke-data || true
id: smoke
env:
EXPECTED_VERSION: ${{ steps.version.outputs.value }}
run: bash tools/ci/smoke-image.sh cliparr:smoke "$EXPECTED_VERSION"

- name: Build Docker image
id: publish
uses: docker/build-push-action@v7
with:
context: .
Expand All @@ -130,3 +110,13 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Write CI summary
if: always()
env:
SUMMARY_STEPS: ${{ toJSON(steps) }}
JOB_STATUS: ${{ job.status }}
DRY_RUN: "true"
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CLIPARR_VERSION: ${{ steps.version.outputs.value }}
run: node tools/ci/summary.mjs
141 changes: 98 additions & 43 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ on:
permissions:
contents: write
packages: write
actions: read

concurrency:
group: release
Expand All @@ -31,6 +32,10 @@ jobs:
name: Release
runs-on: ubuntu-latest

outputs:
release_url: ${{ steps.github-release.outputs.html_url }}
env:
RELEASE_PLAN_FILE: /tmp/cliparr-release-plan/plan.json
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -55,10 +60,20 @@ jobs:
exit 1
fi

- name: Restore release plan on retry
id: restore
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p "$(dirname "$RELEASE_PLAN_FILE")"
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --paginate --jq '.artifacts[] | select(.name == "release-plan") | .id')"
if [[ -n "$artifact_id" ]]; then
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-plan --dir "$(dirname "$RELEASE_PLAN_FILE")"
echo 'restored=true' >> "$GITHUB_OUTPUT"
fi

- name: Plan release
id: plan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHANNEL="stable"
if [[ "${{ inputs.rc }}" == "true" ]]; then
Expand All @@ -71,25 +86,46 @@ jobs:
--image-name "${IMAGE_NAME}" \
--github-output

- name: Preserve release plan for retries
if: steps.restore.outputs.restored != 'true'
uses: actions/upload-artifact@v4
with:
name: release-plan
path: ${{ env.RELEASE_PLAN_FILE }}
retention-days: 30
if-no-files-found: error

- name: Summarize release plan
env:
SUMMARY_PHASE: plan
DRY_RUN: ${{ inputs.dry_run }}
run: node tools/ci/summary.mjs

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Format
id: format
run: pnpm format:check

- name: Lint
id: lint
run: pnpm lint

- name: Knip
id: knip
run: pnpm knip

- name: Test
id: test
run: pnpm test

- name: Docs check
id: docs
run: pnpm docs:check

- name: Build
id: build
env:
CLIPARR_VERSION: ${{ steps.plan.outputs.tag }}
GITHUB_TOKEN: ${{ github.token }}
Expand All @@ -115,44 +151,10 @@ jobs:
cache-from: type=gha

- name: Smoke test image
run: |
set -euo pipefail

health_file="${RUNNER_TEMP}/cliparr-health.json"
index_file="${RUNNER_TEMP}/cliparr-index.html"

docker run \
--detach \
--name cliparr-smoke \
--publish 7171:7171 \
--env APP_KEY="cliparr-smoke-test-key-with-at-least-32-characters" \
--volume cliparr-smoke-data:/data \
cliparr:smoke

for attempt in {1..30}; do
if curl --fail --silent --show-error http://127.0.0.1:7171/api/health > "${health_file}"; then
break
fi
sleep 1
done

cat "${health_file}"
grep -q '"status":"ok"' "${health_file}"
grep -q '"database":"ok"' "${health_file}"
grep -q '"version":"${{ steps.plan.outputs.tag }}"' "${health_file}"

curl --fail --silent --show-error http://127.0.0.1:7171/ > "${index_file}"
grep -q '<div id="root"></div>' "${index_file}"

- name: Show smoke-test logs
if: failure()
run: docker logs cliparr-smoke || true

- name: Stop smoke-test container
if: always()
run: |
docker rm -f cliparr-smoke || true
docker volume rm cliparr-smoke-data || true
id: smoke
env:
EXPECTED_VERSION: ${{ steps.plan.outputs.tag }}
run: bash tools/ci/smoke-image.sh cliparr:smoke "$EXPECTED_VERSION"

- name: Validate release secrets
if: ${{ !inputs.dry_run }}
Expand Down Expand Up @@ -185,7 +187,7 @@ jobs:
push: ${{ !inputs.dry_run }}
build-args: |
CLIPARR_VERSION=${{ steps.plan.outputs.tag }}
tags: ${{ steps.plan.outputs.docker_tags }}
tags: ${{ env.IMAGE_NAME }}:run-${{ github.run_id }}-${{ github.run_attempt }}
labels: |
org.opencontainers.image.title=Cliparr
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
Expand All @@ -199,6 +201,22 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Verify published amd64 digest
id: verify_amd64
if: ${{ !inputs.dry_run }}
env:
IMAGE_DIGEST: ${{ steps.publish.outputs.digest }}
EXPECTED_VERSION: ${{ steps.plan.outputs.tag }}
run: bash tools/ci/smoke-image.sh "$IMAGE_NAME@$IMAGE_DIGEST" "$EXPECTED_VERSION" linux/amd64

- name: Verify published arm64 digest
id: verify_arm64
if: ${{ !inputs.dry_run }}
env:
IMAGE_DIGEST: ${{ steps.publish.outputs.digest }}
EXPECTED_VERSION: ${{ steps.plan.outputs.tag }}
run: bash tools/ci/smoke-image.sh "$IMAGE_NAME@$IMAGE_DIGEST" "$EXPECTED_VERSION" linux/arm64

- name: Write Docker tags file
run: |
printf '%s\n' "${{ steps.plan.outputs.docker_tags }}" > "${RUNNER_TEMP}/docker-tags.txt"
Expand Down Expand Up @@ -234,8 +252,45 @@ jobs:

node tools/release/create-github-release.mjs "${args[@]}"

- name: Trigger Cloudflare Pages rebuild
- name: Promote tested Docker image
id: aliases
if: ${{ !inputs.dry_run }}
env:
IMAGE_DIGEST: ${{ steps.publish.outputs.digest }}
DOCKER_TAGS: ${{ steps.plan.outputs.docker_tags }}
run: |
args=()
while IFS= read -r tag; do
args+=(--tag "$tag")
done <<< "$DOCKER_TAGS"
docker buildx imagetools create "${args[@]}" "$IMAGE_NAME@$IMAGE_DIGEST"

- name: Write release summary
if: always()
env:
SUMMARY_STEPS: ${{ toJSON(steps) }}
JOB_STATUS: ${{ job.status }}
DRY_RUN: ${{ inputs.dry_run }}
IMAGE_DIGEST: ${{ steps.publish.outputs.digest }}
RELEASE_URL: ${{ steps.github-release.outputs.html_url }}
run: node tools/ci/summary.mjs

changelog:
name: Refresh changelog
needs: release
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Trigger Cloudflare Pages rebuild
env:
CLOUDFLARE_PAGES_DEPLOY_HOOK_URL: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_HOOK_URL }}
run: curl --fail --silent --show-error --request POST "${CLOUDFLARE_PAGES_DEPLOY_HOOK_URL}"
run: curl --fail --silent --show-error --request POST "$CLOUDFLARE_PAGES_DEPLOY_HOOK_URL"

- name: Summarize changelog refresh
if: always()
env:
RESULT: ${{ job.status }}
RELEASE_URL: ${{ needs.release.outputs.release_url }}
run: |
printf '## Changelog refresh\n\nResult: %s\n\n[Published release](%s)\n\nIf the refresh failed, rerun this job; the release is already published.\n' "$RESULT" "$RELEASE_URL" >> "$GITHUB_STEP_SUMMARY"
Loading