From d030373d60854ee8d789c7fa49854a2cd83b8a8d Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Wed, 9 Sep 2026 11:21:35 -0400 Subject: [PATCH 1/2] ci(cliparr): improve release validation and summaries --- .github/CONTRIBUTING.md | 14 +- .github/workflows/ci.yml | 18 ++ .github/workflows/docker.yml | 64 +++---- .github/workflows/release.yml | 141 ++++++++++----- package.json | 3 +- tools/ci/smoke-image.sh | 43 +++++ tools/ci/summary.mjs | 165 +++++++++++++++++ tools/release/create-github-release.mjs | 82 +++++---- tools/release/create-github-release.test.mjs | 94 ++++++++++ tools/release/github-output.mjs | 14 ++ tools/release/plan-release.mjs | 180 ++++++++++--------- tools/release/plan-release.test.mjs | 108 +++++++++++ tools/release/summary.test.mjs | 42 +++++ 13 files changed, 768 insertions(+), 200 deletions(-) create mode 100644 tools/ci/smoke-image.sh create mode 100644 tools/ci/summary.mjs create mode 100644 tools/release/github-output.mjs create mode 100644 tools/release/plan-release.test.mjs create mode 100644 tools/release/summary.test.mjs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 20cfff55..6021608f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -53,10 +53,22 @@ 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. ## 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. + +### 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--` 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a35d2f1a..ead769be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: - main workflow_dispatch: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f02e434d..670d1397 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,6 +6,10 @@ on: - main workflow_dispatch: +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -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 @@ -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 '
' "${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: . @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48dda39f..5992078e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ on: permissions: contents: write packages: write + actions: read concurrency: group: release @@ -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 @@ -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 @@ -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 }} @@ -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 '
' "${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 }} @@ -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 }} @@ -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" @@ -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" diff --git a/package.json b/package.json index b70637e2..ee537da5 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "MIT", "type": "module", "imports": { + "#ci/*.mjs": "./tools/ci/*.mjs", "#plex/*.mjs": "./tools/plex/*.mjs", "#release/*.mjs": "./tools/release/*.mjs" }, @@ -37,7 +38,7 @@ "plex:sdk:update": "node tools/plex/update-sdk.mjs", "plex:sdk:check": "node tools/plex/check-sdk.mjs", "preflight": "pnpm format:check && pnpm lint && pnpm knip && pnpm test", - "test": "pnpm plex:sdk:check && pnpm test:release && pnpm test:plex-sdk && pnpm --filter @cliparr/server test && pnpm --filter @cliparr/frontend test", + "test": "pnpm plex:sdk:check && pnpm test:release && pnpm test:plex-sdk && pnpm -r --if-present test", "test:release": "node --test tools/release/*.test.mjs", "test:plex-sdk": "node --test tools/plex/*.test.mjs", "release:plan": "node tools/release/plan-release.mjs", diff --git a/tools/ci/smoke-image.sh b/tools/ci/smoke-image.sh new file mode 100644 index 00000000..444d9f0c --- /dev/null +++ b/tools/ci/smoke-image.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:?image required}" +version="${2:?version required}" +platform="${3:-linux/amd64}" +name="cliparr-smoke" +health_file="$(mktemp)" +index_file="$(mktemp)" +cleanup() { + result=$? + if [[ "$result" != 0 ]]; then docker logs "$name" || true; fi + docker rm -f "$name" >/dev/null 2>&1 || true + docker volume rm "$name-data" >/dev/null 2>&1 || true + rm -f "$health_file" "$index_file" + exit "$result" +} +trap cleanup EXIT + +# Explicitly replace a locally cached architecture when testing the same index +# digest twice on runners using Docker's classic image store. +if [[ "$image" == *@sha256:* ]]; then + docker pull --platform "$platform" "$image" +fi + +docker run --detach --name "$name" --platform "$platform" \ + --publish 127.0.0.1:7171:7171 \ + --env APP_KEY="cliparr-smoke-test-key-with-at-least-32-characters" \ + --volume "$name-data:/data" "$image" + +for attempt in {1..60}; do + if curl --fail --silent http://127.0.0.1:7171/api/health > "$health_file"; then break; fi + sleep 1 +done +jq --exit-status --arg version "$version" \ + '.status == "ok" and .database == "ok" and .version == $version' "$health_file" +curl --fail --silent --show-error http://127.0.0.1:7171/ > "$index_file" +grep -q '
' "$index_file" +runtime_node="$(docker exec "$name" /nodejs/bin/node --version)" +echo "$platform: app $version, Node $runtime_node" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "runtime_node=$runtime_node" >> "$GITHUB_OUTPUT" +fi diff --git a/tools/ci/summary.mjs b/tools/ci/summary.mjs new file mode 100644 index 00000000..a3fccc33 --- /dev/null +++ b/tools/ci/summary.mjs @@ -0,0 +1,165 @@ +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +function cell(value) { + return String(value ?? "unavailable") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("|", "|") + .replaceAll("`", "`") + .replaceAll(/\r?\n/gu, "
"); +} + +const stepNames = { + format: "Formatting", + lint: "Lint and types", + knip: "Unused code", + test: "Tests (release tooling, Plex SDK, all workspaces)", + docs: "Documentation", + build: "Application build", + smoke: "Local amd64 runtime smoke test", + publish: "Multi-platform image build", + verify_amd64: "Published amd64 digest smoke test", + verify_arm64: "Published arm64 digest smoke test (QEMU)", + "github-release": "GitHub release", + aliases: "Promote Docker tags", +}; + +export function renderSummary({ + plan, + steps = {}, + version, + repository, + sha, + headSha, + status, + dryRun = false, + phase, + nodeVersion, + pnpmVersion, + imageName, + digest, + releaseUrl, +}) { + const lines = [ + `## Cliparr ${phase === "plan" ? "release plan" : "validation"}`, + "", + ]; + if (phase !== "plan") { + let outcome = status ?? "unavailable"; + if (dryRun) { + outcome = `Dry run — not published (${outcome})`; + } else if (steps.publish && steps.publish.outcome === "success") { + outcome = + steps.aliases?.outcome === "success" + ? "Published" + : "Partially published — inspect stages below; rerun this workflow to resume"; + } + lines.push(`**${cell(outcome)}**`, ""); + } + lines.push("| Version / source | Value |", "| --- | --- |"); + const row = (name, value) => lines.push(`| ${name} | ${cell(value)} |`); + row("App version", plan?.tag ?? version); + if (phase === "plan") { + row( + "Mode", + dryRun + ? "Dry run — not published" + : "Publication requested after validation", + ); + } + if (repository && sha) { + lines.push( + `| Tested commit | [${cell(sha.slice(0, 7))}](https://github.com/${repository}/commit/${sha}) |`, + ); + } + if (headSha && headSha !== sha) { + row("PR head (tested commit above is the merge)", headSha); + } + if (plan) { + row("Channel", plan.channel); + row("Previous stable", plan.previous_stable_tag); + row( + "Version bump", + `${plan.release_type}; ${plan.releasable_change_count} release-impacting changes of ${plan.change_count}`, + ); + row("Previous candidate", plan.previous_prerelease_tag || "none"); + row( + "Commits since candidate", + plan.changes_since_prerelease || "none available", + ); + row( + "Matches candidate commit", + plan.previous_prerelease_tag ? plan.matches_prerelease : "no candidate", + ); + row("Release notes start", plan.previous_tag); + row("Docker tags (planned)", plan.docker_tags.join("\n")); + } + row( + "Runner Node / pnpm", + `${nodeVersion ?? "unavailable"} / ${pnpmVersion ?? "unavailable"}`, + ); + for (const id of ["smoke", "verify_amd64", "verify_arm64"]) { + if (steps[id]?.outputs?.runtime_node) { + row(`${stepNames[id]} — Node`, steps[id].outputs.runtime_node); + } + } + if (digest) { + row(dryRun ? "Build digest (not published)" : "Registry digest", digest); + } + if (releaseUrl) { + lines.push("", `[GitHub release](${releaseUrl})`); + } + if (!dryRun && digest && imageName) { + lines.push("", "```bash", `docker pull ${imageName}@${digest}`, "```"); + } + if (phase !== "plan") { + lines.push( + "", + "| Validation / publication stage | Result |", + "| --- | --- |", + ); + for (const [id, name] of Object.entries(stepNames)) { + if (steps[id]) { + const label = + dryRun && id === "github-release" ? "Release notes preview" : name; + lines.push(`| ${label} | ${cell(steps[id].outcome)} |`); + } + } + if (steps.publish && !steps.verify_arm64) { + lines.push("", "ARM64: build only; no runtime smoke test in this job."); + } + } + return `${lines.join("\n")}\n`; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const planFile = process.env.RELEASE_PLAN_FILE; + const summary = renderSummary({ + plan: + planFile && existsSync(planFile) + ? JSON.parse(readFileSync(planFile, "utf8")) + : undefined, + steps: JSON.parse(process.env.SUMMARY_STEPS ?? "{}"), + phase: process.env.SUMMARY_PHASE, + version: process.env.CLIPARR_VERSION, + repository: process.env.GITHUB_REPOSITORY, + sha: process.env.GITHUB_SHA, + headSha: process.env.PR_HEAD_SHA, + status: process.env.JOB_STATUS, + dryRun: process.env.DRY_RUN === "true", + nodeVersion: process.version, + pnpmVersion: execFileSync("pnpm", ["--version"], { + encoding: "utf8", + }).trim(), + imageName: process.env.IMAGE_NAME, + digest: process.env.IMAGE_DIGEST, + releaseUrl: process.env.RELEASE_URL, + }); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); +} diff --git a/tools/release/create-github-release.mjs b/tools/release/create-github-release.mjs index 0e861e07..4b020de5 100644 --- a/tools/release/create-github-release.mjs +++ b/tools/release/create-github-release.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { appendFileSync, readFileSync } from "node:fs"; +import { writeGithubOutput } from "#release/github-output.mjs"; import { pathToFileURL } from "node:url"; const booleanArgumentNames = new Set(["--dry-run", "--prerelease"]); @@ -72,7 +73,10 @@ export function parseArguments(argv) { return arguments_; } -async function githubApi(path, { method = "GET", body, token }) { +async function githubApi( + path, + { method = "GET", body, token, allowMissing = false }, +) { const headers = { Accept: "application/vnd.github+json", "Content-Type": "application/json", @@ -90,6 +94,10 @@ async function githubApi(path, { method = "GET", body, token }) { body: body ? JSON.stringify(body) : undefined, }); + if (response.status === 404 && allowMissing) { + return; + } + if (!response.ok) { throw new Error( `GitHub API ${method} ${path} failed: ${response.status} ${await response.text()}`, @@ -112,6 +120,7 @@ export function composeReleaseBody({ imageName, imageDigest, dockerTags, + dryRun = false, }) { const pullTag = dockerTags.find( @@ -120,7 +129,7 @@ export function composeReleaseBody({ const dockerLines = [ "## Docker image", "", - `Published to \`${imageName}\`.`, + `${dryRun ? "Planned image (not published)" : "Published to"} \`${imageName}\`.`, "", "```bash", `docker pull ${pullTag}`, @@ -139,21 +148,6 @@ export function composeReleaseBody({ .join("\n\n")}\n`; } -function writeGithubOutput(outputs) { - const outputFile = process.env.GITHUB_OUTPUT; - - if (!outputFile) { - return; - } - - appendFileSync( - outputFile, - `${Object.entries(outputs) - .map(([key, value]) => `${key}=${value}`) - .join("\n")}\n`, - ); -} - export async function main(argv = process.argv.slice(2)) { const arguments_ = parseArguments(argv); const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; @@ -184,28 +178,56 @@ export async function main(argv = process.argv.slice(2)) { imageName: arguments_.imageName, imageDigest: arguments_.imageDigest, dockerTags, + dryRun: arguments_.dryRun, }); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `
${arguments_.dryRun ? "Planned release notes — not published" : "Release notes"}\n\n${body}\n
\n`, + ); + } + if (arguments_.dryRun) { process.stdout.write(`# ${arguments_.name}\n\n`); process.stdout.write(`${body}\n`); writeGithubOutput({ html_url: "" }); - process.exit(0); + return; } - const release = await githubApi(`/repos/${arguments_.repository}/releases`, { - method: "POST", - token, - body: { - tag_name: arguments_.tag, - target_commitish: arguments_.target, - name: arguments_.name, - body, - draft: false, - prerelease: arguments_.prerelease, - make_latest: arguments_.prerelease ? "false" : "true", + const repositoryPath = `/repos/${arguments_.repository}`; + const existing = await githubApi( + `${repositoryPath}/releases/tags/${arguments_.tag}`, + { token, allowMissing: true }, + ); + const taggedCommit = await githubApi( + `${repositoryPath}/commits/${arguments_.tag}`, + { token, allowMissing: true }, + ); + if ( + (existing && !taggedCommit) || + (taggedCommit && taggedCommit.sha !== arguments_.target) + ) { + throw new Error(`Release tag ${arguments_.tag} points to another commit.`); + } + const release = await githubApi( + existing + ? `${repositoryPath}/releases/${existing.id}` + : `${repositoryPath}/releases`, + { + method: existing ? "PATCH" : "POST", + token, + body: { + tag_name: arguments_.tag, + target_commitish: arguments_.target, + name: arguments_.name, + body, + draft: false, + prerelease: arguments_.prerelease, + make_latest: arguments_.prerelease ? "false" : "true", + }, }, - }); + ); process.stdout.write(`Created release ${release.html_url}\n`); writeGithubOutput({ html_url: release.html_url }); diff --git a/tools/release/create-github-release.test.mjs b/tools/release/create-github-release.test.mjs index 7ff99ffb..86c37560 100644 --- a/tools/release/create-github-release.test.mjs +++ b/tools/release/create-github-release.test.mjs @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { + main, composeReleaseBody, parseArguments, } from "#release/create-github-release.mjs"; @@ -87,3 +91,93 @@ void test("omits the digest line when no image digest exists", () => { assert.match(body, /docker pull ghcr\.io\/techsquidtv\/cliparr:0\.6\.1/u); assert.doesNotMatch(body, /Digest:/u); }); + +void test("dry-run notes identify the image as planned", () => { + const body = composeReleaseBody({ + generatedBody: "Changes", + imageName: "example/image", + dockerTags: ["example/image:1.0.0"], + dryRun: true, + }); + assert.match(body, /Planned image \(not published\)/u); + assert.doesNotMatch(body, /Published to/u); +}); + +function mockReleaseApi(context, { existing = false, conflict = false } = {}) { + const directory = mkdtempSync(path.join(tmpdir(), "cliparr-release-api-")); + const tagsFile = path.join(directory, "tags.txt"); + writeFileSync(tagsFile, "example/image:1.0.0\n"); + const originalEnv = { ...process.env }; + process.env.GITHUB_TOKEN = "test-token"; + delete process.env.GITHUB_OUTPUT; + delete process.env.GITHUB_STEP_SUMMARY; + context.after(() => { + process.env = originalEnv; + rmSync(directory, { recursive: true, force: true }); + }); + const calls = []; + context.mock.method(globalThis, "fetch", async (url, options) => { + const pathname = new URL(url).pathname; + calls.push({ + pathname, + method: options.method, + body: options.body ? JSON.parse(options.body) : undefined, + }); + if (pathname.endsWith("/generate-notes")) { + return Response.json({ body: "Changes" }); + } + if (pathname.includes("/commits/")) { + return existing || conflict + ? Response.json({ sha: conflict ? "other" : "HEAD" }) + : new Response("Missing", { status: 404 }); + } + if (options.method === "GET") { + return existing + ? Response.json({ id: 123 }) + : new Response("Missing", { status: 404 }); + } + return Response.json({ + html_url: "https://github.com/owner/repo/releases/tag/v1.0.0", + }); + }); + return { + calls, + args: [...requiredArguments.slice(0, -2), "--docker-tags-file", tagsFile], + }; +} + +void test("retries update an existing release after verifying the tag target", async (context) => { + const api = mockReleaseApi(context, { existing: true }); + await main(api.args); + const update = api.calls.find((call) => call.method === "PATCH"); + assert.equal(update.pathname, "/repos/TechSquidTV/Cliparr/releases/123"); + assert.equal(update.body.make_latest, "true"); + assert.ok( + !api.calls.some( + (call) => call.method === "POST" && call.pathname.endsWith("/releases"), + ), + ); +}); + +void test("release creation refuses tags belonging to another commit", async (context) => { + const api = mockReleaseApi(context, { conflict: true }); + await assert.rejects(main(api.args), /points to another commit/u); + assert.ok(!api.calls.some((call) => call.pathname.endsWith("/releases"))); +}); + +void test("dry run only generates notes and never creates or updates a release", async (context) => { + const api = mockReleaseApi(context); + await main([...api.args, "--dry-run"]); + assert.equal(api.calls.length, 1); + assert.ok(api.calls[0].pathname.endsWith("/generate-notes")); +}); + +void test("new candidates are prereleases and never become latest", async (context) => { + const api = mockReleaseApi(context); + await main([...api.args, "--prerelease"]); + const creation = api.calls.find( + (call) => call.method === "POST" && call.pathname.endsWith("/releases"), + ); + assert.equal(creation.body.prerelease, true); + assert.equal(creation.body.make_latest, "false"); +}); diff --git a/tools/release/github-output.mjs b/tools/release/github-output.mjs new file mode 100644 index 00000000..c76284be --- /dev/null +++ b/tools/release/github-output.mjs @@ -0,0 +1,14 @@ +import { appendFileSync } from "node:fs"; + +export function writeGithubOutput(outputs) { + const outputFile = process.env.GITHUB_OUTPUT; + if (!outputFile) { + return; + } + const lines = Object.entries(outputs).flatMap(([key, value]) => + Array.isArray(value) + ? [`${key}< titleForCommitMessage(message)), + const titles = messages.map((message) => + extractReleaseTitleFromCommitMessage(message), ); const summary = summarizeChanges(titles); @@ -222,10 +160,19 @@ async function planRelease(arguments_) { throw new Error(`Release tag ${tag} already exists.`); } - const previousPrereleaseTag = isPrerelease - ? latestPrereleaseTag(tags, baseVersion, arguments_.channel) + const previousPrereleaseTag = latestPrereleaseTag( + tags, + baseVersion, + isPrerelease ? arguments_.channel : "rc", + ); + const changesSincePrerelease = previousPrereleaseTag + ? getCommitMessages(previousPrereleaseTag, target).length : undefined; - const previousTag = previousPrereleaseTag ?? previousStableTag; + if (isPrerelease && changesSincePrerelease === 0) { + throw new Error(`No new commits since ${previousPrereleaseTag}.`); + } + const previousTag = + (isPrerelease ? previousPrereleaseTag : undefined) ?? previousStableTag; const shortSha = getShortSha(arguments_.target); const dockerTags = buildDockerTags({ imageName: arguments_.imageName, @@ -235,6 +182,15 @@ async function planRelease(arguments_) { }); return { + target, + channel: arguments_.channel, + previous_stable_tag: previousStableTag, + previous_prerelease_tag: previousPrereleaseTag ?? "", + changes_since_prerelease: + changesSincePrerelease === undefined + ? "" + : String(changesSincePrerelease), + matches_prerelease: String(changesSincePrerelease === 0), version, tag, previous_tag: previousTag, @@ -249,7 +205,50 @@ async function planRelease(arguments_) { async function main(argv = process.argv.slice(2)) { const arguments_ = parseArguments(argv); - const plan = await planRelease(arguments_); + const planFile = process.env.RELEASE_PLAN_FILE; + const plan = + planFile && existsSync(planFile) + ? JSON.parse(readFileSync(planFile, "utf8")) + : planRelease(arguments_); + if ( + plan.target !== getCommitSha(arguments_.target) || + plan.channel !== arguments_.channel + ) { + throw new Error( + "Saved release plan does not match the requested commit and channel.", + ); + } + const existingTags = git(["tag", "--list", "v*"]).split("\n").filter(Boolean); + if ( + existingTags.includes(plan.tag) && + getCommitSha(plan.tag) !== plan.target + ) { + throw new Error(`Release tag ${plan.tag} points to another commit.`); + } + const stableTag = latestStableTag( + existingTags.filter((tag) => tag !== plan.tag), + ); + if (stableTag !== plan.previous_stable_tag) { + throw new Error( + "A newer stable release exists; refusing to resume an outdated plan.", + ); + } + const newerCandidate = existingTags.find((tag) => { + const parsed = parseSemverTag(tag); + return ( + plan.prerelease === "true" && + parsed?.channel === plan.channel && + compareSemverTags(tag, plan.tag) > 0 + ); + }); + if (newerCandidate) { + throw new Error( + `A newer candidate ${newerCandidate} exists; refusing to move the channel backwards.`, + ); + } + if (planFile) { + writeFileSync(planFile, `${JSON.stringify(plan, null, 2)}\n`); + } if (arguments_.githubOutput) { writeGithubOutput(plan); @@ -258,11 +257,16 @@ async function main(argv = process.argv.slice(2)) { process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); } -try { - await main(); -} catch (error) { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exit(1); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + await main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } } diff --git a/tools/release/plan-release.test.mjs b/tools/release/plan-release.test.mjs new file mode 100644 index 00000000..f3119026 --- /dev/null +++ b/tools/release/plan-release.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const script = fileURLToPath(new URL("plan-release.mjs", import.meta.url)); + +function repository(context) { + const directory = mkdtempSync(path.join(tmpdir(), "cliparr-release-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Release test", + GIT_AUTHOR_EMAIL: "release@example.com", + GIT_COMMITTER_NAME: "Release test", + GIT_COMMITTER_EMAIL: "release@example.com", + GITHUB_TOKEN: "must-not-be-used-for-planning", + GITHUB_OUTPUT: "", + RELEASE_PLAN_FILE: "", + }; + const git = (...arguments_) => + execFileSync("git", arguments_, { + cwd: directory, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const commit = (title) => git("commit", "--allow-empty", "-m", title); + git("init", "-b", "main"); + commit("feat: initial release"); + git("tag", "v1.0.0"); + const run = (channel = "stable", extraEnv = {}) => + spawnSync( + process.execPath, + [script, "--channel", channel, "--target", "HEAD"], + { cwd: directory, env: { ...env, ...extraEnv }, encoding: "utf8" }, + ); + return { directory, git, commit, run }; +} + +void test("plans from committed titles without fetching mutable PR metadata", (context) => { + const repo = repository(context); + repo.commit("fix(server): repair startup (#42)"); + const result = repo.run(); + assert.equal(result.status, 0, result.stderr); + const plan = JSON.parse(result.stdout); + assert.equal(plan.tag, "v1.0.1"); + assert.equal(plan.target, repo.git("rev-parse", "HEAD")); + assert.equal(plan.previous_stable_tag, "v1.0.0"); + assert.ok(plan.docker_tags.includes("ghcr.io/techsquidtv/cliparr:latest")); +}); + +void test("increments RCs only for new commits and keeps stable notes cumulative", (context) => { + const repo = repository(context); + repo.commit("feat(frontend): new editor"); + repo.git("tag", "v1.1.0-rc.1"); + assert.match(repo.run("rc").stderr, /No new commits since v1.1.0-rc.1/u); + const stable = JSON.parse(repo.run().stdout); + assert.equal(stable.matches_prerelease, "true"); + assert.equal(stable.previous_tag, "v1.0.0"); + repo.commit("fix(frontend): repair preview"); + const candidate = JSON.parse(repo.run("rc").stdout); + assert.equal(candidate.tag, "v1.1.0-rc.2"); + assert.equal(candidate.previous_tag, "v1.1.0-rc.1"); + assert.equal(candidate.changes_since_prerelease, "1"); + assert.ok(!candidate.docker_tags.some((tag) => tag.endsWith(":latest"))); +}); + +void test("resumes the saved plan after its release tag exists", (context) => { + const repo = repository(context); + repo.commit("fix: startup"); + const planFile = path.join(repo.directory, "plan.json"); + const env = { RELEASE_PLAN_FILE: planFile }; + const first = repo.run("rc", env); + assert.equal(first.status, 0, first.stderr); + repo.git("tag", "v1.0.1-rc.1"); + const retry = repo.run("rc", env); + assert.equal(retry.status, 0, retry.stderr); + assert.deepEqual(JSON.parse(retry.stdout), JSON.parse(first.stdout)); + assert.equal(JSON.parse(readFileSync(planFile, "utf8")).tag, "v1.0.1-rc.1"); + assert.match(repo.run("stable", env).stderr, /does not match/u); + repo.commit("fix: another change"); + assert.match(repo.run("rc", env).stderr, /does not match/u); +}); + +void test("rejects retry tag conflicts and newer stable releases", (context) => { + const repo = repository(context); + repo.commit("fix: startup"); + const env = { RELEASE_PLAN_FILE: path.join(repo.directory, "plan.json") }; + assert.equal(repo.run("stable", env).status, 0); + repo.git("tag", "v1.0.1", "v1.0.0"); + assert.match(repo.run("stable", env).stderr, /points to another commit/u); + repo.git("tag", "-d", "v1.0.1"); + repo.git("tag", "v1.1.0"); + assert.match(repo.run("stable", env).stderr, /newer stable release/u); +}); + +void test("refuses to resume an RC after a newer candidate has shipped", (context) => { + const repo = repository(context); + repo.commit("fix: startup"); + const env = { RELEASE_PLAN_FILE: path.join(repo.directory, "plan.json") }; + assert.equal(repo.run("rc", env).status, 0); + repo.git("tag", "v1.0.1-rc.2"); + assert.match(repo.run("rc", env).stderr, /newer candidate/u); +}); diff --git a/tools/release/summary.test.mjs b/tools/release/summary.test.mjs new file mode 100644 index 00000000..bd310ce2 --- /dev/null +++ b/tools/release/summary.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderSummary } from "#ci/summary.mjs"; + +void test("dry runs never report successful builds as published", () => { + const summary = renderSummary({ + dryRun: true, + status: "success", + steps: { publish: { outcome: "success" } }, + imageName: "example/image", + digest: "sha256:abc", + }); + assert.match(summary, /Dry run — not published/u); + assert.match(summary, /ARM64: build only/u); + assert.doesNotMatch(summary, /docker pull/u); +}); + +void test("reports partial publication and preserves failed and skipped outcomes", () => { + const summary = renderSummary({ + steps: { + publish: { outcome: "success" }, + verify_arm64: { outcome: "failure" }, + aliases: { outcome: "skipped" }, + }, + }); + assert.match(summary, /Partially published/u); + assert.match(summary, /QEMU\) \| failure/u); + assert.match(summary, /Promote Docker tags \| skipped/u); +}); + +void test("escapes version text and identifies the tested PR merge commit", () => { + const summary = renderSummary({ + version: "branch|@abc", + repository: "owner/repo", + sha: "abcdefg", + headSha: "1234567", + status: "success", + }); + assert.match(summary, /branch|<test>@abc/u); + assert.match(summary, /commit\/abcdefg/u); + assert.match(summary, /PR head \(tested commit above is the merge\)/u); +}); From cd59d6bf0fdbf79a02faa703118e64716f19c17c Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Wed, 9 Sep 2026 11:27:41 -0400 Subject: [PATCH 2/2] chore(cliparr): prepare v2.0.0 release notes --- .github/CONTRIBUTING.md | 8 ++++++-- tools/release/create-github-release.test.mjs | 4 ++-- tools/release/notes/{v1.3.0.md => v2.0.0.md} | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) rename tools/release/notes/{v1.3.0.md => v2.0.0.md} (88%) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6021608f..4d71d95a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -57,9 +57,9 @@ GitHub Releases are the canonical changelog. The `Release` workflow is run manua 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. -## Security +### Upcoming v2.0.0 -Do not include Plex tokens, Jellyfin credentials, server URLs, local media paths, or other private account details in issues, logs, screenshots, or pull requests. +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 @@ -72,3 +72,7 @@ Dry runs build both architectures and smoke-test the local amd64 image, generate 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. diff --git a/tools/release/create-github-release.test.mjs b/tools/release/create-github-release.test.mjs index 86c37560..de2f8697 100644 --- a/tools/release/create-github-release.test.mjs +++ b/tools/release/create-github-release.test.mjs @@ -49,10 +49,10 @@ void test("accepts a release notes file and rejects a missing path", () => { const arguments_ = parseArguments([ ...requiredArguments, "--notes-file", - "tools/release/notes/v1.3.0.md", + "tools/release/notes/v2.0.0.md", ]); - assert.equal(arguments_.notesFile, "tools/release/notes/v1.3.0.md"); + assert.equal(arguments_.notesFile, "tools/release/notes/v2.0.0.md"); assert.throws( () => parseArguments([...requiredArguments, "--notes-file"]), /--notes-file requires a value\./u, diff --git a/tools/release/notes/v1.3.0.md b/tools/release/notes/v2.0.0.md similarity index 88% rename from tools/release/notes/v1.3.0.md rename to tools/release/notes/v2.0.0.md index 5876cd5b..2ea4cac1 100644 --- a/tools/release/notes/v1.3.0.md +++ b/tools/release/notes/v2.0.0.md @@ -6,8 +6,8 @@ - Get more reliable HLS trimming, video track selection, ProRes handling, and preview recovery. - Connect providers more easily, including regular Jellyfin accounts, with improved Plex token selection. -## Upgrade notes +## Breaking changes and upgrade notes -**Remote video URLs now require a signed-in provider session.** Sign in to Plex or Jellyfin before opening a remote URL. Previously, this workflow was available without signing in. Local files still work without an account. This access restriction is included in v1.3.0 as a security correction. +**Remote video URLs now require a signed-in provider session.** Sign in to Plex or Jellyfin before opening a remote URL. Previously, this workflow was available without signing in. Local files still work without an account. This security correction is a breaking change in v2.0.0. Proxied content can no longer execute scripts, HLS playlists larger than 8 MB are rejected, and exports fail when the selected video would otherwise be silently discarded.