diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bf6c0ce..24bb1fae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: working-directory: packages/shared run: pnpm test + - name: Release Contract Tests + run: pnpm test:release-contract + - name: TRPC Tests working-directory: packages/trpc run: pnpm test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 358760481..30a09b7c1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,7 @@ -name: Build and Push image +name: Build and Push commit images -# Pull-based deploy: this workflow builds + pushes version-compatible web and -# workers images to GHCR. The VPS runs Watchtower, which polls those tags and -# redeploys both Marka services without an inbound SSH deploy step. +# Pull-based deploy support: this workflow keeps immutable commit images +# available for rollback. Release tags are built and promoted by release.yml. on: workflow_run: @@ -46,47 +45,106 @@ jobs: run: | set -euo pipefail image_name="ghcr.io/${{ github.repository_owner }}/marka" + target="${{ github.event.workflow_run.head_sha || github.sha }}" short_sha="$(git rev-parse --short=12 HEAD)" { echo "image_name=${image_name}" - echo "sha_tag=sha-${short_sha}" + echo "target=${target}" + echo "short_sha=${short_sha}" } >> "$GITHUB_OUTPUT" + - name: Check immutable commit image tags + id: images + env: + IMAGE_NAME: ${{ steps.meta.outputs.image_name }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + run: | + set -euo pipefail + get_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null | head -n 1 + } + + web_digest="$(get_digest "$IMAGE_NAME:web-sha-$SHORT_SHA" || true)" + workers_digest="$(get_digest "$IMAGE_NAME:workers-sha-$SHORT_SHA" || true)" + if [[ -n "$web_digest" ]]; then + echo "web_exists=true" >> "$GITHUB_OUTPUT" + echo "build_web=false" >> "$GITHUB_OUTPUT" + else + echo "web_exists=false" >> "$GITHUB_OUTPUT" + echo "build_web=true" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$workers_digest" ]]; then + echo "workers_exists=true" >> "$GITHUB_OUTPUT" + echo "build_workers=false" >> "$GITHUB_OUTPUT" + else + echo "workers_exists=false" >> "$GITHUB_OUTPUT" + echo "build_workers=true" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$web_digest" || -n "$workers_digest" ]]; then + echo "verify_existing=true" >> "$GITHUB_OUTPUT" + else + echo "verify_existing=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify existing commit image pair + if: steps.images.outputs.verify_existing == 'true' + env: + IMAGE_NAME: ${{ steps.meta.outputs.image_name }} + TARGET: ${{ steps.meta.outputs.target }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + WEB_EXISTS: ${{ steps.images.outputs.web_exists }} + WORKERS_EXISTS: ${{ steps.images.outputs.workers_exists }} + run: | + set -euo pipefail + verify_ref() { + local ref="$1" + docker pull "$ref" >/dev/null + source="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" + revision="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + [[ "$source" == "https://github.com/absolutepraya/marka" ]] || { + echo "Unexpected source label on existing commit image $ref: $source" >&2 + exit 1 + } + [[ "$revision" == "$TARGET" ]] || { + echo "Existing commit image $ref points to $revision, expected $TARGET" >&2 + exit 1 + } + } + if [[ "$WEB_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:web-sha-$SHORT_SHA" + fi + if [[ "$WORKERS_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:workers-sha-$SHORT_SHA" + fi + - name: Build and push web image + if: steps.images.outputs.build_web == 'true' uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: docker/Dockerfile target: web platforms: linux/amd64 - build-args: SERVER_VERSION=${{ github.event.workflow_run.head_sha || github.sha }} + build-args: | + SERVER_VERSION=${{ steps.meta.outputs.target }} + SERVER_COMMIT=${{ steps.meta.outputs.target }} push: true - tags: ${{ steps.meta.outputs.image_name }}:web-${{ steps.meta.outputs.sha_tag }} + tags: ${{ steps.meta.outputs.image_name }}:web-sha-${{ steps.meta.outputs.short_sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build and push workers image + if: steps.images.outputs.build_workers == 'true' uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: docker/Dockerfile target: workers platforms: linux/amd64 - build-args: SERVER_VERSION=${{ github.event.workflow_run.head_sha || github.sha }} + build-args: | + SERVER_VERSION=${{ steps.meta.outputs.target }} + SERVER_COMMIT=${{ steps.meta.outputs.target }} push: true - tags: ${{ steps.meta.outputs.image_name }}:workers-${{ steps.meta.outputs.sha_tag }} + tags: ${{ steps.meta.outputs.image_name }}:workers-sha-${{ steps.meta.outputs.short_sha }} cache-from: type=gha cache-to: type=gha,mode=max - - - name: Promote paired release tags - env: - IMAGE_NAME: ${{ steps.meta.outputs.image_name }} - SHA_TAG: ${{ steps.meta.outputs.sha_tag }} - run: | - set -euo pipefail - docker buildx imagetools create \ - --tag "${IMAGE_NAME}:web-main" \ - "${IMAGE_NAME}:web-${SHA_TAG}" - docker buildx imagetools create \ - --tag "${IMAGE_NAME}:workers-main" \ - "${IMAGE_NAME}:workers-${SHA_TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..761150b2e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,406 @@ +name: Release + +on: + push: + tags: + - "v*" + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate release tag and source commit + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + [[ "${{ github.event.created }}" == "true" && "${{ github.event.forced }}" != "true" ]] || { + echo "Release tags must be created once and never moved." >&2 + exit 1 + } + tag_type="$(git cat-file -t "refs/tags/$tag")" + [[ "$tag_type" == "tag" ]] || { + echo "Release tags must be annotated tags, received $tag_type" >&2 + exit 1 + } + + release_json="$(node scripts/release-contract.mjs validate-tag "$tag")" + version="$(jq -r '.version' <<<"$release_json")" + existing_release_count="$(gh release list --limit 1 --json tagName --jq 'length')" + if [[ "$existing_release_count" == "0" && "$tag" != "v0.1.0" ]]; then + echo "The first Marka release must be v0.1.0, received $tag" >&2 + exit 1 + fi + target="$(git rev-parse "${tag}^{commit}")" + [[ "$target" == "$GITHUB_SHA" ]] || { + echo "Tag target $target does not match event commit $GITHUB_SHA" >&2 + exit 1 + } + + git fetch --no-tags origin main + git merge-base --is-ancestor "$target" origin/main || { + echo "Release target $target is not reachable from origin/main" >&2 + exit 1 + } + + short_sha="$(git rev-parse --short=12 "$target")" + { + echo "tag=$tag" + echo "version=$version" + echo "target=$target" + echo "short_sha=$short_sha" + } >> "$GITHUB_OUTPUT" + + - name: Verify exact-commit blocking CI + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.release.outputs.target }} + run: | + set -euo pipefail + ci_run_id="$(gh run list --workflow CI --commit "$TARGET" --limit 20 \ + --json databaseId,status,conclusion,headSha \ + --jq '.[] | select(.headSha == env.TARGET and .status == "completed" and .conclusion == "success") | .databaseId' \ + | head -n 1)" + [[ -n "$ci_run_id" ]] || { + echo "No successful CI workflow run exists for $TARGET" >&2 + exit 1 + } + + ci_jobs="$(gh run view "$ci_run_id" --json jobs --jq '.jobs[] | [.name, .conclusion] | @tsv')" + for job in lint format typecheck tests open-api-spec; do + grep -Fq "$job$(printf '\t')success" <<<"$ci_jobs" || { + echo "Blocking CI job $job did not succeed in run $ci_run_id" >&2 + exit 1 + } + done + + - name: Verify installer CI when installer paths changed + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.release.outputs.target }} + run: | + set -euo pipefail + changed_files="$(git diff-tree --no-commit-id --name-only -r "${TARGET}^1" "$TARGET")" + if ! grep -Eq '^(scripts/install\.sh|scripts/install\.test\.sh|\.github/workflows/installer-tests\.yml)$' <<<"$changed_files"; then + echo "No installer contract paths changed. Installer CI is not required." + exit 0 + fi + + installer_run_id="$(gh run list --workflow "Installer Tests" --commit "$TARGET" --limit 20 \ + --json databaseId,status,conclusion,headSha \ + --jq '.[] | select(.headSha == env.TARGET and .status == "completed" and .conclusion == "success") | .databaseId' \ + | head -n 1)" + [[ -n "$installer_run_id" ]] || { + echo "Installer paths changed but no successful Installer Tests run exists for $TARGET" >&2 + exit 1 + } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check immutable release image tags + id: images + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + SHORT_SHA: ${{ steps.release.outputs.short_sha }} + run: | + set -euo pipefail + get_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null | head -n 1 + } + + web_version_digest="$(get_digest "$IMAGE_NAME:web-v$VERSION" || true)" + workers_version_digest="$(get_digest "$IMAGE_NAME:workers-v$VERSION" || true)" + web_sha_digest="$(get_digest "$IMAGE_NAME:web-sha-$SHORT_SHA" || true)" + workers_sha_digest="$(get_digest "$IMAGE_NAME:workers-sha-$SHORT_SHA" || true)" + + if [[ -n "$web_version_digest" ]]; then + echo "web_version_exists=true" >> "$GITHUB_OUTPUT" + echo "build_web=false" >> "$GITHUB_OUTPUT" + else + echo "web_version_exists=false" >> "$GITHUB_OUTPUT" + echo "build_web=true" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$workers_version_digest" ]]; then + echo "workers_version_exists=true" >> "$GITHUB_OUTPUT" + echo "build_workers=false" >> "$GITHUB_OUTPUT" + else + echo "workers_version_exists=false" >> "$GITHUB_OUTPUT" + echo "build_workers=true" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$web_sha_digest" ]]; then + echo "web_sha_exists=true" >> "$GITHUB_OUTPUT" + else + echo "web_sha_exists=false" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$workers_sha_digest" ]]; then + echo "workers_sha_exists=true" >> "$GITHUB_OUTPUT" + else + echo "workers_sha_exists=false" >> "$GITHUB_OUTPUT" + fi + + if [[ -n "$web_version_digest" && -z "$web_sha_digest" ]]; then + echo "ensure_web_sha=true" >> "$GITHUB_OUTPUT" + else + echo "ensure_web_sha=false" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$workers_version_digest" && -z "$workers_sha_digest" ]]; then + echo "ensure_workers_sha=true" >> "$GITHUB_OUTPUT" + else + echo "ensure_workers_sha=false" >> "$GITHUB_OUTPUT" + fi + if [[ -n "$web_version_digest" || -n "$workers_version_digest" || -n "$web_sha_digest" || -n "$workers_sha_digest" ]]; then + echo "verify_existing=true" >> "$GITHUB_OUTPUT" + else + echo "verify_existing=false" >> "$GITHUB_OUTPUT" + fi + + if [[ -z "$web_version_digest" ]]; then + { + echo 'web_tags<> "$GITHUB_OUTPUT" + fi + if [[ -z "$workers_version_digest" ]]; then + { + echo 'workers_tags<> "$GITHUB_OUTPUT" + fi + + - name: Verify existing immutable image tags + if: steps.images.outputs.verify_existing == 'true' + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + TARGET: ${{ steps.release.outputs.target }} + SHORT_SHA: ${{ steps.release.outputs.short_sha }} + WEB_VERSION_EXISTS: ${{ steps.images.outputs.web_version_exists }} + WORKERS_VERSION_EXISTS: ${{ steps.images.outputs.workers_version_exists }} + WEB_SHA_EXISTS: ${{ steps.images.outputs.web_sha_exists }} + WORKERS_SHA_EXISTS: ${{ steps.images.outputs.workers_sha_exists }} + run: | + set -euo pipefail + verify_ref() { + local ref="$1" expected_release="$2" + docker pull "$ref" >/dev/null + source="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" + revision="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + [[ "$source" == "https://github.com/absolutepraya/marka" ]] || { + echo "Unexpected source label on existing rollback image $ref: $source" >&2 + exit 1 + } + [[ "$revision" == "$TARGET" ]] || { + echo "Existing rollback image $ref points to $revision, expected $TARGET" >&2 + exit 1 + } + if [[ "$expected_release" == "required" ]]; then + release="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" + [[ "$release" == "$VERSION" ]] || { + echo "Existing release image $ref has release label $release, expected $VERSION" >&2 + exit 1 + } + else + release="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" + [[ -z "$release" || "$release" == "" || "$release" == "$VERSION" ]] || { + echo "Existing rollback image $ref has unexpected release label $release" >&2 + exit 1 + } + fi + } + if [[ "$WEB_VERSION_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:web-v$VERSION" required + fi + if [[ "$WORKERS_VERSION_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:workers-v$VERSION" required + fi + if [[ "$WEB_SHA_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:web-sha-$SHORT_SHA" optional + fi + if [[ "$WORKERS_SHA_EXISTS" == "true" ]]; then + verify_ref "$IMAGE_NAME:workers-sha-$SHORT_SHA" optional + fi + + - name: Build and push web release images + if: steps.images.outputs.build_web == 'true' + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: docker/Dockerfile + target: web + platforms: linux/amd64 + build-args: | + SERVER_VERSION=${{ steps.release.outputs.target }} + SERVER_COMMIT=${{ steps.release.outputs.target }} + SERVER_RELEASE=${{ steps.release.outputs.version }} + push: true + tags: ${{ steps.images.outputs.web_tags }} + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push workers release images + if: steps.images.outputs.build_workers == 'true' + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: docker/Dockerfile + target: workers + platforms: linux/amd64 + build-args: | + SERVER_VERSION=${{ steps.release.outputs.target }} + SERVER_COMMIT=${{ steps.release.outputs.target }} + SERVER_RELEASE=${{ steps.release.outputs.version }} + push: true + tags: ${{ steps.images.outputs.workers_tags }} + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Verify immutable image digests and source metadata + id: verified + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + TARGET: ${{ steps.release.outputs.target }} + run: | + set -euo pipefail + get_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null | head -n 1 + } + web_ref="$IMAGE_NAME:web-v$VERSION" + workers_ref="$IMAGE_NAME:workers-v$VERSION" + web_digest="$(get_digest "$web_ref")" + workers_digest="$(get_digest "$workers_ref")" + [[ -n "$web_digest" && -n "$workers_digest" ]] || { + echo "Immutable web/workers image digests are missing" >&2 + exit 1 + } + + docker pull "$web_ref" >/dev/null + docker pull "$workers_ref" >/dev/null + for ref in "$web_ref" "$workers_ref"; do + source="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" + revision="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + release="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" + [[ "$source" == "https://github.com/absolutepraya/marka" ]] || { + echo "Unexpected source label on $ref: $source" >&2 + exit 1 + } + [[ "$revision" == "$TARGET" ]] || { + echo "Unexpected source revision on $ref: $revision" >&2 + exit 1 + } + [[ "$release" == "$VERSION" ]] || { + echo "Unexpected release label on $ref: $release" >&2 + exit 1 + } + done + + { + echo "web_digest=$web_digest" + echo "workers_digest=$workers_digest" + } >> "$GITHUB_OUTPUT" + + - name: Complete missing SHA rollback tags + if: steps.images.outputs.ensure_web_sha == 'true' || steps.images.outputs.ensure_workers_sha == 'true' + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + SHORT_SHA: ${{ steps.release.outputs.short_sha }} + run: | + set -euo pipefail + if [[ "${{ steps.images.outputs.ensure_web_sha }}" == "true" ]]; then + docker buildx imagetools create --tag "$IMAGE_NAME:web-sha-$SHORT_SHA" "$IMAGE_NAME:web-v$VERSION" + fi + if [[ "${{ steps.images.outputs.ensure_workers_sha }}" == "true" ]]; then + docker buildx imagetools create --tag "$IMAGE_NAME:workers-sha-$SHORT_SHA" "$IMAGE_NAME:workers-v$VERSION" + fi + + - name: Verify SHA rollback image tags + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + TARGET: ${{ steps.release.outputs.target }} + SHORT_SHA: ${{ steps.release.outputs.short_sha }} + run: | + set -euo pipefail + get_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null | head -n 1 + } + for ref in "$IMAGE_NAME:web-sha-$SHORT_SHA" "$IMAGE_NAME:workers-sha-$SHORT_SHA"; do + [[ -n "$(get_digest "$ref" || true)" ]] || { + echo "Missing SHA rollback image $ref" >&2 + exit 1 + } + docker pull "$ref" >/dev/null + source="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" + revision="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + release="$(docker image inspect "$ref" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" + [[ "$source" == "https://github.com/absolutepraya/marka" ]] || { + echo "Unexpected source label on $ref: $source" >&2 + exit 1 + } + [[ "$revision" == "$TARGET" ]] || { + echo "Unexpected source revision on $ref: $revision" >&2 + exit 1 + } + [[ -z "$release" || "$release" == "" || "$release" == "$VERSION" ]] || { + echo "Unexpected release label on $ref: $release" >&2 + exit 1 + } + done + + - name: Promote and verify paired stable channel + env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/marka + VERSION: ${{ steps.release.outputs.version }} + WEB_DIGEST: ${{ steps.verified.outputs.web_digest }} + WORKERS_DIGEST: ${{ steps.verified.outputs.workers_digest }} + run: | + bash scripts/promote-release.sh + + - name: Create or verify GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "GitHub Release $RELEASE_TAG already exists." + exit 0 + fi + gh release create "$RELEASE_TAG" \ + --verify-tag \ + --title "Marka v$VERSION" \ + --generate-notes diff --git a/AGENTS.md b/AGENTS.md index c14cb69bf..8fe04c600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,8 @@ Important installer facts: - the script never installs Docker, changes firewall rules, configures DNS, or provisions TLS/reverse-proxy infrastructure - default configuration directory is `~/marka`; default persistent data directory is `~/marka/data` - generated Compose project name remains `karakeep` for compatibility -- generated app images are the paired `ghcr.io/absolutepraya/marka:web-main` and `ghcr.io/absolutepraya/marka:workers-main` tags +- generated app images default to the paired `ghcr.io/absolutepraya/marka:web-stable` and `ghcr.io/absolutepraya/marka:workers-stable` channel +- immutable `web-v` / `workers-v` and `web-sha-` / `workers-sha-` pairs are the rollback references - the default web listener is `127.0.0.1:3000`, intended to sit behind an operator-managed reverse proxy for Internet-facing installs - search choices are managed Meilisearch, external Meilisearch, or disabled search - renderer choices are managed private Chrome, external token-protected Browserless, or disabled browser rendering @@ -187,7 +188,7 @@ Marka uses a **pull-based** personal VPS deploy flow that is separate from the p High-level flow: - CI passes on `main` -- `.github/workflows/docker.yml` builds and pushes matching `ghcr.io//marka:web-main` and `ghcr.io//marka:workers-main` images from the same successful commit +- `.github/workflows/docker.yml` builds immutable commit-addressed images from successful `main` builds; `.github/workflows/release.yml` validates annotated release tags and promotes paired `web-stable` / `workers-stable` images - a Watchtower container on the VPS polls the paired GHCR tags and redeploys automatically Important notes: diff --git a/CONTEXT.md b/CONTEXT.md index 0b19df014..f3dd206e2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,3 +81,49 @@ _Avoid_: treating saved or crawled HTML as trusted markup. **Office files (out of scope)**: Office ingestion, provider-backed viewing, and editing are canceled from Marka's active product scope. Generic raw attachments, if accepted by a separate path, do not imply document support. _Avoid_: reopening the retired Office proposals by referring to them as an available or planned viewer. + +## Release and deployment model + +**Release**: +A SemVer identity for one compatible web and workers product pair, represented by an immutable annotated Git tag. A release is distinct from package manifest versions. +_Avoid_: treating an SDK, MCP, mobile, extension, or other package version as the shared product release. + +**Build**: +A deployable web or workers artifact produced from one source commit. A release has paired builds, but a build is not itself a release or a deployment. +_Avoid_: using build, release, and deployment interchangeably. + +**Release channel**: +A mutable pair of deployment references intended to resolve to the same release. `stable` is the only supported channel for this scope. +_Avoid_: treating a channel pointer as an immutable rollback reference. + +**Deployment**: +The web and workers services currently running for an installation. Services may roll forward independently, so adjacent builds must remain compatible during a channel update. +_Avoid_: assuming that promoting a channel changes every service atomically. + +**Release provenance**: +The metadata that identifies a running build precisely, including its release identity and source commit, with image-level evidence available for operator verification. +_Avoid_: treating a short commit display or a mutable channel name as sufficient exact provenance. + +**Rollback artifact**: +An immutable, paired web and workers image reference that can restore a known-good deployment. Version-tagged artifacts are the preferred human-readable choice; SHA-tagged artifacts remain available as a fallback. +_Avoid_: rolling back only one service or relying on a mutable channel pointer. + +**GitHub Release**: +A human-facing publication associated with one immutable Git tag. It explains a release and may contain generated or edited notes, but it does not define the release identity or replace image provenance. +_Avoid_: treating a GitHub Release page as the source of truth instead of its Git tag and source commit. + +**Eligible release commit**: +A source commit that is reachable from `main` and has a successful exact-commit CI result for the repository's blocking checks. A tag on an eligible commit may enter the release workflow. +_Avoid_: accepting the latest branch result, an unrelated successful run, or advisory-only checks as release eligibility. + +**Release compatibility window**: +The bounded period while independently updated web and workers services overlap during a channel rollout. Both adjacent releases must remain compatible throughout this window. +_Avoid_: describing an independently rolled deployment as an atomic switch. + +**Build identity**: +The full source commit that uniquely identifies a deployable build. A release number is useful for human communication, but the build identity is the value used to determine whether two running or cached builds are actually different. +_Avoid_: using a release number alone as a cache, service-worker, or rollback identity. + +**Compatibility version field**: +The existing server-version response field retained for clients that only understand a commit string. It remains a full commit when available; newer clients use structured release metadata alongside it. +_Avoid_: changing the legacy field from a commit identity to a release number without a compatibility layer. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b9bcd309..e5d53ade7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,25 @@ A good PR for this repo should include: - commands run for validation - any deploy, migration, or compatibility implications +### Release and deployment changes + +The shared Marka web and workers release identity is an annotated +`vMAJOR.MINOR.PATCH` Git tag. Package manifest versions remain independent and +must not be silently bumped as part of a shared release. The tag workflow checks +that the tagged commit is reachable from `main` and has successful exact-commit +blocking CI before building paired immutable `web-v` and +`workers-v` images. It then promotes the paired `web-stable` and +`workers-stable` channel and creates the GitHub Release. + +The stable channel is mutable and Watchtower updates the two services +independently, so adjacent releases must remain compatible. Exact rollback uses +matching immutable version tags or matching `web-sha-` and +`workers-sha-` tags. Release contract tests are offline and run with: + +```bash +pnpm test:release-contract +``` + ## Review expectations Pull requests targeting `main` may receive automated review from CodeRabbit in addition to the repository's GitHub Actions checks. diff --git a/README.md b/README.md index 47757126f..669bbaafc 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ REF=; curl -fsSLo /tmp/marka-setup.sh "https://raw.githubuser Read the [guided installation guide](docs/docs/02-installation/11-guided-docker-setup.md) for all configuration modes and rollback details. +Marka web and workers releases share an annotated `vMAJOR.MINOR.PATCH` Git tag. +The production Compose file and guided installer follow the paired `stable` +channel. Each release also keeps immutable version and source-commit image tags +so operators can roll back web and workers together. + ## Develop Marka Start with [`CONTRIBUTING.md`](CONTRIBUTING.md) for contribution rules and [`docs/operator-setup.md`](docs/operator-setup.md) for local development and deployment workflows. diff --git a/apps/mobile/app/dashboard/settings/index.tsx b/apps/mobile/app/dashboard/settings/index.tsx index 7942cb1fc..d84a8e241 100644 --- a/apps/mobile/app/dashboard/settings/index.tsx +++ b/apps/mobile/app/dashboard/settings/index.tsx @@ -23,6 +23,7 @@ import useAppSettings from "@/lib/settings"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useTRPC } from "@karakeep/shared-react/trpc"; +import { formatServerVersionDisplay } from "@karakeep/shared/version"; function SectionHeader({ title }: { title: string }) { return ( @@ -395,7 +396,9 @@ export default function Settings() { ? "Loading..." : serverVersionError ? "unavailable" - : (serverVersion ?? "unknown")} + : serverVersion + ? formatServerVersionDisplay(serverVersion) + : "unknown"} diff --git a/apps/mobile/lib/hooks.ts b/apps/mobile/lib/hooks.ts index ea4f068bf..8c35b1112 100644 --- a/apps/mobile/lib/hooks.ts +++ b/apps/mobile/lib/hooks.ts @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@karakeep/shared-react/trpc"; +import { createServerVersionResponse } from "@karakeep/shared/version"; +import type { ServerVersionResponse } from "@karakeep/shared/version"; import useAppSettings from "./settings"; import { buildApiHeaders } from "./utils"; @@ -32,8 +34,12 @@ export function useServerVersion() { throw new Error(`Failed to fetch server version: ${response.status}`); } - const data = await response.json(); - return data.version as string; + const data = (await response.json()) as Partial; + return createServerVersionResponse({ + legacyVersion: data.version, + release: data.release, + commit: data.commit, + }); }, enabled: !!settings.address, staleTime: 1000 * 60 * 5, // Cache for 5 minutes diff --git a/apps/web/components/admin/BasicStats.tsx b/apps/web/components/admin/BasicStats.tsx index 241356748..f1e947c76 100644 --- a/apps/web/components/admin/BasicStats.tsx +++ b/apps/web/components/admin/BasicStats.tsx @@ -9,12 +9,14 @@ import { useQuery } from "@tanstack/react-query"; import { BookOpen, Download, Users } from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; +import { normalizeReleaseVersion } from "@karakeep/shared/version"; const REPO_LATEST_RELEASE_API = - "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; -const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; + "https://api.github.com/repos/absolutepraya/marka/releases/latest"; +const REPO_RELEASE_PAGE = "https://github.com/absolutepraya/marka/releases"; function useLatestRelease() { + const { disableNewReleaseCheck } = useClientConfig(); const { data } = useQuery({ queryKey: ["latest-release"], queryFn: async () => { @@ -22,19 +24,25 @@ function useLatestRelease() { if (!res.ok) { return undefined; } - const data = (await res.json()) as { name: string }; - return data.name; + const data = (await res.json()) as { tag_name?: unknown }; + return normalizeReleaseVersion( + typeof data.tag_name === "string" ? data.tag_name : null, + ); }, staleTime: 60 * 60 * 1000, - enabled: !useClientConfig().disableNewReleaseCheck, + enabled: !disableNewReleaseCheck, }); return data; } function ReleaseInfo() { - const currentRelease = useClientConfig().serverVersion ?? "NA"; + const { serverRelease, serverVersion } = useClientConfig(); + const currentRelease = serverRelease + ? `v${serverRelease}` + : (serverVersion ?? "unknown"); const latestRelease = useLatestRelease(); - const hasUpdate = latestRelease && currentRelease !== latestRelease; + const hasUpdate = + !!serverRelease && !!latestRelease && serverRelease !== latestRelease; return (
@@ -51,7 +59,7 @@ function ReleaseInfo() { > Update available - {latestRelease} + v{latestRelease} )} diff --git a/apps/web/components/pwa/ServiceWorkerRegistration.test.tsx b/apps/web/components/pwa/ServiceWorkerRegistration.test.tsx index 0a3abf60d..2136ec956 100644 --- a/apps/web/components/pwa/ServiceWorkerRegistration.test.tsx +++ b/apps/web/components/pwa/ServiceWorkerRegistration.test.tsx @@ -155,6 +155,33 @@ describe("ServiceWorkerRegistration", () => { }); }); + it("prefers the full commit field over the legacy version field", async () => { + mocks.fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + version: "ccccccc", + commit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + { + headers: { "content-type": "application/json" }, + status: 200, + }, + ), + ); + + renderRegistration(); + + await waitFor(() => { + expect(mocks.register).toHaveBeenCalledWith( + "/sw.js?v=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + { + scope: "/", + updateViaCache: "none", + }, + ); + }); + }); + it("refreshes the registered worker during a manual version check", async () => { const initialUpdate = vi.fn().mockResolvedValue(undefined); const registeredUpdate = vi.fn().mockResolvedValue(undefined); diff --git a/apps/web/components/pwa/ServiceWorkerRegistration.tsx b/apps/web/components/pwa/ServiceWorkerRegistration.tsx index 50cb13f39..4a70d6402 100644 --- a/apps/web/components/pwa/ServiceWorkerRegistration.tsx +++ b/apps/web/components/pwa/ServiceWorkerRegistration.tsx @@ -12,6 +12,8 @@ import React, { import { useSession } from "@/lib/auth/client"; import { recordThumbnailAccess } from "@/lib/offline-library/repository"; +import { normalizeCommitSha } from "@karakeep/shared/version"; + type WorkerMessage = | { type: "ACTIVATE_UPDATE" } | { type: "UPDATE_ACTIVATION_BLOCKED" } @@ -269,27 +271,32 @@ export default function ServiceWorkerRegistration({ return; } - const body = (await response.json()) as { version?: unknown }; - if (!isValidBuild(appBuild) || !isDeployBuild(body.version)) { + const body = (await response.json()) as { + version?: unknown; + commit?: unknown; + }; + const deployedVersion = + normalizeCommitSha(body.commit) ?? normalizeCommitSha(body.version); + if (!isValidBuild(appBuild) || !isDeployBuild(deployedVersion)) { setUpdateStatus("unavailable"); return; } - setDeployedBuild(body.version); + setDeployedBuild(deployedVersion); if ( registrationRef.current && typeof registrationRef.current.update === "function" ) { await registrationRef.current.update().catch(() => undefined); } - if (body.version === appBuild) { + if (deployedVersion === appBuild) { setUpdateStatus("current"); return; } setUpdateStatus("available"); const registration = await navigator.serviceWorker.register( - `/sw.js?v=${encodeURIComponent(body.version)}`, + `/sw.js?v=${encodeURIComponent(deployedVersion)}`, { scope: "/", updateViaCache: "none", @@ -300,19 +307,19 @@ export default function ServiceWorkerRegistration({ await registration.update().catch(() => undefined); } - if (isWorkerForBuild(registration.waiting, body.version)) { + if (isWorkerForBuild(registration.waiting, deployedVersion)) { setUpdateStatus("ready"); return; } if ( registration.installing && - isWorkerForBuild(registration.installing, body.version) + isWorkerForBuild(registration.installing, deployedVersion) ) { watchInstallingWorker( registration, registration.installing, - body.version, + deployedVersion, ); } else { setUpdateStatus("available"); diff --git a/apps/web/components/shared/sidebar/SidebarVersion.test.tsx b/apps/web/components/shared/sidebar/SidebarVersion.test.tsx index c94ee0920..abdeb638e 100644 --- a/apps/web/components/shared/sidebar/SidebarVersion.test.tsx +++ b/apps/web/components/shared/sidebar/SidebarVersion.test.tsx @@ -16,6 +16,10 @@ const mocks = vi.hoisted(() => ({ checkForUpdate: vi.fn(), activateUpdate: vi.fn(), }, + clientConfig: { + serverRelease: undefined as string | undefined, + serverCommitShort: undefined as string | undefined, + }, })); vi.mock("next/link", () => ({ @@ -38,6 +42,10 @@ vi.mock("@/components/pwa/ServiceWorkerRegistration", () => ({ usePwaLifecycle: () => mocks.lifecycle, })); +vi.mock("@/lib/clientConfig", () => ({ + useClientConfig: () => mocks.clientConfig, +})); + vi.mock("@/lib/i18n/client", () => ({ useTranslation: () => ({ t: (key: string, values?: { build?: string }) => { @@ -67,6 +75,8 @@ describe("SidebarVersion", () => { mocks.lifecycle.updateAvailable = true; mocks.lifecycle.activateUpdate.mockReset(); mocks.lifecycle.checkForUpdate.mockReset(); + mocks.clientConfig.serverRelease = undefined; + mocks.clientConfig.serverCommitShort = undefined; }); it("shows the running app build and a ready deployed update", () => { @@ -89,6 +99,15 @@ describe("SidebarVersion", () => { ).toBeNull(); }); + it("shows the release and short commit when release metadata is available", () => { + mocks.clientConfig.serverRelease = "0.1.0"; + mocks.clientConfig.serverCommitShort = "aaaaaaa"; + + const { container } = render(); + + expect(container.textContent).toContain("Build v0.1.0 · aaaaaaa"); + }); + it("shows an available update before its worker is ready", () => { mocks.lifecycle.updateStatus = "available"; mocks.lifecycle.updateAvailable = true; @@ -158,7 +177,7 @@ describe("SidebarVersion", () => { const { container } = render(); const buildLink = container.querySelector('a[href*="/commit/"]'); - expect(buildLink?.className).toContain("text-xs"); + expect(buildLink?.className).toContain("text-[11px]"); expect(buildLink?.className).toContain("opacity-50"); expect(buildLink?.querySelector("svg")?.className.baseVal).toContain( "size-3", diff --git a/apps/web/components/shared/sidebar/SidebarVersion.tsx b/apps/web/components/shared/sidebar/SidebarVersion.tsx index bcc02dbb3..f70745383 100644 --- a/apps/web/components/shared/sidebar/SidebarVersion.tsx +++ b/apps/web/components/shared/sidebar/SidebarVersion.tsx @@ -3,6 +3,7 @@ import React from "react"; import Link from "next/link"; import { usePwaLifecycle } from "@/components/pwa/ServiceWorkerRegistration"; +import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { Download, GitBranch, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -26,6 +27,7 @@ export default function SidebarVersion({ placement = "sidebar", }: SidebarVersionProps) { const { t } = useTranslation("profile_menu"); + const { serverRelease, serverCommitShort } = useClientConfig(); const { appBuild, deployedBuild, @@ -40,6 +42,9 @@ export default function SidebarVersion({ : appBuild === "development" ? "development" : "unknown"; + const visibleVersion = serverRelease + ? `v${serverRelease} · ${serverCommitShort ?? visibleBuild}` + : (serverCommitShort ?? visibleBuild); const containerClassName = placement === "profile" ? "flex h-7 min-w-0 items-center justify-between gap-2 text-[11px] leading-4" @@ -68,7 +73,7 @@ export default function SidebarVersion({ const updateStatusClassName = placement === "profile" ? "min-w-0 truncate" : undefined; - const buildLabel = t("build", { build: visibleBuild }); + const buildLabel = t("build", { build: visibleVersion }); return (
diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 43ff0ea2f..8307eda29 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -23,6 +23,9 @@ export const ClientConfigCtx = createContext({ privacyPolicyUrl: undefined, }, serverVersion: undefined, + serverRelease: undefined, + serverCommit: undefined, + serverCommitShort: undefined, disableNewReleaseCheck: true, }); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 2e0e71d4b..3911d3eb2 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,12 +1,11 @@ import bundleAnalyzer from "@next/bundle-analyzer"; import { execSync } from "node:child_process"; -// Fork versioning: surface the git commit as SERVER_VERSION so the sidebar -// shows the build you're running. Docker builds set SERVER_VERSION via a build -// arg; locally we derive it from git (no-op when git isn't available). -if (!process.env.SERVER_VERSION) { +// Fork versioning: keep SERVER_VERSION as the legacy full-commit alias while +// exposing the same commit through the explicit SERVER_COMMIT variable. +if (!process.env.SERVER_COMMIT && !process.env.SERVER_VERSION) { try { - process.env.SERVER_VERSION = execSync("git rev-parse HEAD", { + process.env.SERVER_COMMIT = execSync("git rev-parse HEAD", { stdio: ["ignore", "pipe", "ignore"], }) .toString() @@ -17,7 +16,21 @@ if (!process.env.SERVER_VERSION) { } } -const serviceWorkerBuildVersion = process.env.SERVER_VERSION ?? "development"; +if ( + !process.env.SERVER_COMMIT && + /^[0-9a-f]{7,40}$/i.test(process.env.SERVER_VERSION ?? "") +) { + process.env.SERVER_COMMIT = process.env.SERVER_VERSION; +} +if (!process.env.SERVER_VERSION && process.env.SERVER_COMMIT) { + process.env.SERVER_VERSION = process.env.SERVER_COMMIT; +} + +const serviceWorkerBuildVersion = ( + process.env.SERVER_COMMIT ?? + process.env.SERVER_VERSION ?? + "development" +).toLowerCase(); const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index df53ecb1f..c006701d1 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -8,7 +8,7 @@ # NEXTAUTH_SECRET, MEILI_MASTER_KEY, NEXTAUTH_URL, DISABLE_SIGNUPS, ... services: web: - image: ${KARAKEEP_WEB_IMAGE:-ghcr.io/absolutepraya/marka:web-main} + image: ${KARAKEEP_WEB_IMAGE:-ghcr.io/absolutepraya/marka:web-stable} restart: unless-stopped mem_limit: 512m depends_on: @@ -28,7 +28,7 @@ services: DATA_DIR: /data workers: - image: ${KARAKEEP_WORKERS_IMAGE:-ghcr.io/absolutepraya/marka:workers-main} + image: ${KARAKEEP_WORKERS_IMAGE:-ghcr.io/absolutepraya/marka:workers-stable} restart: unless-stopped mem_limit: 512m depends_on: diff --git a/docker/Dockerfile b/docker/Dockerfile index 6833bd380..604f23329 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -56,7 +56,11 @@ RUN pnpm install --frozen-lockfile COPY . . ARG SERVER_VERSION=development +ARG SERVER_RELEASE= +ARG SERVER_COMMIT= ENV SERVER_VERSION=${SERVER_VERSION} +ENV SERVER_RELEASE=${SERVER_RELEASE} +ENV SERVER_COMMIT=${SERVER_COMMIT} # Build the db migration script RUN cd packages/db && \ @@ -81,11 +85,17 @@ RUN (cd apps/mcp && pnpm --config.verify-deps-before-run=false build) # Replace with node:24-slim as soon as https://github.com/nodejs/node/pull/65042 is fixed and uploaded to Docker hub FROM node:24.18.1-slim AS aio_builder -LABEL org.opencontainers.image.source="https://github.com/karakeep-app/karakeep" +ARG SERVER_VERSION=nightly +ARG SERVER_RELEASE= +ARG SERVER_COMMIT= +LABEL org.opencontainers.image.source="https://github.com/absolutepraya/marka" \ + org.opencontainers.image.version="${SERVER_RELEASE}" \ + org.opencontainers.image.revision="${SERVER_COMMIT}" WORKDIR /app -ARG SERVER_VERSION=nightly ENV SERVER_VERSION=${SERVER_VERSION} +ENV SERVER_RELEASE=${SERVER_RELEASE} +ENV SERVER_COMMIT=${SERVER_COMMIT} ENV PORT 3000 ENV HOSTNAME "0.0.0.0" @@ -219,7 +229,7 @@ ENV USING_LEGACY_SEPARATE_CONTAINERS=true \ # Replace with node:24-slim as soon as https://github.com/nodejs/node/pull/65042 is fixed and uploaded to Docker hub FROM node:24.18.1-slim AS cli -LABEL org.opencontainers.image.source="https://github.com/karakeep-app/karakeep" +LABEL org.opencontainers.image.source="https://github.com/absolutepraya/marka" WORKDIR /app COPY --from=base /app/apps/cli/dist/index.mjs apps/cli/index.mjs @@ -227,7 +237,11 @@ COPY --from=base /app/apps/cli/dist/index.mjs apps/cli/index.mjs WORKDIR /app/apps/cli ARG SERVER_VERSION=nightly +ARG SERVER_RELEASE= +ARG SERVER_COMMIT= ENV SERVER_VERSION=${SERVER_VERSION} +ENV SERVER_RELEASE=${SERVER_RELEASE} +ENV SERVER_COMMIT=${SERVER_COMMIT} ENTRYPOINT ["node", "index.mjs"] @@ -235,7 +249,7 @@ ENTRYPOINT ["node", "index.mjs"] # Replace with node:24-slim as soon as https://github.com/nodejs/node/pull/65042 is fixed and uploaded to Docker hub FROM node:24.18.1-slim AS mcp -LABEL org.opencontainers.image.source="https://github.com/karakeep-app/karakeep" +LABEL org.opencontainers.image.source="https://github.com/absolutepraya/marka" WORKDIR /app COPY --from=base /app/apps/mcp/dist/index.js apps/mcp/index.js diff --git a/docs/adr/0009-release-channel-and-rollback.md b/docs/adr/0009-release-channel-and-rollback.md new file mode 100644 index 000000000..d9fe6242a --- /dev/null +++ b/docs/adr/0009-release-channel-and-rollback.md @@ -0,0 +1,140 @@ +# Release channel and rollback contract + +Status: accepted + +Issue #69 introduces an explicit shared release identity for the web and +workers product pair. It must improve operator-facing provenance without +coupling the release process to SDK, MCP, mobile, extension, or package +publication versioning. + +## Decisions + +### Git tags define shared releases + +An annotated `vMAJOR.MINOR.PATCH` Git tag is the authoritative shared release +identity. The normalized SemVer value is release metadata; package manifest +versions remain independent metadata and are not silently bumped by this +issue. + +A release is built from one eligible source commit. The web and workers +artifacts from that commit receive paired immutable version tags such as +`web-v0.1.0` and `workers-v0.1.0`. Existing paired SHA tags remain valid +rollback artifacts and are retained indefinitely. + +The first Marka release in this contract is `v0.1.0`. Any pre-existing local +tag with that name from unrelated upstream history is not authoritative and +must not be force-pushed as the Marka release. + +### Stable is a paired mutable channel + +The only supported channel in this scope is `stable`, represented by +`web-stable` and `workers-stable`. Channel references are convenience pointers, +not exact rollback identities. Production Compose and the guided Docker +installer use this channel by default. + +The release process must publish and validate both immutable version artifacts +before moving either stable pointer. Because GHCR has no transaction spanning +two image repositories or tags, promotion is sequential. The process records +the previous stable pair, verifies the resulting pair's release provenance, +and restores the previous pointer if the second promotion or verification +fails. Operators can recover from a partial or unhealthy rollout by pinning +both services to one matching immutable version pair or SHA pair. + +### Independent service rollout remains compatible + +The existing Watchtower deployment continues to update the web and workers +services independently. Adjacent releases must therefore remain compatible +during the bounded rolling overlap, including database migration behavior. +The stable channel is considered successfully promoted only when both pointers +resolve to the intended release, but promotion does not claim an atomic +multi-container switch. + +### Legacy source builds are separate + +The release contract covers the current Docker production Compose and guided +Docker installer paths. The legacy source-built `marka-linux.sh` path and its +Debuntu documentation are not silently migrated by this issue; any future +alignment is a separate decision. + +### GitHub Releases are derived publications + +The tag-triggered workflow creates a GitHub Release only after the immutable +web and workers artifacts, stable promotion, and provenance verification have +succeeded. The GitHub Release uses the same `vMAJOR.MINOR.PATCH` tag, has a +`Marka vX.Y.Z` title, and uses GitHub-generated notes that maintainers may +edit. A committed `CHANGELOG.md` is not required by this issue. + +The Git tag and source commit remain authoritative. A GitHub Release is a +human-facing explanation of that release, not a deployment pointer. + +The release workflow proceeds in this order: + +1. validate the annotated tag and eligible source commit; +2. verify the exact-commit blocking CI result; +3. build and publish immutable version and SHA artifacts; +4. verify both image digests and source provenance; +5. promote and verify the stable pair; +6. create the GitHub Release. + +If stable promotion partially fails, the workflow restores the previous stable +pair and fails visibly. If GitHub Release creation fails after deployment +artifacts are verified, a retry may create the missing publication without +rebuilding images or repeating stable promotion. + +### Runtime metadata is additive + +`/api/version` keeps the existing `version` field as a full-commit compatibility +alias and adds structured release metadata: the normalized release number, +the full source commit, and its short display form. New clients use the +structured fields. The PWA continues to compare the full commit for update +identity, while web and mobile user-facing surfaces display the release and +short commit together. + +Development and legacy SHA-only builds remain valid: their release metadata +may be absent, and clients fall back to the existing development, unknown, or +commit-only behavior without treating the build as a numbered release. + +The compatibility response is conceptually: + +```json +{ + "version": "", + "release": "0.1.0", + "commit": "", + "shortCommit": "abcdef0" +} +``` + +The PWA compares the full commit first and the legacy `version` field second; +it never uses the release number alone as its update identity. Web and mobile +surfaces display the release and short commit when available. + +The admin release checker follows Marka's GitHub Releases and compares release +identity only when the running build exposes one. The active image provenance +label points to Marka; historical upstream records are not rewritten. + +## Considered options + +- **Use package manifest versions as the shared release source:** rejected + because the repository contains independently versioned packages and the + issue does not authorize changing their publication semantics. +- **Keep `main` as the production channel:** rejected because a branch name + describes source integration, not a stable deployment contract. +- **Claim atomic stable promotion:** rejected because two independent GHCR + tags and Watchtower service updates cannot be changed transactionally. +- **Remove the existing pull-based Watchtower rollout:** deferred because it + would expand this issue into a coordinated deployment controller. The + compatibility and paired rollback rules provide a bounded migration path. + +## Consequences + +Maintainers must validate a tagged source commit before creating its release, +publish both immutable service artifacts before channel promotion, and retain +enough metadata to prove that the two artifacts came from the same commit. +Operator documentation must distinguish a Git tag, a GitHub Release, an +immutable image tag, a mutable channel pointer, and a running deployment. + +An exact rollback always changes both services together. A channel promotion +or Watchtower rollout can still expose a short compatibility window, so +release changes must preserve web and workers compatibility across that +window. diff --git a/docs/docs/02-installation/06-debuntu.md b/docs/docs/02-installation/06-debuntu.md index a8ee4b157..a04a07aad 100644 --- a/docs/docs/02-installation/06-debuntu.md +++ b/docs/docs/02-installation/06-debuntu.md @@ -1,5 +1,12 @@ # Debian 12/Ubuntu 24.04 +:::warning Release contract scope +The legacy source-built `marka-linux.sh` and this Debuntu page are outside the +issue #69 release and rollback contract. They do not consume the paired GHCR +`stable` channel or its immutable release image tags. For the supported +release-based Docker path, use the [guided Docker setup](./11-guided-docker-setup.md). +::: + :::warning This script is a stripped-down version of those found in the [Proxmox Community Scripts](https://github.com/community-scripts/ProxmoxVE) repo. It has been adapted to work on baremetal Debian 12 or Ubuntu 24.04 installs **only**. Any other use is not supported and you use this script at your own risk. ::: diff --git a/docs/docs/02-installation/11-guided-docker-setup.md b/docs/docs/02-installation/11-guided-docker-setup.md index d26f742fd..8967ed738 100644 --- a/docs/docs/02-installation/11-guided-docker-setup.md +++ b/docs/docs/02-installation/11-guided-docker-setup.md @@ -69,8 +69,13 @@ Fresh deployments always start with signups enabled so the first administrator a The script uses the stable Compose project name `karakeep` for compatibility and the paired Marka images: -- `ghcr.io/absolutepraya/marka:web-main` -- `ghcr.io/absolutepraya/marka:workers-main` +- `ghcr.io/absolutepraya/marka:web-stable` +- `ghcr.io/absolutepraya/marka:workers-stable` + +`stable` is a mutable channel pointer for the current supported release. The +release workflow publishes immutable `web-v` and `workers-v` +tags, along with matching `web-sha-` and `workers-sha-` rollback tags. +The two application images are always treated as a pair. A default fully featured installation runs four containers: @@ -156,7 +161,7 @@ The script copy in the configuration directory also acts as the management helpe `backup` briefly stops the web and worker services, archives the authoritative SQLite/assets data directory, then restores them if they were running. Meilisearch is not included because it is a derived search index. The backup command checks for `tar` when it is invoked. -`update` pulls the current `web-main` and `workers-main` images and recreates changed services. For rollback, pin both images to matching immutable `web-sha-` and `workers-sha-` tags from the same known-good commit. +`update` pulls the current paired `web-stable` and `workers-stable` images and recreates changed services. For an exact rollback, edit both application image lines in the generated `docker-compose.yml` to matching immutable `web-v` and `workers-v` tags, or matching `web-sha-` and `workers-sha-` tags from one known-good source commit. Do not roll back only one service. After both services are healthy, restore the stable tags before the next normal update. `uninstall` removes the Compose containers and network only. It intentionally keeps both the configuration directory and persistent data directory. diff --git a/docs/docs/03-configuration/01-environment-variables.md b/docs/docs/03-configuration/01-environment-variables.md index 3cab645b6..8bc80aa93 100644 --- a/docs/docs/03-configuration/01-environment-variables.md +++ b/docs/docs/03-configuration/01-environment-variables.md @@ -14,6 +14,8 @@ The app is mainly configured by environment variables. All the used environment | ASSETS_DIR | No | Not set | The path where crawled assets will be stored. If not set, defaults to `${DATA_DIR}/assets`. | | NEXTAUTH_URL | Yes | Not set | Should point to the address of your server. The app will function without it, but will redirect you to wrong addresses on signout for example. | | NEXTAUTH_SECRET | Yes | Not set | Random string used to sign the JWT tokens. Generate one with `openssl rand -base64 36`. | +| SERVER_RELEASE | No | Not set | Build metadata set by the release workflow to the normalized `MAJOR.MINOR.PATCH` value. It is displayed alongside the short source commit when available. | +| SERVER_COMMIT | No | Not set | Build metadata set by the release workflow to the full source commit. The existing `SERVER_VERSION` value remains the compatibility alias for this commit. | | MEILI_ADDR | No | Not set | The address of meilisearch. If not set, Search will be disabled. E.g. (`http://meilisearch:7700`) | | MEILI_MASTER_KEY | Only in Prod and if search is enabled | Not set | The master key configured for meilisearch. Not needed in development environment. Generate one with `openssl rand -base64 36 \| tr -dc 'A-Za-z0-9'` | | MAX_ASSET_SIZE_MB | No | 50 | Sets the maximum allowed asset size (in MB) to be uploaded | diff --git a/docs/operator-setup.md b/docs/operator-setup.md index 8e49c759a..41be59327 100644 --- a/docs/operator-setup.md +++ b/docs/operator-setup.md @@ -204,16 +204,21 @@ Repository-specific notes: This repository deploys with a **pull-based split Docker flow**. -### Build path -- `.github/workflows/docker.yml` builds the `web` and `workers` targets from the same successful `main` commit -- the workflow first pushes matching immutable `:web-sha-` and `:workers-sha-` tags, then promotes both mutable release tags only after both builds succeed -- the mutable release tags are `ghcr.io//marka:web-main` and `ghcr.io//marka:workers-main` +### Release and build path +- an annotated `vMAJOR.MINOR.PATCH` Git tag is the shared web and workers release identity +- the tag must point to a commit reachable from `main` with successful exact-commit blocking CI: lint, format, typecheck, tests, and open-api-spec +- `.github/workflows/release.yml` builds paired immutable `:web-v` and `:workers-v` images, plus matching `:web-sha-` and `:workers-sha-` rollback tags +- the workflow validates source metadata and promotes the mutable `:web-stable` and `:workers-stable` channel only after both immutable images are verified +- GitHub Releases are generated from the same tag after image promotion; the Git tag and source commit remain authoritative +- `.github/workflows/docker.yml` keeps commit-addressed SHA images available for successful `main` builds, but does not move the stable channel +- package manifest versions remain independent of the shared product release - `web` runs Next.js and owns database migrations - `workers` runs background work with `WORKER_PROFILE=screenshot-first` ### Deploy path - the VPS runs a Watchtower container -- Watchtower polls the paired release tags and rolls `web` and `workers` forward independently after their immutable images have both been published +- production Compose defaults to the paired `ghcr.io//marka:web-stable` and `ghcr.io//marka:workers-stable` channel +- Watchtower polls the paired stable tags and rolls `web` and `workers` forward independently after their immutable release images have both been published - this is a bounded rolling overlap, not an atomic multi-container switch: every release must keep `web` and `workers` compatible with the immediately preceding release, including database migrations - Compose starts workers only after web is healthy and Meilisearch has started - Browserless is a token-protected private service attached through the external `karakeep-renderer` network @@ -224,6 +229,13 @@ Important characteristics: - GHCR package is public, so the VPS pulls anonymously - the canonical production compose is `deploy/docker-compose.prod.yml` +For an exact rollback, set both `KARAKEEP_WEB_IMAGE` and +`KARAKEEP_WORKERS_IMAGE` to matching immutable version tags, or matching SHA +tags from one known-good source commit. Restore the stable channel only after +both services have been verified healthy. A stable-channel promotion can +temporarily expose the adjacent web and workers builds because Watchtower is +not an atomic multi-container switch. + ## Production compose Canonical compose file: diff --git a/package.json b/package.json index 96a167346..2f2f729ca 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:infra:down": "bash scripts/dev-infra.sh down", "assets:marka": "node scripts/generate-marka-assets.mjs", "test:marka-assets": "node --test scripts/generate-marka-assets.test.mjs", + "test:release-contract": "node --test scripts/release-contract.test.mjs && bash scripts/promote-release.test.sh", "reader:smoke": "node scripts/reader-view-smoke.mjs", "roadmap:check": "node scripts/render-roadmap.mjs --check", "roadmap:render": "node scripts/render-roadmap.mjs --render", diff --git a/packages/api/routes/version.ts b/packages/api/routes/version.ts index 18e045afb..afd8a3249 100644 --- a/packages/api/routes/version.ts +++ b/packages/api/routes/version.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import serverConfig from "@karakeep/shared/config"; +import { createServerVersionResponse } from "@karakeep/shared/version"; import { Context } from "@karakeep/trpc"; const version = new Hono<{ @@ -8,9 +9,13 @@ const version = new Hono<{ ctx: Context; }; }>().get("/", (c) => { - return c.json({ - version: serverConfig.serverVersion ?? "unknown", - }); + return c.json( + createServerVersionResponse({ + legacyVersion: serverConfig.serverVersion, + release: serverConfig.serverRelease, + commit: serverConfig.serverCommit, + }), + ); }); export default version; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index a50a81a44..1872f343f 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -2,6 +2,8 @@ import crypto from "node:crypto"; import path from "path"; import { z } from "zod"; +import { createServerVersionResponse } from "./version"; + const stringBool = (defaultValue: string) => z .string() @@ -154,6 +156,8 @@ const allEnv = z.object({ // Build only flag SERVER_VERSION: z.string().optional(), + SERVER_RELEASE: z.string().optional(), + SERVER_COMMIT: z.string().optional(), CHANGELOG_VERSION: z.string().optional(), DISABLE_NEW_RELEASE_CHECK: stringBool("false"), @@ -406,6 +410,8 @@ const serverConfigSchema = allEnv.transform((val, ctx) => { privacyPolicyUrl: val.PRIVACY_POLICY_URL, }, serverVersion: val.SERVER_VERSION, + serverRelease: val.SERVER_RELEASE, + serverCommit: val.SERVER_COMMIT, changelogVersion: val.CHANGELOG_VERSION, disableNewReleaseCheck: val.DISABLE_NEW_RELEASE_CHECK, usingLegacySeparateContainers: val.USING_LEGACY_SEPARATE_CONTAINERS, @@ -539,6 +545,12 @@ const serverConfigSchema = allEnv.transform((val, ctx) => { const serverConfig: Readonly> = serverConfigSchema.parse(process.env); +const serverVersion = createServerVersionResponse({ + legacyVersion: serverConfig.serverVersion, + release: serverConfig.serverRelease, + commit: serverConfig.serverCommit, +}); + // Always explicitly pick up stuff from server config to avoid accidentally leaking stuff export const clientConfig = { publicUrl: serverConfig.publicUrl, @@ -566,6 +578,9 @@ export const clientConfig = { privacyPolicyUrl: serverConfig.legal.privacyPolicyUrl, }, serverVersion: serverConfig.serverVersion, + serverRelease: serverVersion.release ?? undefined, + serverCommit: serverVersion.commit ?? undefined, + serverCommitShort: serverVersion.shortCommit ?? undefined, disableNewReleaseCheck: serverConfig.disableNewReleaseCheck, }; export type ClientConfig = typeof clientConfig; diff --git a/packages/shared/types/config.ts b/packages/shared/types/config.ts index 7c130a33c..a74effab8 100644 --- a/packages/shared/types/config.ts +++ b/packages/shared/types/config.ts @@ -29,5 +29,8 @@ export const zClientConfigSchema = z.object({ privacyPolicyUrl: z.string().optional(), }), serverVersion: z.string().optional(), + serverRelease: z.string().optional(), + serverCommit: z.string().optional(), + serverCommitShort: z.string().optional(), disableNewReleaseCheck: z.boolean(), }); diff --git a/packages/shared/version.test.ts b/packages/shared/version.test.ts new file mode 100644 index 000000000..956e20217 --- /dev/null +++ b/packages/shared/version.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + createServerVersionResponse, + formatServerVersionDisplay, + normalizeReleaseVersion, +} from "./version"; + +describe("server version metadata", () => { + it("normalizes release tags without accepting malformed versions", () => { + expect(normalizeReleaseVersion("v0.1.0")).toBe("0.1.0"); + expect(normalizeReleaseVersion("0.1.0")).toBe("0.1.0"); + expect(normalizeReleaseVersion("v01.2.3")).toBeNull(); + expect(normalizeReleaseVersion("nightly")).toBeNull(); + }); + + it("prefers the explicit full commit and derives the short commit", () => { + expect( + createServerVersionResponse({ + legacyVersion: "development", + release: "v0.1.0", + commit: "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + }), + ).toEqual({ + version: "abcdef0123456789abcdef0123456789abcdef01", + release: "0.1.0", + commit: "abcdef0123456789abcdef0123456789abcdef01", + shortCommit: "abcdef0", + }); + }); + + it("uses the legacy commit field when the new commit field is absent", () => { + const version = createServerVersionResponse({ + legacyVersion: "abcdef0123456789abcdef0123456789abcdef01", + }); + + expect(version.commit).toBe("abcdef0123456789abcdef0123456789abcdef01"); + expect(version.shortCommit).toBe("abcdef0"); + expect(formatServerVersionDisplay(version)).toBe("abcdef0"); + }); + + it("preserves development fallbacks without inventing release metadata", () => { + const version = createServerVersionResponse({ + legacyVersion: "development", + release: "not-a-release", + }); + + expect(version).toEqual({ + version: "development", + release: null, + commit: null, + shortCommit: null, + }); + expect(formatServerVersionDisplay(version)).toBe("development"); + }); +}); diff --git a/packages/shared/version.ts b/packages/shared/version.ts new file mode 100644 index 000000000..a41c6b45c --- /dev/null +++ b/packages/shared/version.ts @@ -0,0 +1,63 @@ +export const RELEASE_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i; + +export interface ServerVersionInput { + legacyVersion?: string | null; + release?: string | null; + commit?: string | null; +} + +export interface ServerVersionResponse { + version: string; + release: string | null; + commit: string | null; + shortCommit: string | null; +} + +export function normalizeReleaseVersion(value?: string | null): string | null { + const release = value?.trim(); + + if (!release) { + return null; + } + + const normalized = release.startsWith("v") ? release.slice(1) : release; + + return RELEASE_VERSION_PATTERN.test(normalized) ? normalized : null; +} + +export function normalizeCommitSha(value?: unknown): string | null { + const commit = typeof value === "string" ? value.trim() : null; + + return commit && COMMIT_SHA_PATTERN.test(commit) + ? commit.toLowerCase() + : null; +} + +export function createServerVersionResponse({ + legacyVersion, + release, + commit, +}: ServerVersionInput): ServerVersionResponse { + const normalizedLegacyVersion = legacyVersion?.trim() || null; + const normalizedCommit = + normalizeCommitSha(commit) ?? normalizeCommitSha(normalizedLegacyVersion); + + return { + version: normalizedCommit ?? normalizedLegacyVersion ?? "unknown", + release: normalizeReleaseVersion(release), + commit: normalizedCommit, + shortCommit: normalizedCommit?.slice(0, 7) ?? null, + }; +} + +export function formatServerVersionDisplay( + version: ServerVersionResponse, +): string { + if (version.release && version.shortCommit) { + return `${version.release} · ${version.shortCommit}`; + } + + return version.shortCommit ?? version.commit ?? version.version; +} diff --git a/scripts/install.sh b/scripts/install.sh index 29efce4e1..f4ac60267 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -Eeuo pipefail -SCRIPT_VERSION="1" +SCRIPT_VERSION="2" DEFAULT_INSTALL_DIR="${HOME}/marka" DEFAULT_PUBLIC_URL="http://localhost:3000" DEFAULT_PORT="3000" @@ -10,8 +10,8 @@ DEFAULT_SEARCH_MODE="managed" DEFAULT_RENDERER_MODE="managed" DEFAULT_AI_MODE="deferred" COMPOSE_PROJECT_NAME="karakeep" -WEB_IMAGE="ghcr.io/absolutepraya/marka:web-main" -WORKERS_IMAGE="ghcr.io/absolutepraya/marka:workers-main" +WEB_IMAGE="ghcr.io/absolutepraya/marka:web-stable" +WORKERS_IMAGE="ghcr.io/absolutepraya/marka:workers-stable" MEILI_IMAGE="getmeili/meilisearch:v1.41.0" CHROME_IMAGE="ghcr.io/karakeep-app/karakeep-chrome:release" @@ -594,7 +594,7 @@ write_generated_files() { say "# Stable project name: $COMPOSE_PROJECT_NAME" say "# Update with: ./install.sh update" say "# Back up first with: ./install.sh backup" - say "# For rollback, pin web/workers to matching immutable web-sha- and workers-sha- tags from one known-good commit." + say "# For rollback, pin web/workers to matching immutable web-v and workers-v tags, or matching web-sha- and workers-sha- tags, from one known-good commit." say "name: $COMPOSE_PROJECT_NAME" say "" say "services:" @@ -804,7 +804,7 @@ update_command() { check_platform management_install_dir check_docker - info "Pulling the current paired web-main/workers-main images and recreating changed services..." + info "Pulling the configured web and workers images and recreating changed services..." compose_in_install_dir config --quiet >/dev/null compose_in_install_dir pull compose_in_install_dir up -d --remove-orphans diff --git a/scripts/install.test.sh b/scripts/install.test.sh index f6b5f27fa..84f6099f2 100644 --- a/scripts/install.test.sh +++ b/scripts/install.test.sh @@ -131,8 +131,10 @@ bash "$INSTALLER" --non-interactive --no-start --yes \ --install-dir "$managed/install" --data-dir "$managed/data" \ --public-url https://keep.example.com --data-mode fresh \ --search managed --renderer managed --ai deferred >/dev/null -assert_contains "$managed/install/docker-compose.yml" "ghcr.io/absolutepraya/marka:web-main" -assert_contains "$managed/install/docker-compose.yml" "ghcr.io/absolutepraya/marka:workers-main" +assert_contains "$managed/install/docker-compose.yml" "ghcr.io/absolutepraya/marka:web-stable" +assert_contains "$managed/install/docker-compose.yml" "ghcr.io/absolutepraya/marka:workers-stable" +assert_contains "$managed/install/docker-compose.yml" "web-v" +assert_contains "$managed/install/docker-compose.yml" "workers-v" assert_contains "$managed/install/docker-compose.yml" "getmeili/meilisearch:v1.41.0" assert_contains "$managed/install/docker-compose.yml" "ghcr.io/karakeep-app/karakeep-chrome:release" assert_contains "$managed/install/docker-compose.yml" "init: true" diff --git a/scripts/promote-release.sh b/scripts/promote-release.sh new file mode 100644 index 000000000..9a4bea247 --- /dev/null +++ b/scripts/promote-release.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${IMAGE_NAME:?IMAGE_NAME is required}" +: "${VERSION:?VERSION is required}" +: "${WEB_DIGEST:?WEB_DIGEST is required}" +: "${WORKERS_DIGEST:?WORKERS_DIGEST is required}" + +get_digest() { + docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}' 2>/dev/null | head -n 1 +} + +web_stable="$IMAGE_NAME:web-stable" +workers_stable="$IMAGE_NAME:workers-stable" +previous_web="$(get_digest "$web_stable" || true)" +previous_workers="$(get_digest "$workers_stable" || true)" + +manual_recovery() { + echo "Manual stable-channel recovery is required." >&2 + if [[ -n "$previous_web" && -n "$previous_workers" ]]; then + echo "Restore the previous stable pair with:" >&2 + printf ' docker buildx imagetools create --tag %s %s\n' \ + "$web_stable" "$IMAGE_NAME@$previous_web" >&2 + printf ' docker buildx imagetools create --tag %s %s\n' \ + "$workers_stable" "$IMAGE_NAME@$previous_workers" >&2 + else + echo "No complete previous stable pair was available. Complete the intended release with:" >&2 + printf ' docker buildx imagetools create --tag %s %s\n' \ + "$web_stable" "$IMAGE_NAME:web-v$VERSION" >&2 + printf ' docker buildx imagetools create --tag %s %s\n' \ + "$workers_stable" "$IMAGE_NAME:workers-v$VERSION" >&2 + fi +} + +if [[ -n "$previous_web" && -z "$previous_workers" && "$previous_web" != "$WEB_DIGEST" ]]; then + echo "Stable channel has only web-stable, and it does not match the intended release." >&2 + manual_recovery + exit 1 +fi +if [[ -z "$previous_web" && -n "$previous_workers" && "$previous_workers" != "$WORKERS_DIGEST" ]]; then + echo "Stable channel has only workers-stable, and it does not match the intended release." >&2 + manual_recovery + exit 1 +fi + +verify_pair() { + local expected_web="$1" expected_workers="$2" actual_web actual_workers + actual_web="$(get_digest "$web_stable" || true)" + actual_workers="$(get_digest "$workers_stable" || true)" + if [[ "$actual_web" == "$expected_web" && "$actual_workers" == "$expected_workers" ]]; then + return 0 + fi + echo "Stable pair mismatch: web=$actual_web workers=$actual_workers; expected web=$expected_web workers=$expected_workers." >&2 + return 1 +} + +restore_previous_pair() { + local failed=0 + if ! docker buildx imagetools create --tag "$web_stable" "$IMAGE_NAME@$previous_web"; then + echo "Failed to restore $web_stable." >&2 + failed=1 + fi + if ! docker buildx imagetools create --tag "$workers_stable" "$IMAGE_NAME@$previous_workers"; then + echo "Failed to restore $workers_stable." >&2 + failed=1 + fi + if ! verify_pair "$previous_web" "$previous_workers"; then + failed=1 + fi + if ((failed)); then + manual_recovery + return 1 + fi + echo "Restored the previous stable image pair." +} + +rollback() { + if [[ -n "$previous_web" && -n "$previous_workers" ]]; then + restore_previous_pair + return + fi + echo "No complete previous stable pair existed. The current release may be partially promoted." >&2 + manual_recovery + return 1 +} + +promote_ref() { + local target="$1" source="$2" expected="$3" current + current="$(get_digest "$target" || true)" + if [[ "$current" == "$expected" ]]; then + echo "$target already points to the intended digest." + return 0 + fi + docker buildx imagetools create --tag "$target" "$source" +} + +if verify_pair "$WEB_DIGEST" "$WORKERS_DIGEST"; then + echo "Stable channel already points to $VERSION." + exit 0 +fi + +if ! promote_ref "$web_stable" "$IMAGE_NAME:web-v$VERSION" "$WEB_DIGEST"; then + rollback + exit 1 +fi +if ! promote_ref "$workers_stable" "$IMAGE_NAME:workers-v$VERSION" "$WORKERS_DIGEST"; then + rollback + exit 1 +fi +if ! verify_pair "$WEB_DIGEST" "$WORKERS_DIGEST"; then + echo "Stable channel verification failed." >&2 + rollback + exit 1 +fi diff --git a/scripts/promote-release.test.sh b/scripts/promote-release.test.sh new file mode 100644 index 000000000..6788e3b2b --- /dev/null +++ b/scripts/promote-release.test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROMOTER="$SCRIPT_DIR/promote-release.sh" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_state() { + local state="$1" ref="$2" expected="$3" actual + actual="$(grep -F "${ref}=" "$state" | cut -d= -f2- || true)" + [[ "$actual" == "$expected" ]] || fail "$ref expected $expected, got $actual" +} + +make_fake_docker() { + local dir="$1" + mkdir -p "$dir" + cat >"$dir/docker" <<'EOF_DOCKER' +#!/usr/bin/env bash +set -euo pipefail + +state_value() { + grep -F "${1}=" "$FAKE_RELEASE_STATE" | cut -d= -f2- || true +} + +set_state_value() { + local ref="$1" value="$2" temp + temp="${FAKE_RELEASE_STATE}.tmp" + grep -Fv "${ref}=" "$FAKE_RELEASE_STATE" >"$temp" || true + printf '%s=%s\n' "$ref" "$value" >>"$temp" + mv "$temp" "$FAKE_RELEASE_STATE" +} + +if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then + value="$(state_value "$4")" + [[ -n "$value" ]] || exit 1 + printf '%s\n' "$value" + exit 0 +fi + +if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "create" ]]; then + if [[ -n "${FAKE_RELEASE_LOG:-}" ]]; then + printf '%s\n' "$*" >>"$FAKE_RELEASE_LOG" + fi + target="$5" + source="$6" + if [[ "${FAKE_RELEASE_FAIL_WORKERS:-0}" == "1" && "$target" == *workers-stable ]]; then + exit 1 + fi + if [[ "$source" == *@* ]]; then + value="${source##*@}" + else + value="$(state_value "$source")" + fi + [[ -n "$value" ]] || exit 1 + set_state_value "$target" "$value" + exit 0 +fi + +exit 1 +EOF_DOCKER + chmod +x "$dir/docker" +} + +bash -n "$PROMOTER" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT +fake_bin="$root/bin" +make_fake_docker "$fake_bin" +state="$root/state" +cat >"$state" <<'EOF_STATE' +ghcr.io/absolutepraya/marka:web-stable=sha256:web-old +ghcr.io/absolutepraya/marka:workers-stable=sha256:workers-old +ghcr.io/absolutepraya/marka:web-v0.1.0=sha256:web-new +ghcr.io/absolutepraya/marka:workers-v0.1.0=sha256:workers-new +EOF_STATE + +PATH="$fake_bin:$PATH" \ +FAKE_RELEASE_STATE="$state" \ +IMAGE_NAME="ghcr.io/absolutepraya/marka" \ +VERSION="0.1.0" \ +WEB_DIGEST="sha256:web-new" \ +WORKERS_DIGEST="sha256:workers-new" \ +bash "$PROMOTER" +assert_state "$state" "ghcr.io/absolutepraya/marka:web-stable" "sha256:web-new" +assert_state "$state" "ghcr.io/absolutepraya/marka:workers-stable" "sha256:workers-new" + +cat >"$state" <<'EOF_STATE' +ghcr.io/absolutepraya/marka:web-stable=sha256:web-new +ghcr.io/absolutepraya/marka:web-v0.1.0=sha256:web-new +ghcr.io/absolutepraya/marka:workers-v0.1.0=sha256:workers-new +EOF_STATE +PATH="$fake_bin:$PATH" \ +FAKE_RELEASE_STATE="$state" \ +IMAGE_NAME="ghcr.io/absolutepraya/marka" \ +VERSION="0.1.0" \ +WEB_DIGEST="sha256:web-new" \ +WORKERS_DIGEST="sha256:workers-new" \ +bash "$PROMOTER" +assert_state "$state" "ghcr.io/absolutepraya/marka:web-stable" "sha256:web-new" +assert_state "$state" "ghcr.io/absolutepraya/marka:workers-stable" "sha256:workers-new" + +cat >"$state" <<'EOF_STATE' +ghcr.io/absolutepraya/marka:web-stable=sha256:web-old +ghcr.io/absolutepraya/marka:workers-stable=sha256:workers-old +ghcr.io/absolutepraya/marka:web-v0.1.0=sha256:web-new +ghcr.io/absolutepraya/marka:workers-v0.1.0=sha256:workers-new +EOF_STATE +if PATH="$fake_bin:$PATH" \ + FAKE_RELEASE_STATE="$state" \ + FAKE_RELEASE_FAIL_WORKERS="1" \ + IMAGE_NAME="ghcr.io/absolutepraya/marka" \ + VERSION="0.1.0" \ + WEB_DIGEST="sha256:web-new" \ + WORKERS_DIGEST="sha256:workers-new" \ + bash "$PROMOTER" >/dev/null 2>&1; then + fail "promotion unexpectedly succeeded when workers promotion failed" +fi +assert_state "$state" "ghcr.io/absolutepraya/marka:web-stable" "sha256:web-old" +assert_state "$state" "ghcr.io/absolutepraya/marka:workers-stable" "sha256:workers-old" + +failure_log="$root/failure.log" +: >"$failure_log" +if failure_output="$(PATH="$fake_bin:$PATH" \ + FAKE_RELEASE_STATE="$state" \ + FAKE_RELEASE_LOG="$failure_log" \ + FAKE_RELEASE_FAIL_WORKERS="1" \ + IMAGE_NAME="ghcr.io/absolutepraya/marka" \ + VERSION="0.1.0" \ + WEB_DIGEST="sha256:web-new" \ + WORKERS_DIGEST="sha256:workers-new" \ + bash "$PROMOTER" 2>&1)"; then + fail "promotion unexpectedly succeeded when rollback restoration failed" +fi +grep -Fq "Manual stable-channel recovery is required." <<<"$failure_output" || + fail "rollback failure did not print manual recovery instructions" +[[ "$(grep -Fc 'ghcr.io/absolutepraya/marka:web-stable' "$failure_log")" -ge 2 ]] || + fail "web rollback was not attempted after worker promotion failure" +[[ "$(grep -Fc 'ghcr.io/absolutepraya/marka:workers-stable' "$failure_log")" -ge 2 ]] || + fail "worker rollback was not attempted after worker promotion failure" + +printf 'Release promotion tests passed.\n' diff --git a/scripts/release-contract.mjs b/scripts/release-contract.mjs new file mode 100644 index 000000000..bd0fa7e3b --- /dev/null +++ b/scripts/release-contract.mjs @@ -0,0 +1,116 @@ +import { fileURLToPath } from "node:url"; + +export const RELEASE_TAG_PATTERN = + /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i; +export const FIRST_RELEASE_TAG = "v0.1.0"; + +export function parseReleaseTag(tag) { + if (typeof tag !== "string" || !RELEASE_TAG_PATTERN.test(tag)) { + throw new Error( + `Release tag must match vMAJOR.MINOR.PATCH with no leading zeroes: ${tag}`, + ); + } + + return { + tag, + version: tag.slice(1), + }; +} + +export function assertReleaseTagIsNew(tag, existingTags) { + parseReleaseTag(tag); + if (existingTags.includes(tag)) { + throw new Error(`Release tag already exists: ${tag}`); + } +} + +export function assertFirstReleaseTag(tag, existingReleaseTags) { + parseReleaseTag(tag); + if (existingReleaseTags.length === 0 && tag !== FIRST_RELEASE_TAG) { + throw new Error( + `The first Marka release must be ${FIRST_RELEASE_TAG}, received ${tag}`, + ); + } +} + +export function releaseImageTags({ releaseTag, commit }) { + const { version } = parseReleaseTag(releaseTag); + if (typeof commit !== "string" || !COMMIT_SHA_PATTERN.test(commit)) { + throw new Error( + `Release commit must be a 7 to 40 character SHA: ${commit}`, + ); + } + + const shortCommit = commit.slice(0, 12).toLowerCase(); + return { + version, + shortCommit, + webVersion: `web-v${version}`, + workersVersion: `workers-v${version}`, + webSha: `web-sha-${shortCommit}`, + workersSha: `workers-sha-${shortCommit}`, + webStable: "web-stable", + workersStable: "workers-stable", + }; +} + +export function releasePromotionPlan({ + imageName, + releaseTag, + commit, + previous, +}) { + const tags = releaseImageTags({ releaseTag, commit }); + const rollback = previous + ? [ + { + target: `${imageName}:${tags.webStable}`, + source: `${imageName}@${previous.webDigest}`, + }, + { + target: `${imageName}:${tags.workersStable}`, + source: `${imageName}@${previous.workersDigest}`, + }, + ] + : []; + + return { + immutable: [ + `${imageName}:${tags.webVersion}`, + `${imageName}:${tags.workersVersion}`, + `${imageName}:${tags.webSha}`, + `${imageName}:${tags.workersSha}`, + ], + stable: [ + { + target: `${imageName}:${tags.webStable}`, + source: `${imageName}:${tags.webVersion}`, + }, + { + target: `${imageName}:${tags.workersStable}`, + source: `${imageName}:${tags.workersVersion}`, + }, + ], + rollback, + }; +} + +const isCli = process.argv[1] === fileURLToPath(import.meta.url); + +if (isCli) { + const [command, value] = process.argv.slice(2); + + try { + if (command !== "validate-tag" || !value) { + throw new Error( + "Usage: node scripts/release-contract.mjs validate-tag vMAJOR.MINOR.PATCH", + ); + } + + console.log(JSON.stringify(parseReleaseTag(value))); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/release-contract.test.mjs b/scripts/release-contract.test.mjs new file mode 100644 index 000000000..521a92ede --- /dev/null +++ b/scripts/release-contract.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertFirstReleaseTag, + assertReleaseTagIsNew, + parseReleaseTag, + releaseImageTags, + releasePromotionPlan, +} from "./release-contract.mjs"; + +test("parses the first release tag and rejects malformed versions", () => { + assert.deepEqual(parseReleaseTag("v0.1.0"), { + tag: "v0.1.0", + version: "0.1.0", + }); + assert.throws(() => parseReleaseTag("0.01.0")); + assert.throws(() => parseReleaseTag("0.1.0")); + assert.throws(() => parseReleaseTag("v1.2")); +}); + +test("rejects duplicate release tags before release work starts", () => { + assert.doesNotThrow(() => assertReleaseTagIsNew("v0.1.0", ["v0.0.9"])); + assert.throws(() => assertReleaseTagIsNew("v0.1.0", ["v0.1.0"])); +}); + +test("requires v0.1.0 as the first remote Marka release", () => { + assert.doesNotThrow(() => assertFirstReleaseTag("v0.1.0", [])); + assert.throws(() => assertFirstReleaseTag("v0.1.1", [])); + assert.doesNotThrow(() => assertFirstReleaseTag("v0.1.1", ["v0.1.0"])); +}); + +test("creates paired immutable version and commit image tags", () => { + assert.deepEqual( + releaseImageTags({ + releaseTag: "v1.2.3", + commit: "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + }), + { + version: "1.2.3", + shortCommit: "abcdef012345", + webVersion: "web-v1.2.3", + workersVersion: "workers-v1.2.3", + webSha: "web-sha-abcdef012345", + workersSha: "workers-sha-abcdef012345", + webStable: "web-stable", + workersStable: "workers-stable", + }, + ); +}); + +test("keeps stable promotion and rollback paired by service", () => { + const plan = releasePromotionPlan({ + imageName: "ghcr.io/absolutepraya/marka", + releaseTag: "v0.1.0", + commit: "abcdef0123456789abcdef0123456789abcdef01", + previous: { + webDigest: "sha256:web-old", + workersDigest: "sha256:workers-old", + }, + }); + + assert.deepEqual(plan.stable, [ + { + target: "ghcr.io/absolutepraya/marka:web-stable", + source: "ghcr.io/absolutepraya/marka:web-v0.1.0", + }, + { + target: "ghcr.io/absolutepraya/marka:workers-stable", + source: "ghcr.io/absolutepraya/marka:workers-v0.1.0", + }, + ]); + assert.deepEqual(plan.rollback, [ + { + target: "ghcr.io/absolutepraya/marka:web-stable", + source: "ghcr.io/absolutepraya/marka@sha256:web-old", + }, + { + target: "ghcr.io/absolutepraya/marka:workers-stable", + source: "ghcr.io/absolutepraya/marka@sha256:workers-old", + }, + ]); +});