name: release # Maintainer-only release workflow. # Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). # # Flow (one click → everything below): # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z # 2. build-web — build Next.js standalone bundle (shared across arches) # 3. build-cli — build + esbuild-bundle CLI (shared across arches) # 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms # 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming # the prebuilt linux bundles from step 4 # 6. docker-manifest — stitch the two arch digests into the user-facing # multi-arch tags via `buildx imagetools create` # 7. docker-universal-{amd64,arm64} — native universal builds on top of # docker-manifest's per-arch base # 8. docker-universal-manifest — size-gate amd64 staging, then promote both # arches to the user-facing :universal tags # 9. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes # 10. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs # 11. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev # # npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. # Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow # configured as its trusted publisher on npmjs.com (Settings → Publishing access). # # Required secrets: # HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key on: workflow_dispatch: inputs: bump: description: "Version bump" required: true type: choice default: minor options: - patch - minor - major dry_run: description: "Dry run (no PR, tag, npm publish, or tap update)" type: boolean default: false # Default to read-only at the workflow level. Each job opts into the writes # it actually needs. permissions: contents: read jobs: prepare: name: Prepare release (bump version, PR, tag) runs-on: ubuntu-latest permissions: contents: write # push release branch + tag pull-requests: write # create + merge release PR (required by main ruleset) outputs: version: ${{ steps.compute.outputs.version }} tag: ${{ steps.compute.outputs.tag }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" - name: Install git-cliff env: # Pinned to v2.13.1 to match git-cliff-action@v4's default runtime. # To bump, also update the sha512 below — fetch the # new one from: # https://github.com/orhun/git-cliff/releases/download//.sha512 CLIFF_VERSION: "2.13.1" CLIFF_ASSET: "git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz" CLIFF_SHA512: "e716cce3a07dda41b1e370d6afbd7a59eb3d4739509fb7856aeec8da2be28c0396584e29e106141c1a1c535c1827dbc1f60417524f5cfb1da9e11f700bd00f30" run: | set -euo pipefail curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/${CLIFF_ASSET}" -o /tmp/git-cliff.tar.gz echo "${CLIFF_SHA512} /tmp/git-cliff.tar.gz" | sha512sum -c - tar -xz -C /tmp -f /tmp/git-cliff.tar.gz sudo mv "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/ rm -rf /tmp/git-cliff.tar.gz "/tmp/git-cliff-${CLIFF_VERSION}" git-cliff --version - name: Configure git identity run: | git config user.email "release-bot@kandev" git config user.name "kandev release bot" - name: Compute next version id: compute env: BUMP: ${{ inputs.bump }} run: | set -euo pipefail PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) LATEST_TAG="$(git tag --list 'v*' | python3 -c " import sys, re best = (0, 0, 0) for t in (l.strip() for l in sys.stdin if l.strip()): s = t.lstrip('v') m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) m2 = re.match(r'^(\d+)\.(\d+)$', s) if m3: v = tuple(int(x) for x in m3.groups()) elif m2: v = (int(m2.group(1)), int(m2.group(2)), 0) else: continue if v > best: best = v print(f'{best[0]}.{best[1]}.{best[2]}') ")" # Use the higher of package.json version and the latest tag as baseline BASELINE="$(python3 -c " a = '$PKG_VERSION'.split('.') b = '$LATEST_TAG'.split('.') ai = tuple(int(x) for x in a) bi = tuple(int(x) for x in b) print('$PKG_VERSION' if ai >= bi else '$LATEST_TAG') ")" IFS=. read -r MAJ MIN PAT <<<"$BASELINE" case "$BUMP" in patch) NEXT="${MAJ}.${MIN}.$((PAT + 1))" ;; minor) NEXT="${MAJ}.$((MIN + 1)).0" ;; major) NEXT="$((MAJ + 1)).0.0" ;; *) echo "Invalid bump '$BUMP'" >&2; exit 1 ;; esac TAG="v${NEXT}" echo "Current package.json version: $PKG_VERSION" echo "Latest git tag (normalized): $LATEST_TAG" echo "Baseline: $BASELINE" echo "Bump: $BUMP" echo "Next version: $NEXT" echo "Next tag: $TAG" echo "version=$NEXT" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" # Sanity: tag must not already exist if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists locally" >&2 exit 1 fi if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then echo "Tag $TAG already exists on origin" >&2 exit 1 fi - name: Bump version + generate CHANGELOG (in working tree) if: ${{ !inputs.dry_run }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # Bump only the main package version. optionalDependencies stay # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh # rewrites them to the real version in-flight before publishing, # once the matching @kdlbs/runtime-* tarballs exist on npm. (cd apps/cli && npm version --no-git-tag-version "$NEXT") pnpm -C apps install --no-frozen-lockfile git-cliff --tag "$TAG" -o CHANGELOG.md - name: Create release PR + squash-merge + tag if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} run: | set -euo pipefail # main is protected by a ruleset that requires PRs, so we go through # a release branch + auto-merged PR rather than pushing directly. BRANCH="release/$TAG" MSG="release: $NEXT" git checkout -b "$BRANCH" git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml git commit -m "$MSG" # If a previous run pushed this branch but failed before the merge # landed, the remote branch still exists. Delete it so the push # below is a clean -u origin "$BRANCH" rather than a non-fast-forward # rejection. Closing the corresponding stale PR (if any) happens # automatically when GitHub sees the head branch deleted. git push origin --delete "$BRANCH" 2>/dev/null || true git push -u origin "$BRANCH" gh pr create \ --title "$MSG" \ --body "Automated release: \`$NEXT\`" \ --head "$BRANCH" \ --base main # Immediate squash-merge. This relies on main's ruleset requiring # only "PR required" with no status checks. If status checks are # added to the ruleset later, this needs to switch to `--auto` + # polling for merge completion before the tag steps below. gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body "" # Pull the squashed commit into local main, then tag from it. git fetch origin main git checkout main git reset --hard origin/main git tag -a "$TAG" -m "release: $NEXT" # Tag push: retry once for transient network blips. If both attempts # fail, main already has the bumped commit but no tag exists. # Recover by manually pushing the tag from any clone pointing at the # squashed merge commit: git push origin "$TAG" git push origin "$TAG" || { echo "Tag push failed; retrying in 5s..." >&2 sleep 5 git push origin "$TAG" || { echo "ERROR: Tag push failed twice after PR merge succeeded." >&2 echo " main is at $NEXT but the tag was not created on origin." >&2 echo " Recover manually: git push origin $TAG" >&2 exit 1 } } - name: Dry-run summary if: ${{ inputs.dry_run }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # In dry-run we still bump + run git-cliff locally so the maintainer # can verify the computed version + changelog look right, but nothing # is committed or pushed. (cd apps/cli && npm version --no-git-tag-version "$NEXT") pnpm -C apps install --no-frozen-lockfile git-cliff --tag "$TAG" -o CHANGELOG.md echo echo "DRY RUN: Would have created + merged release PR and tagged $TAG." echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." build-web: name: Build web standalone needs: prepare if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - name: Build web (standalone) env: KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: pnpm -C apps --filter @kandev/web build - name: Package web bundle run: bash scripts/release/package-web.sh - uses: actions/upload-artifact@v5 with: name: web-bundle path: dist/web include-hidden-files: true build-cli: name: Build & bundle CLI needs: prepare if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - name: Build and bundle CLI run: bash scripts/release/package-cli.sh - uses: actions/upload-artifact@v5 with: name: cli-bundle path: dist/kandev/cli build-bundles: name: Build platform bundle (${{ matrix.platform }}) needs: [prepare, build-web, build-cli] if: ${{ !inputs.dry_run }} strategy: matrix: include: - os: ubuntu-latest platform: linux-x64 goos: linux goarch: amd64 - os: ubuntu-24.04-arm platform: linux-arm64 goos: linux goarch: arm64 - os: macos-14 platform: macos-x64 goos: darwin goarch: amd64 - os: macos-14 platform: macos-arm64 goos: darwin goarch: arm64 - os: ubuntu-latest platform: windows-x64 goos: windows goarch: amd64 cc: x86_64-w64-mingw32-gcc runs-on: ${{ matrix.os }} env: CGO_ENABLED: 1 GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod cache-dependency-path: apps/backend/go.sum - uses: actions/download-artifact@v5 with: name: web-bundle path: dist/web - uses: actions/download-artifact@v5 with: name: cli-bundle path: dist/kandev/cli - name: Install cross-compilation tools if: matrix.cc run: | sudo apt-get update sudo apt-get install -y gcc-mingw-w64-x86-64 # The `fts5` build tag is REQUIRED on the kandev binary. The office # task search migrations create a `tasks_fts` virtual table using # `USING fts5(...)`; without the tag, mattn/go-sqlite3 lacks the # module and any subsequent boot against a DB that already has the # virtual table fails with "no such module: fts5" during schema init. # See issue #1006 for the regression this prevents. # # Version ldflags mirror apps/backend/Makefile so `kandev --version` # reports the released tag instead of the build-time default "dev". - name: Build backend binaries env: CC: ${{ matrix.cc || '' }} KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: | cd apps/backend mkdir -p bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} COMMIT=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS="-s -w -X main.Version=${KANDEV_VERSION} -X main.Commit=${COMMIT} -X main.BuildTime=${BUILD_TIME}" # Export to subsequent steps so the cross-compiled agentctl helper # below gets the same version metadata (matches Makefile parity). echo "LDFLAGS=${LDFLAGS}" >> "${GITHUB_ENV}" go build -tags fts5 -ldflags "${LDFLAGS}" -o bin/kandev${EXT} ./cmd/kandev go build -ldflags "${LDFLAGS}" -o bin/agentctl${EXT} ./cmd/agentctl # Cross-compile a linux/amd64 agentctl regardless of the host platform. # Remote executors (local Docker, remote Docker, Sprites) bind-mount this # binary into the container — without it, Docker sessions fail with # "agentctl linux binary not found" on Mac/Windows installs and even on # Linux installs (the resolver looks for the -linux-amd64 suffix). # CGO is off so this works from any host without extra toolchain setup. - name: Build linux/amd64 agentctl helper run: | cd apps/backend GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-amd64 ./cmd/agentctl - name: Package bundle run: | rm -rf dist/kandev/bin dist/kandev/web mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ # cli/ was already placed by the build-cli job (downloaded above). # actions/upload-artifact + download-artifact zip-roundtrip strips the # executable bit, so re-apply it. The Homebrew formula's wrapper at # bin/kandev does `exec libexec/cli/bin/cli.js`, which fails with # "Permission denied" if this isn't +x. chmod +x dist/kandev/cli/bin/cli.js cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" - uses: actions/upload-artifact@v5 with: name: bundle-${{ matrix.platform }} path: | dist/kandev-${{ matrix.platform }}.tar.gz dist/kandev-${{ matrix.platform }}.tar.gz.sha256 # --------------------------------------------------------------------------- # Docker base image — split per arch onto native runners, then stitched into # a multi-arch manifest list. Each arch consumes the matching linux bundle # produced by build-bundles, so no Go/Next.js/pnpm work happens inside the # Docker build (the Dockerfile is reduced to apt-get + COPY). # --------------------------------------------------------------------------- docker-amd64: name: Build & push container image (amd64) needs: [prepare, build-bundles] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: actions/download-artifact@v4 with: name: bundle-linux-x64 path: dist/bundle - name: Prepare Docker build context run: | set -euo pipefail mkdir -p ctx/bundle # --strip-components=1 drops the top-level `kandev/` dir inside the # tarball so ctx/bundle/ ends up with bin/, web/, cli/ directly. tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle cp docker-entrypoint.sh ctx/ - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # OCI labels are produced once here per arch. The manifest job combines # the per-arch image manifests into a multi-arch list via `imagetools # create`, which cannot retroactively add labels to image configs, so # the labels MUST be stamped at build time on each arch. GHCR's package # page link + provenance tooling rely on these annotations. - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (amd64 staging tag) id: build uses: docker/build-push-action@v6 with: context: ./ctx file: ./Dockerfile push: true platforms: linux/amd64 tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} # provenance/sbom would attach an extra attestation manifest, which # turns `imagetools inspect --raw` into an OCI index and breaks the # size gate's `.layers[].size` query. The final multi-arch tags # don't preserve attestations through `imagetools create` anyway, # so disabling here doesn't change user-facing image content. provenance: false sbom: false cache-from: type=gha,scope=base-amd64 cache-to: type=gha,mode=max,scope=base-amd64 docker-arm64: name: Build & push container image (arm64) needs: [prepare, build-bundles] if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04-arm permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64 steps: - uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.tag }} - uses: actions/download-artifact@v4 with: name: bundle-linux-arm64 path: dist/bundle - name: Prepare Docker build context run: | set -euo pipefail mkdir -p ctx/bundle tar -xzf dist/bundle/kandev-linux-arm64.tar.gz --strip-components=1 -C ctx/bundle cp docker-entrypoint.sh ctx/ - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (arm64 staging tag) id: build uses: docker/build-push-action@v6 with: context: ./ctx file: ./Dockerfile push: true platforms: linux/arm64 tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false cache-from: type=gha,scope=base-arm64 cache-to: type=gha,mode=max,scope=base-arm64 docker-manifest: name: Assemble multi-arch base manifest needs: [prepare, docker-amd64, docker-arm64] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev tags: | type=raw,value=${{ needs.prepare.outputs.version }} type=raw,value=${{ needs.prepare.outputs.tag }} type=sha type=raw,value=latest - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # `imagetools create` combines two single-arch images into a multi-arch # manifest list under each of the user-facing tags. No layers are # rebuilt or re-uploaded; this is a registry-side metadata operation. # The per-arch source images are referenced by digest (not by the # mutable staging tag) so this is pinned to the exact bytes the # per-arch jobs just pushed. - name: Combine arch images into multi-arch tags env: AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-amd64.outputs.digest }} ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-arm64.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail tag_args=() while IFS= read -r tag; do [ -z "$tag" ] && continue tag_args+=(--tag "$tag") done <<<"$TAGS" docker buildx imagetools create "${tag_args[@]}" "$AMD64_REF" "$ARM64_REF" # --------------------------------------------------------------------------- # Universal image — same per-arch native split, on top of the base image # we just promoted. The size gate inspects the amd64 staging tag and aborts # before the user-facing :universal tags are moved. # --------------------------------------------------------------------------- docker-universal-amd64: name: Build & push universal image (amd64) needs: [prepare, docker-manifest] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal amd64 staging tag) id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile.universal push: true platforms: linux/amd64 build-args: | BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }} tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} # provenance/sbom off so `imagetools inspect --raw` on the staging # tag returns the plain image manifest, not an OCI index — required # by the size gate in docker-universal-manifest. provenance: false sbom: false cache-from: type=gha,scope=universal-amd64 cache-to: type=gha,mode=max,scope=universal-amd64 docker-universal-arm64: name: Build & push universal image (arm64) needs: [prepare, docker-manifest] if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04-arm permissions: contents: read packages: write outputs: digest: ${{ steps.build.outputs.digest }} env: STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64 steps: - uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.tag }} - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata (labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/kdlbs/kandev - name: Build and push (universal arm64 staging tag) id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile.universal push: true platforms: linux/arm64 build-args: | BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }} tags: ${{ env.STAGING_IMAGE }} labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false cache-from: type=gha,scope=universal-arm64 cache-to: type=gha,mode=max,scope=universal-arm64 docker-universal-manifest: name: Gate size + promote universal manifest # docker-universal-manifest is included in publish-release's `needs` so # its size gate (and any per-arch build failure) blocks the release. needs: [prepare, docker-universal-amd64, docker-universal-arm64] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: read packages: write env: AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-amd64.outputs.digest }} ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-arm64.outputs.digest }} steps: - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Universal size gate. Inspects the amd64 staging image by digest - the # image is in GHCR but under a per-run tag no user pulls. Failing the # gate aborts before promotion, so the user-facing :universal / # :vX.Y.Z-universal tags never move to an oversized image. Bump # MAX_COMPRESSED_BYTES deliberately when adding scope, with a # one-sentence justification in the commit msg. amd64 is the bigger of # the two arches in practice, so gating on it gives us a single-number # signal. Per-arch staging builds set provenance: false, so the digest # points directly at the image manifest (not an OCI index). - name: Enforce size budget env: MAX_COMPRESSED_BYTES: "2684354560" # 2.5 GiB run: | set -euo pipefail docker buildx imagetools inspect "$AMD64_REF" --raw > /tmp/manifest.json TOTAL="$(jq '[.layers[].size] | add' /tmp/manifest.json)" echo "Universal image (amd64) compressed size: $TOTAL bytes (max: $MAX_COMPRESSED_BYTES)" if [ "$TOTAL" -gt "$MAX_COMPRESSED_BYTES" ]; then echo "::error::Universal image exceeds compressed-size budget. Staging tags will not be promoted. See docs/images.md inclusion policy." >&2 exit 1 fi # Promote: combine both arches into a single multi-arch manifest list # under the user-facing tags. `imagetools create` is a metadata-only # registry call - no layers are rebuilt or re-uploaded. Per-arch source # images referenced by digest, not tag, so this is pinned to the bytes # the previous jobs pushed. - name: Promote staging to final tags run: | set -euo pipefail docker buildx imagetools create \ --tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.version }}-universal" \ --tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }}-universal" \ --tag "ghcr.io/kdlbs/kandev:universal" \ "$AMD64_REF" "$ARM64_REF" publish-release: name: Publish GitHub release needs: [prepare, build-bundles, docker-universal-manifest] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: write # softprops/action-gh-release creates the release steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Generate release notes uses: orhun/git-cliff-action@v4 with: config: cliff.toml args: --latest --strip header env: OUTPUT: RELEASE_NOTES.md GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/download-artifact@v5 with: pattern: bundle-* path: dist/release-assets merge-multiple: true - name: Verify release assets run: | set -euo pipefail required_platforms=(linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64) for platform in "${required_platforms[@]}"; do archive="dist/release-assets/kandev-${platform}.tar.gz" checksum="${archive}.sha256" if [ ! -f "$archive" ]; then echo "Missing required archive: $archive" >&2 exit 1 fi if [ ! -f "$checksum" ]; then echo "Missing required checksum: $checksum" >&2 exit 1 fi done ls -lh dist/release-assets - name: Publish release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }} body_path: RELEASE_NOTES.md files: | dist/release-assets/kandev-*.tar.gz dist/release-assets/kandev-*.tar.gz.sha256 publish-npm: name: Publish npm packages needs: [prepare, publish-release] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: id-token: write # Required for OIDC trusted publishing contents: read steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v5 with: version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" registry-url: "https://registry.npmjs.org" cache: pnpm cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - name: Publish npm packages env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | bash scripts/release/publish-npm.sh \ "${{ needs.prepare.outputs.version }}" \ "${{ needs.prepare.outputs.tag }}" update-homebrew-tap: name: Update Homebrew tap needs: [prepare, publish-release] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.tag }} - name: Update Homebrew tap formula env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} run: | bash scripts/release/update-homebrew-tap.sh \ "${{ needs.prepare.outputs.version }}" \ "${{ needs.prepare.outputs.tag }}"