From ac08c1df5c7400be652115303602cef2e69b7527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 16 Aug 2026 21:12:19 +0000 Subject: [PATCH] feat: automate Scoop stable releases (#2517) --- .agents/skills/release/SKILL.md | 14 +- .../scripts/release-workflow-contract_test.py | 32 ++- .github/workflows/lint-action-pinning.yml | 2 +- .github/workflows/release.yml | 44 +++ AGENTS.md | 2 +- Makefile | 2 +- apps/cli/README_internal.md | 16 +- ...elease-backfill-and-desktop-diagnostics.md | 8 +- .../2026-07-31-npm-nightly-release-channel.md | 15 +- docs/plans/scoop-release-automation/plan.md | 166 +++++++++++ .../task-01-scoop-publisher.md | 76 +++++ .../task-02-release-workflow.md | 72 +++++ .../task-03-release-contract-docs.md | 79 ++++++ docs/public/release-process.md | 24 +- docs/specs/INDEX.md | 1 + docs/specs/npm-nightly-channel/spec.md | 4 +- docs/specs/scoop-release-automation/spec.md | 90 ++++++ scripts/release/update-scoop-bucket.sh | 175 ++++++++++++ scripts/release/update-scoop-bucket.test.mjs | 259 ++++++++++++++++++ 19 files changed, 1043 insertions(+), 38 deletions(-) create mode 100644 docs/plans/scoop-release-automation/plan.md create mode 100644 docs/plans/scoop-release-automation/task-01-scoop-publisher.md create mode 100644 docs/plans/scoop-release-automation/task-02-release-workflow.md create mode 100644 docs/plans/scoop-release-automation/task-03-release-contract-docs.md create mode 100644 docs/specs/scoop-release-automation/spec.md create mode 100755 scripts/release/update-scoop-bucket.sh create mode 100644 scripts/release/update-scoop-bucket.test.mjs diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 5a97a0caaa..3487d9fe57 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -16,14 +16,15 @@ artifact set. - npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package) - Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`) - Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"` +- Scoop bucket: `kdlbs/scoop-kandev` `bucket/kandev.json` version, URL, and hash - GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256` -**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other. +**npm, Homebrew, and Scoop are sibling channels**, not chained. All three consume the same GitHub release artifacts; none depends on another package-manager channel. For npm Nightly, Stable `X.Y.Z` plus a full `main` SHA produces `X.Y.(Z+1)-nightly.sha`. `kandev` and all five runtime packages publish at that exact version under npm's `nightly` dist-tag. Nightly never moves `latest` and creates no Git -tag, GitHub Release, Homebrew formula, Desktop feed/build, or container tag. +tag, GitHub Release, Homebrew formula, Scoop bucket update, Desktop feed/build, or container tag. ## Release flow @@ -35,15 +36,16 @@ Stable runs entirely in CI via `.github/workflows/release.yml`, triggered by a m 4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes. 5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs. 6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key. +7. `update-scoop-bucket` pushes updated `bucket/kandev.json` to `kdlbs/scoop-kandev` via its SSH deploy key. **Workflow-control invariant:** When a channel intentionally skips a job, every downstream job reachable through that dependency chain must use a status function such as `!cancelled()` plus explicit `needs..result == 'success'` checks. For a partial Stable release, preserve the existing signed tag and rerun with `backfill_tag`; never run a normal bump against an existing tag. Declare Stable -complete only after `publish-release`, `publish-npm`, and `update-homebrew-tap` -each succeed and their artifacts are verified—an aggregate green run can hide -skipped publication jobs. +complete only after `publish-release`, `publish-npm`, `update-homebrew-tap`, and +`update-scoop-bucket` each succeed and their artifacts are verified—an aggregate +green run can hide skipped publication jobs. Stable has no local release driver; the entire Stable flow runs in GHA. The Nightly metadata and publication revalidation state machine lives in `scripts/release/nightly-release.sh`, which GHA @@ -84,7 +86,7 @@ whose fingerprint matches that variable; never commit private key material. `backfill_tag` repairs publication for an already-signed existing tag only and does not bypass the normal-release signing checks. -Desktop signing is automatic. Complete macOS/Windows signing and notarization secrets produce signed artifacts; missing or incomplete signing inputs produce unsigned desktop artifacts and the GitHub release notes get an unsigned-artifact warning. `desktop_validation_only=true` builds artifacts from the current workflow ref for maintainer inspection and skips the release PR, tag, GitHub release, npm publish, Homebrew update, and public container tags. +Desktop signing is automatic. Complete macOS/Windows signing and notarization secrets produce signed artifacts; missing or incomplete signing inputs produce unsigned desktop artifacts and the GitHub release notes get an unsigned-artifact warning. `desktop_validation_only=true` builds artifacts from the current workflow ref for maintainer inspection and skips the release PR, tag, GitHub release, npm publish, Homebrew update, Scoop update, and public container tags. ## Runtime resolution diff --git a/.github/scripts/release-workflow-contract_test.py b/.github/scripts/release-workflow-contract_test.py index 0e7f13b4c6..196885ea26 100644 --- a/.github/scripts/release-workflow-contract_test.py +++ b/.github/scripts/release-workflow-contract_test.py @@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml" DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh" PUBLISH_NPM_PATH = REPO_ROOT / "scripts" / "release" / "publish-npm.sh" +UPDATE_SCOOP_BUCKET_PATH = REPO_ROOT / "scripts" / "release" / "update-scoop-bucket.sh" NPM_PACKAGES_PATH = REPO_ROOT / "scripts" / "release" / "npm-packages.sh" PUBLIC_KEY_PATH = REPO_ROOT / ".github" / "release-signing-key.asc" RELEASE_PROCESS_PATH = REPO_ROOT / "docs" / "public" / "release-process.md" @@ -19,6 +20,7 @@ LINT_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning. WORKFLOW = WORKFLOW_PATH.read_text() DIAGNOSTICS = DIAGNOSTICS_PATH.read_text() PUBLISH_NPM = PUBLISH_NPM_PATH.read_text() +UPDATE_SCOOP_BUCKET = UPDATE_SCOOP_BUCKET_PATH.read_text() NPM_PACKAGES = NPM_PACKAGES_PATH.read_text() RELEASE_PROCESS = RELEASE_PROCESS_PATH.read_text() LINT_WORKFLOW = LINT_WORKFLOW_PATH.read_text() @@ -152,6 +154,7 @@ class ReleaseWorkflowContractTest(unittest.TestCase): "publish-release", "publish-npm", "update-homebrew-tap", + "update-scoop-bucket", ): block = job_block(name) self.assertIn("github.event_name == 'workflow_dispatch'", block) @@ -196,6 +199,7 @@ class ReleaseWorkflowContractTest(unittest.TestCase): ), "publish-npm": ("prepare", "publish-release"), "update-homebrew-tap": ("prepare", "publish-release"), + "update-scoop-bucket": ("prepare", "publish-release"), } for name, dependencies in direct_dependencies.items(): @@ -255,6 +259,30 @@ class ReleaseWorkflowContractTest(unittest.TestCase): self.assertIn('CLI_PACKAGE_BACKUP="$WORK_DIR/cli-package.json"', PUBLISH_NPM) self.assertIn('cp "$CLI_PACKAGE_BACKUP" "$CLI_PACKAGE_JSON"', PUBLISH_NPM) + def test_scoop_publication_uses_current_control_revision_and_deploy_key(self) -> None: + scoop = job_block("update-scoop-bucket") + self.assertIn("needs: [prepare, publish-release]", scoop) + self.assertIn("ref: ${{ github.workflow_sha }}", scoop) + self.assertIn("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", scoop) + self.assertIn( + "SCOOP_BUCKET_DEPLOY_KEY: ${{ secrets.SCOOP_BUCKET_DEPLOY_KEY }}", + scoop, + ) + self.assertIn("bash scripts/release/update-scoop-bucket.sh", scoop) + self.assertIn('"${{ needs.prepare.outputs.version }}"', scoop) + self.assertIn('"${{ needs.prepare.outputs.tag }}"', scoop) + self.assertIn("!inputs.dry_run", scoop) + self.assertIn("!inputs.desktop_validation_only", scoop) + self.assertNotIn("inputs.backfill_tag == ''", scoop) + + preflight = step_block("Require Scoop bucket deploy key") + self.assertIn('if [ -z "$SCOOP_BUCKET_DEPLOY_KEY" ]', preflight) + self.assertIn("SCOOP_BUCKET_DEPLOY_KEY is required", preflight) + + self.assertIn("SCOOP_BUCKET_DEPLOY_KEY", UPDATE_SCOOP_BUCKET) + self.assertIn("git push origin HEAD:main", UPDATE_SCOOP_BUCKET) + self.assertIn('CHECKSUM_NAME="${ARCHIVE_NAME}.sha256"', UPDATE_SCOOP_BUCKET) + def test_publish_npm_rejects_version_dist_tag_mismatches(self) -> None: cases = ( ( @@ -492,7 +520,8 @@ class ReleaseWorkflowContractTest(unittest.TestCase): "node --test scripts/release/nightly-version.test.mjs " "scripts/release/nightly-release.test.mjs " "scripts/release/npm-view-version.test.mjs " - "scripts/release/publish-npm.test.mjs", + "scripts/release/publish-npm.test.mjs " + "scripts/release/update-scoop-bucket.test.mjs", LINT_WORKFLOW, ) @@ -566,6 +595,7 @@ class ReleaseWorkflowContractTest(unittest.TestCase): "publish-release", "publish-npm", "update-homebrew-tap", + "update-scoop-bucket", ): block = job_block(name) self.assertRegex( diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index bbfba90814..a421b9bde2 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -71,7 +71,7 @@ jobs: run: bash scripts/release/retry-ghcr-command.test.sh - name: Test npm release helpers - run: node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs + run: node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs scripts/release/update-scoop-bucket.test.mjs - name: Verify action refs run: python3 .github/scripts/lint-action-pinning.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d3f2f5d7b..805ea731f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ name: release # 9. publish-release — create GH release with runtime + desktop assets and release 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 +# 12. update-scoop-bucket — push updated bucket/kandev.json to kdlbs/scoop-kandev # # npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. # Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow @@ -26,6 +27,7 @@ name: release # # Required repository secret for public releases: # HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key +# SCOOP_BUCKET_DEPLOY_KEY — SSH private key for kdlbs/scoop-kandev deploy key # Required configuration in the `release` environment: # RELEASE_GPG_PRIVATE_KEY — secret containing the ASCII-armored release tag signing key # RELEASE_GPG_FINGERPRINT — variable containing the key's full fingerprint @@ -2157,3 +2159,45 @@ jobs: bash scripts/release/update-homebrew-tap.sh \ "${{ needs.prepare.outputs.version }}" \ "${{ needs.prepare.outputs.tag }}" + + update-scoop-bucket: + name: Update Scoop bucket + needs: [prepare, publish-release] + if: >- + ${{ !cancelled() && + github.event_name == 'workflow_dispatch' && + inputs.channel == 'stable' && + !inputs.dry_run && + !inputs.desktop_validation_only && + needs.prepare.result == 'success' && + needs.publish-release.result == 'success' + }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + # Backfill application artifacts come from the selected tag, but the + # publication helper must come from the current workflow revision. + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Require Scoop bucket deploy key + env: + SCOOP_BUCKET_DEPLOY_KEY: ${{ secrets.SCOOP_BUCKET_DEPLOY_KEY }} + run: | + set -euo pipefail + if [ -z "$SCOOP_BUCKET_DEPLOY_KEY" ]; then + echo "::error::SCOOP_BUCKET_DEPLOY_KEY is required to publish the Scoop bucket." >&2 + exit 1 + fi + + - name: Update Scoop bucket + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SCOOP_BUCKET_DEPLOY_KEY: ${{ secrets.SCOOP_BUCKET_DEPLOY_KEY }} + run: | + bash scripts/release/update-scoop-bucket.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" diff --git a/AGENTS.md b/AGENTS.md index 7e048ce9b8..c35372bd9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ The commitlint hook caps the header at **100 characters** (`type(scope): descrip ### Release & Versioning -Stable Kandev releases use one **SemVer** `X.Y.Z` across npm, Homebrew, GitHub Releases, Desktop, and containers. Scheduled npm-only Nightlies use `X.Y.(Z+1)-nightly.sha<12-hex>` without moving any Stable channel. Both flows run in `.github/workflows/release.yml`. Full details are in the `/release` skill — load it when cutting a release, changing version channels, or debugging release artifacts. +Stable Kandev releases use one **SemVer** `X.Y.Z` across npm, Homebrew, Scoop, GitHub Releases, Desktop, and containers. Scheduled npm-only Nightlies use `X.Y.(Z+1)-nightly.sha<12-hex>` without moving any Stable channel. Both flows run in `.github/workflows/release.yml`. A Stable release is complete only after the GitHub Release, npm, Homebrew, and Scoop publications succeed and are verified. Full details are in the `/release` skill — load it when cutting a release, changing version channels, or debugging release artifacts. ### Code Quality diff --git a/Makefile b/Makefile index 33419654f6..db418ef558 100644 --- a/Makefile +++ b/Makefile @@ -550,7 +550,7 @@ test-scripts: @bash scripts/release/retry-ghcr-command.test.sh @node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs @python3 .github/scripts/release-workflow-contract_test.py - @node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs + @node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs scripts/release/update-scoop-bucket.test.mjs @node --test scripts/validate-public-docs.test.mjs .PHONY: test-e2e diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index e4dccc78fb..8f643c7508 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -7,7 +7,7 @@ flowchart TD A["kandev"] --> B["CLI parses arguments"] B --> C{"Install channel?"} - C -->|Homebrew / manual| H["bin/kandev native launcher"] + C -->|Homebrew / Scoop / manual| H["bin/kandev native launcher"] C -->|npm / npx| D["bin/cli.js native shim"] D --> D1{"KANDEV_BUNDLE_DIR set?"} @@ -28,19 +28,20 @@ flowchart TD This package provides the `kandev` CLI launcher. The runtime bundle (Go backend, agentctl, and static Vite web assets) is **installed by the package manager** — there is no first-run download. -Two install channels share the same release artifacts: +Three native bundle consumers share the same release artifacts: - **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. - **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and installs `bin/kandev` as the public command. +- **Scoop**: `kdlbs/scoop-kandev` manifest downloads the Windows x64 GitHub release tarball and exposes `bin\kandev.exe` as the public command. -Both channels resolve to a native runtime bundle. Homebrew/manual bundles contain `bin/kandev` and `bin/agentctl`; npm runtime packages also include the platform-matched binaries. The public command remains `kandev`; the hidden backend mode is `kandev __backend`. +All three consumers resolve to a native runtime bundle. Homebrew/Scoop/manual bundles contain the platform launcher and `bin/agentctl`; npm runtime packages also include the platform-matched binaries. The public command remains `kandev`; the hidden backend mode is `kandev __backend`. ## Artifact shapes The GitHub release bundle and the npm runtime package are **different shapes** because they serve different consumers: ``` -# GitHub release bundle (used by Homebrew + manual installs) +# GitHub release bundle (used by Homebrew + Scoop + manual installs) kandev/ └── bin/{kandev,agentctl,agentctl-linux-amd64,agentctl-darwin-arm64,agentctl-darwin-amd64} @@ -81,6 +82,7 @@ The CLI no longer self-updates. Updates flow through the install channel: ```bash brew upgrade kandev # Homebrew +scoop update kandev # Scoop npm install -g kandev@latest # npm global npx kandev@latest # always pulls latest ``` @@ -118,13 +120,13 @@ Releases run entirely in GitHub Actions. From the GHA UI: 4. Optionally tick `dry_run` to validate without publishing. 5. Click **Run workflow**. -The workflow does everything: version bump, CHANGELOG, PR, merge, tag, runtime bundles, desktop artifacts, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml). +The workflow does everything: version bump, CHANGELOG, PR, merge, tag, runtime bundles, desktop artifacts, npm publish, Homebrew tap update, and Scoop bucket update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml). -Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, Homebrew formula, GitHub runtime tarballs, and desktop artifacts are all bumped to the same `X.Y.Z`. +Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, Homebrew formula, Scoop manifest, GitHub runtime tarballs, and desktop artifacts are all bumped to the same `X.Y.Z`. Versioning: -- One SemVer `X.Y.Z` for everything (npm, GitHub tag, Homebrew formula) +- One SemVer `X.Y.Z` for everything (npm, GitHub tag, Homebrew formula, Scoop manifest) - Git tag format: `vX.Y.Z` - Legacy `vM.m` tags normalized to `M.m.0` during migration; new releases always use `vX.Y.Z` diff --git a/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md b/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md index 551b8ffda0..86e829f5f0 100644 --- a/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md +++ b/docs/decisions/0029-release-backfill-and-desktop-diagnostics.md @@ -2,7 +2,7 @@ **Status:** accepted **Date:** 2026-07-01 -**Amended:** 2026-07-16 +**Amended:** 2026-08-11 **Area:** infra, workflow ## Context @@ -13,7 +13,7 @@ The first desktop macOS failure mode we observed happened after Rust compilation ## Decision -Add a maintainer-only release backfill mode to `.github/workflows/release.yml`. The `backfill_tag` input accepts the current highest existing `vX.Y.Z` release tag, verifies that the tag exists, and verifies that all release-critical manifests at that tag match the version. It then uses the existing tag as the build and publish ref. In this mode the workflow skips version bumping, changelog generation, release PR creation, and tag creation, while still running the build, GitHub release, npm publish, and Homebrew update jobs. +Add a maintainer-only release backfill mode to `.github/workflows/release.yml`. The `backfill_tag` input accepts the current highest existing `vX.Y.Z` release tag, verifies that the tag exists, and verifies that all release-critical manifests at that tag match the version. It then uses the existing tag as the build and publish ref. In this mode the workflow skips version bumping, changelog generation, release PR creation, and tag creation, while still running the build, GitHub release, npm publish, Homebrew update, and Scoop update jobs. Keep artifact and application inputs pinned to that immutable release tag, but source release control-plane helpers from `github.workflow_sha`. This allows a corrected workflow revision to recover a broken tag without changing the tag or silently rebuilding application source from main. @@ -23,11 +23,11 @@ Keep desktop installer builds on native OS runners. Linux container images remai ## Consequences -Release recovery becomes explicit and repeatable for tag-only or partially published current releases. Publishing scripts can keep their existing idempotent behavior, so rerunning a backfill is safe when some assets or packages already exist. Older tags are rejected because this workflow still updates mutable channels such as Docker tags, npm dist-tags, and the Homebrew formula. +Release recovery becomes explicit and repeatable for tag-only or partially published current releases. Publishing scripts can keep their existing idempotent behavior, so rerunning a backfill is safe when some assets or packages already exist. Older tags are rejected because this workflow still updates mutable channels such as Docker tags, npm dist-tags, the Homebrew formula, and the Scoop bucket. The release workflow has more branching logic, and maintainers must choose between normal release, desktop validation, dry run, and tag backfill modes intentionally. The workflow contract is covered by a small CI test so later YAML changes do not silently remove the recovery path or macOS diagnostics. -Backfills therefore combine immutable tagged application inputs with current workflow control logic. Control-plane helper changes must remain compatible with the tagged inputs they validate. +Backfills therefore combine immutable tagged application inputs with current workflow control logic. Control-plane helper changes must remain compatible with the tagged inputs they validate. The Scoop publisher checks out the current workflow revision while reconciling the selected tag's Windows release asset into the bucket. macOS package failures should now leave actionable artifacts instead of only the final Tauri command line. The retry may add time to a failing macOS job, but the step-level timeout caps total time. diff --git a/docs/decisions/2026-07-31-npm-nightly-release-channel.md b/docs/decisions/2026-07-31-npm-nightly-release-channel.md index e4d5ad5321..be4671d0b9 100644 --- a/docs/decisions/2026-07-31-npm-nightly-release-channel.md +++ b/docs/decisions/2026-07-31-npm-nightly-release-channel.md @@ -1,14 +1,15 @@ # ADR-2026-07-31-npm-nightly-release-channel: Publish deterministic npm-only nightlies -**Status:** accepted (amended 2026-08-03) +**Status:** accepted (amended 2026-08-11) **Date:** 2026-07-31 **Area:** workflow, backend, frontend, cli ## Context -Kandev's stable release workflow intentionally gives npm, Homebrew, GitHub Releases, Desktop, and -containers one shared `X.Y.Z`. Users also need prerelease access to current `main`, but Homebrew -and Desktop require separate mutable-feed and signing designs. npm already has six coordinated +Kandev's stable release workflow intentionally gives npm, Homebrew, Scoop, GitHub Releases, Desktop, +and containers one shared `X.Y.Z`. Users also need prerelease access to current `main`, but Homebrew +and Scoop require separate mutable-feed and signing designs, while Desktop has its own update feed +and signing design. npm already has six coordinated packages, trusted publishing bound to `.github/workflows/release.yml`, and native dist-tags. A commit-derived prerelease must be deterministic for retries and valid even when the abbreviated @@ -38,7 +39,7 @@ rejected instead of silently changing the requested operation. Nightlies publish all five runtime packages before the launcher with `npm publish --tag nightly`; stable `latest` tags remain untouched. The publisher and Nightly preflight load one shared package inventory. Nightly jobs do not enter the Git tag, GitHub Release, -Desktop, container, or Homebrew graph. Stable and Nightly workflow runs share one non-cancelling +Desktop, container, Homebrew, or Scoop graph. Stable and Nightly workflow runs share one non-cancelling concurrency group from start through publication. This release-wide lock is required because Stable pushes its Git tag before its npm publish job is ready; locking only the two npm jobs leaves a window where Nightly can derive from the previous npm @@ -51,6 +52,8 @@ when its embedded commit is an ancestor of current `main`. Divergent or newer pa closed. Before publishing, both the Stable baseline and previously observed Nightly tag must still match. +Scoop remains Stable-only: Nightly never changes `kdlbs/scoop-kandev`. + The backend owns an install-wide Stable/Nightly preference. Stable remains the default and resolves GitHub Releases. Nightly resolves npm's `kandev@nightly` target and is selectable only for verified managed npm/npx user services. Update intents always contain an exact version. Nightly-to-nightly @@ -66,7 +69,7 @@ observable. A collision in the accepted 12-hex namespace blocks automatic public maintainer resolution. Publishing six packages can still fail partially, so runtime-first/main-last order and tag-consistency checks are required. -npm accumulates immutable nightly versions. Homebrew and Desktop users do not receive channel +npm accumulates immutable nightly versions. Homebrew, Scoop, and Desktop users do not receive channel parity in this iteration. The release workflow must explicitly gate every stable-only job when handling Nightly events. The shared dispatch form still displays Stable-only inputs for a manual Nightly, so validation and input descriptions must keep their meaning explicit. A manual Stable diff --git a/docs/plans/scoop-release-automation/plan.md b/docs/plans/scoop-release-automation/plan.md new file mode 100644 index 0000000000..b8e86d0775 --- /dev/null +++ b/docs/plans/scoop-release-automation/plan.md @@ -0,0 +1,166 @@ +--- +spec: docs/specs/scoop-release-automation/spec.md +created: 2026-08-11 +status: complete +--- + +# Implementation Plan: Automate Scoop Stable Releases + +## Overview + +Add an idempotent Scoop publisher before its Stable release job. Then extend +the release contract tests and maintainer documentation. The work uses the +published Windows checksum and a repository-scoped SSH deploy key. + +The confirmed root cause has two parts. The Kandev release graph has no Scoop +publication job. The Scoop bucket contains `checkver` and `autoupdate` data, +but that repository has no automation that runs them. + +## Release automation + +### Scoop publisher + +- Add `scripts/release/update-scoop-bucket.sh` with the interface + `update-scoop-bucket.sh `. +- Use `kdlbs/kandev` as the source repository and + `kdlbs/scoop-kandev` as the default bucket repository. +- Download `kandev-windows-x64.tar.gz.sha256` from the exact GitHub Release. +- Accept only one lowercase 64-character SHA-256 value for the expected + archive name. +- Clone the bucket into a temporary directory. Use + `SCOOP_BUCKET_DEPLOY_KEY` for the CI SSH path. +- Use a temporary private-key file with mode `0600`. Pin the published GitHub + SSH host keys and remove all temporary files on exit. +- Update only `version`, `architecture.64bit.url`, and + `architecture.64bit.hash` in `bucket/kandev.json`. Keep all other manifest + fields. +- Commit `kandev ` and push to `main`. Exit successfully without a + commit when the manifest is already correct. +- Support a local authenticated fallback and repository override for the + black-box test. The CI job still requires the deploy-key secret. +- Add `scripts/release/update-scoop-bucket.test.mjs`. Use temporary Git + repositories and a fake `gh` executable. Cover a normal update, a no-op + rerun, and a malformed checksum. +- Add the new test to the root `test-scripts` target. + +### Stable workflow integration + +- Add `update-scoop-bucket` to `.github/workflows/release.yml` as a sibling of + `publish-npm` and `update-homebrew-tap`. +- Depend on `prepare` and `publish-release`. Require both jobs to succeed. +- Use the existing Stable-only condition. Exclude Nightly, dry runs, and + Desktop validation runs. Keep `backfill_tag` eligible. +- Check out `github.workflow_sha` for this job. A backfill tag can predate the + Scoop helper, so the job must use current release control logic. +- Pass the built-in `GITHUB_TOKEN` for release-asset reads. Pass the new + `SCOOP_BUCKET_DEPLOY_KEY` repository secret for the cross-repository push. +- Fail with a clear error before publication when the deploy-key secret is + absent. +- Add the helper and its test to the release-contract CI path filters and test + command in `.github/workflows/lint-action-pinning.yml`. + +## Release contract records + +- Update `docs/public/release-process.md`. Add Scoop to the Stable target list, + credential checklist, publication graph, verification list, and backfill + guidance. Classify this page as a how-to guide. +- Update `.agents/skills/release/SKILL.md`. Add Scoop to version targets, + sibling channels, Stable completion, and Nightly exclusions. +- Update the Release and Versioning section in `AGENTS.md` with the Scoop + target and completion rule. +- Update `apps/cli/README_internal.md`. Describe Scoop as a third native bundle + consumer and include its update command. +- Amend `docs/decisions/0029-release-backfill-and-desktop-diagnostics.md` so + backfill reconciles the Scoop bucket with current workflow control logic. +- Amend `docs/decisions/2026-07-31-npm-nightly-release-channel.md` and + `docs/specs/npm-nightly-channel/spec.md` so Scoop remains Stable-only. +- Change the repair spec and its index entry to `shipped` after all tasks and + checks are complete. + +## Tests + +- **What:** a valid Stable checksum produces the exact Scoop version, URL, and + hash without changing unrelated fields. + **File:** `scripts/release/update-scoop-bucket.test.mjs`. + **How:** run the shell publisher against a temporary bucket and fake GitHub + release download. +- **What:** a second publication for the same version creates no commit. + **File:** `scripts/release/update-scoop-bucket.test.mjs`. + **How:** run the publisher twice and compare the bucket branch commit. +- **What:** an absent or malformed checksum changes no bucket state. + **File:** `scripts/release/update-scoop-bucket.test.mjs`. + **How:** return invalid fixture data and assert a nonzero result with an + unchanged remote commit. +- **What:** Scoop runs only after a successful Stable GitHub Release and stays + eligible for backfill. + **File:** `.github/scripts/release-workflow-contract_test.py`. + **How:** add the job to Stable dependency, Nightly exclusion, and backfill + contract tables. +- **What:** the workflow uses current control logic and a separate deploy key. + **File:** `.github/scripts/release-workflow-contract_test.py`. + **How:** assert `github.workflow_sha`, the secret name, its preflight, and the + exact publisher arguments. +- **What:** release contract CI runs for Scoop helper changes. + **File:** `.github/scripts/release-workflow-contract_test.py`. + **How:** assert the path filters and Node test command in the lint workflow. + +## E2E tests + +No browser E2E test is required. This repair changes CI publication and +maintainer documentation. It does not change the Kandev UI or runtime. + +## Post-merge rollout + +1. Create a dedicated SSH key for `kdlbs/scoop-kandev`. +2. Add the public key to that repository as a write-enabled deploy key. +3. Store the private key as the `SCOOP_BUCKET_DEPLOY_KEY` Actions repository + secret in `kdlbs/kandev`. +4. Reconcile the current Scoop manifest to the latest Stable release. +5. Verify the manifest version and hash. Then verify `scoop update kandev` on + Windows. + +Do not use the Homebrew deploy key for Scoop. Each deploy key has one target +repository. + +## Verification results + +Task 01: + +- `node --test scripts/release/update-scoop-bucket.test.mjs` passed: 2 tests. +- `bash -n scripts/release/update-scoop-bucket.sh` passed. +- The task's `git diff --check` command passed. + +Task 02: + +- `python3 .github/scripts/release-workflow-contract_test.py` passed: 25 tests. +- `python3 .github/scripts/lint-action-pinning_test.py` passed: 9 tests. +- The task's `git diff --check` command passed. + +Task 03: + +- `node --test scripts/validate-public-docs.test.mjs` passed: 58 tests. +- `node scripts/validate-public-docs.mjs` passed: 41 published pages. +- `python3 scripts/lint-harness-files.test.py` passed: 19 tests. +- `python3 .github/scripts/lint-harness-files.py --all` passed: 118 files. +- Focused harness pre-commit lint passed. + +## Implementation waves and parallel candidates + +Wave 1 (sequential): + +- [x] [task-01-scoop-publisher](task-01-scoop-publisher.md) + +Wave 2 (sequential, depends on Wave 1): + +- [x] [task-02-release-workflow](task-02-release-workflow.md) + +Wave 3 (sequential, depends on Wave 2): + +- [x] [task-03-release-contract-docs](task-03-release-contract-docs.md) + +Parallel delegation is not authorized by this plan. The work stays in the +primary session unless the user explicitly authorizes subagents. + +## Open questions + +None. diff --git a/docs/plans/scoop-release-automation/task-01-scoop-publisher.md b/docs/plans/scoop-release-automation/task-01-scoop-publisher.md new file mode 100644 index 0000000000..fc850fe526 --- /dev/null +++ b/docs/plans/scoop-release-automation/task-01-scoop-publisher.md @@ -0,0 +1,76 @@ +--- +id: "01-scoop-publisher" +title: "Add Scoop publisher" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/scoop-release-automation/spec.md" +--- + +# Task 01: Add Scoop publisher + +## Acceptance + +- The publisher reads the exact Windows checksum and updates only the three + version-dependent Scoop manifest fields. +- A repeated publication creates no commit. An absent or malformed checksum + fails before the remote bucket changes. +- The CI SSH path protects and removes the deploy key, pins GitHub host keys, + and pushes only to `kdlbs/scoop-kandev`. + +## Verification + +- `node --test scripts/release/update-scoop-bucket.test.mjs` +- `bash -n scripts/release/update-scoop-bucket.sh` +- `git diff --check -- scripts/release/update-scoop-bucket.sh scripts/release/update-scoop-bucket.test.mjs Makefile` + +## Files likely touched + +- `scripts/release/update-scoop-bucket.sh` +- `scripts/release/update-scoop-bucket.test.mjs` +- `Makefile` + +## Dependencies + +None. + +## Parallelism + +Sequential. The workflow task depends on the final publisher interface. + +## Inputs + +- The `What`, Permissions, Failure modes, and Scenarios sections in the repair + spec. +- `scripts/release/update-homebrew-tap.sh` for the deploy-key, host-key, Git, + and cleanup pattern. +- The current `bucket/kandev.json` contract in `kdlbs/scoop-kandev`. +- `scripts/release/nightly-release.test.mjs` for fake-command and temporary Git + fixture patterns. + +## Output contract + +Report the changed files, Red and Green test results, cleanup evidence, and +security boundaries. Update this task and `plan.md` in the same conversation. + +## Results + +Red: + +- `node --test scripts/release/update-scoop-bucket.test.mjs` failed before the + helper existed, proving the fixture exercised the missing behavior. + +Green: + +- `node --test scripts/release/update-scoop-bucket.test.mjs` passed: 2 tests, + 2 passes. +- `bash -n scripts/release/update-scoop-bucket.sh` passed. +- `git diff --check -- scripts/release/update-scoop-bucket.sh + scripts/release/update-scoop-bucket.test.mjs Makefile` passed. + +The helper uses `SCOOP_BUCKET_DEPLOY_KEY` only for the cross-repository SSH +path. It writes the key and pinned GitHub host keys with mode `0600`, uses an +isolated `GIT_SSH_COMMAND`, and removes the key, known-hosts file, and temporary +clone directory from its `EXIT` cleanup trap. The local fallback uses the +authenticated `gh` CLI and the repository override is covered by the fixture. diff --git a/docs/plans/scoop-release-automation/task-02-release-workflow.md b/docs/plans/scoop-release-automation/task-02-release-workflow.md new file mode 100644 index 0000000000..4d063bfb23 --- /dev/null +++ b/docs/plans/scoop-release-automation/task-02-release-workflow.md @@ -0,0 +1,72 @@ +--- +id: "02-release-workflow" +title: "Wire Scoop release publication" +status: done +wave: 2 +depends_on: ["01-scoop-publisher"] +plan: "plan.md" +spec: "../../specs/scoop-release-automation/spec.md" +--- + +# Task 02: Wire Scoop release publication + +## Acceptance + +- Stable and backfill runs start Scoop publication only after the GitHub + Release succeeds. Nightly, dry run, and Desktop validation do not start it. +- The job uses `github.workflow_sha`, `GITHUB_TOKEN`, and the separate + `SCOOP_BUCKET_DEPLOY_KEY` secret. It fails clearly when the secret is absent. +- Release contract CI detects missing job dependencies, skipped-channel gates, + backfill exclusion, secret wiring, or omitted helper tests. + +## Verification + +- `python3 .github/scripts/release-workflow-contract_test.py` +- `python3 .github/scripts/lint-action-pinning_test.py` +- `git diff --check -- .github/workflows/release.yml .github/workflows/lint-action-pinning.yml .github/scripts/release-workflow-contract_test.py` + +## Files likely touched + +- `.github/workflows/release.yml` +- `.github/workflows/lint-action-pinning.yml` +- `.github/scripts/release-workflow-contract_test.py` + +## Dependencies + +Task 01 must pass before this task starts. + +## Parallelism + +Sequential. The workflow calls the publisher interface from Task 01. + +## Inputs + +- The Stable, backfill, and excluded-mode scenarios in the repair spec. +- The `update-homebrew-tap` job and its explicit dependency gates. +- ADR 0029 for the `github.workflow_sha` control-plane rule. +- The existing release workflow contract tables for Stable and Nightly jobs. + +## Output contract + +Report the changed workflow files, Red and Green contract results, and the +secret boundary. Update this task and `plan.md` in the same conversation. + +## Results + +Red: + +- `python3 .github/scripts/release-workflow-contract_test.py` initially found + the missing Scoop job in five contract assertions. + +Green: + +- `python3 .github/scripts/release-workflow-contract_test.py` passed: 25 tests. +- `python3 .github/scripts/lint-action-pinning_test.py` passed: 9 tests. +- `git diff --check -- .github/workflows/release.yml + .github/workflows/lint-action-pinning.yml + .github/scripts/release-workflow-contract_test.py` passed. + +The new job is a Stable-only sibling of npm and Homebrew. It requires successful +`prepare` and `publish-release` jobs, remains eligible for `backfill_tag`, reads +release assets with `GITHUB_TOKEN`, checks out `github.workflow_sha` for current +control logic, and uses only `SCOOP_BUCKET_DEPLOY_KEY` for the bucket push. diff --git a/docs/plans/scoop-release-automation/task-03-release-contract-docs.md b/docs/plans/scoop-release-automation/task-03-release-contract-docs.md new file mode 100644 index 0000000000..3526aee1ac --- /dev/null +++ b/docs/plans/scoop-release-automation/task-03-release-contract-docs.md @@ -0,0 +1,79 @@ +--- +id: "03-release-contract-docs" +title: "Reconcile release documentation" +status: done +wave: 3 +depends_on: ["02-release-workflow"] +plan: "plan.md" +spec: "../../specs/scoop-release-automation/spec.md" +--- + +# Task 03: Reconcile release documentation + +## Acceptance + +- Public and internal release guidance lists Scoop as a Stable sibling channel + with its own repository-scoped deploy key. +- The backfill decision and Nightly records state that backfill includes Scoop + and Nightly excludes Scoop. +- The repair spec and index are `shipped`. Public docs and harness files pass + their focused validation. + +## Verification + +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `python3 scripts/lint-harness-files.test.py` +- `python3 .github/scripts/lint-harness-files.py --all` +- `pre-commit run harness-lint --files AGENTS.md .agents/skills/release/SKILL.md` +- `git diff --check -- AGENTS.md .agents/skills/release/SKILL.md apps/cli/README_internal.md docs/public/release-process.md docs/decisions/0029-release-backfill-and-desktop-diagnostics.md docs/decisions/2026-07-31-npm-nightly-release-channel.md docs/specs/npm-nightly-channel/spec.md docs/specs/scoop-release-automation/spec.md docs/specs/INDEX.md` + +## Files likely touched + +- `AGENTS.md` +- `.agents/skills/release/SKILL.md` +- `apps/cli/README_internal.md` +- `docs/public/release-process.md` +- `docs/decisions/0029-release-backfill-and-desktop-diagnostics.md` +- `docs/decisions/2026-07-31-npm-nightly-release-channel.md` +- `docs/specs/npm-nightly-channel/spec.md` +- `docs/specs/scoop-release-automation/spec.md` +- `docs/specs/INDEX.md` + +## Dependencies + +Task 02 must pass before this task starts. + +## Parallelism + +Sequential. These records must use the final workflow and secret names. + +## Inputs + +- The complete repair spec and release workflow from Tasks 01 and 02. +- The release skill and the Release and Versioning section in `AGENTS.md`. +- `docs/public/release-process.md`, classified as a how-to guide. +- ADR 0029 and the npm Nightly decision and spec. + +## Output contract + +Report each changed record, the public-docs classification, exact validation +results, and any stale release references found. Update this task and +`plan.md` in the same conversation. + +## Results + +Green: + +- `node --test scripts/validate-public-docs.test.mjs` passed: 58 tests. +- `node scripts/validate-public-docs.mjs` passed: 41 published pages. +- `python3 scripts/lint-harness-files.test.py` passed: 19 tests. +- `python3 .github/scripts/lint-harness-files.py --all` passed: 118 files. +- `pre-commit run harness-lint --files AGENTS.md + .agents/skills/release/SKILL.md` passed. +- The task's `git diff --check` command passed. + +Public documentation remains a focused how-to guide and now covers Scoop in +Stable targets, credentials, publication order, verification, and backfill +recovery. Internal skill, CLI, ADR, Nightly, spec, and index records all state +that Scoop is a Stable sibling channel and uses its own deploy key. diff --git a/docs/public/release-process.md b/docs/public/release-process.md index 516a444892..cda54f4f8d 100644 --- a/docs/public/release-process.md +++ b/docs/public/release-process.md @@ -1,11 +1,15 @@ --- title: "Release Process" -description: "Run and verify Kandev's version, runtime, desktop, container, npm, GitHub, updater, and Homebrew release automation." +description: "Run and verify Kandev's version, runtime, desktop, container, npm, GitHub, updater, Homebrew, and Scoop release automation." --- # Release Process -Kandev uses one semantic version across the Git tag, native runtime bundles, desktop app, npm packages, GitHub release, container images, and Homebrew formula. Publish through the manual **Release** GitHub Actions workflow; do not update channels independently. +This how-to guide is for maintainers who run and verify a Stable release. Its +Stable targets are the Git tag, runtime bundles, desktop app, npm packages, +GitHub Release, container images, Homebrew formula, and Scoop bucket. + +Kandev uses one semantic version across the Git tag, native runtime bundles, desktop app, npm packages, GitHub release, container images, Homebrew formula, and Scoop bucket. Publish through the manual **Release** GitHub Actions workflow; do not update channels independently. ## Quick path @@ -22,7 +26,7 @@ The workflow has four mutually exclusive operating modes: | ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Normal release | `bump=patch`, `minor`, or `major` | Creates and merges a release PR, tags its merge, builds, and publishes | | Dry run | `dry_run=true` | Computes the next version and exercises CLI package/lock plus changelog generation in the runner; no PR, tag, artifact build, or publication | -| Desktop validation | `desktop_validation_only=true` | Builds web, five runtime bundles, and five desktop targets from the selected commit; no PR, tag, GitHub release, GHCR, npm, or Homebrew publication | +| Desktop validation | `desktop_validation_only=true` | Builds web, five runtime bundles, and five desktop targets from the selected commit; no PR, tag, GitHub release, GHCR, npm, Homebrew, or Scoop publication | | Backfill | `backfill_tag=vX.Y.Z` | Rebuilds and repairs channels for the latest existing release tag without creating a version or tag | `dry_run` and desktop validation are not release candidates. Backfill cannot be combined with either and accepts only the latest exact SemVer tag after version manifests are checked for agreement. @@ -33,7 +37,7 @@ The workflow has four mutually exclusive operating modes: 2. Confirm required checks are green on `main` and no release or release PR is active. 3. Confirm merged PR titles/commits use the conventional categories consumed by `cliff.toml`. The workflow generates `CHANGELOG.md` and release notes. 4. Verify platform-sensitive launcher, agentctl, container, and desktop changes on affected targets. -5. Check GitHub/GHCR access, npm trusted publishing, the release-tag GPG key, the Homebrew deploy key, and any configured desktop signing/notarization secrets. +5. Check GitHub/GHCR access, npm trusted publishing, the release-tag GPG key, the Homebrew deploy key, the Scoop deploy key (`SCOOP_BUCKET_DEPLOY_KEY`), and any configured desktop signing/notarization secrets. 6. Update public docs for behavior that is about to ship. Normal releases require a dedicated release-tag signing identity and a GitHub Environment named `release`. Restrict that environment to deployments from `main`, but do not configure required reviewers: any maintainer authorized to dispatch the workflow and access the environment may start a normal release. Store the ASCII-armored private key as the environment secret `RELEASE_GPG_PRIVATE_KEY`, its optional passphrase as the environment secret `RELEASE_GPG_PASSPHRASE`, and the exact full fingerprint as the required environment variable `RELEASE_GPG_FINGERPRINT`. Before changing version files or creating the release PR, the workflow rejects a missing, multi-key, or secret-material public-key attachment and requires its sole primary fingerprint to match `RELEASE_GPG_FINGERPRINT`. After the release PR merges, it revalidates the public key from the merged revision, imports the private key, and requires that key to match both the merged key and `RELEASE_GPG_FINGERPRINT`. Do not define this signing material as repository-wide configuration. @@ -65,9 +69,10 @@ Normal mode performs these stages: 5. **Build containers.** Publish amd64/arm64 base manifests, enforce the universal-image size gate, then publish multi-architecture universal images. 6. **Publish GitHub Release.** Attach runtime archives, checksums, desktop artifacts, notes, and the updater feed when eligible. 7. **Publish npm.** Use OIDC trusted publishing for five `@kdlbs/runtime-*` packages first, then the `kandev` launcher. Existing versions are skipped; the main package is not published after a runtime-package failure. -8. **Update Homebrew.** Push the formula update to `kdlbs/homebrew-kandev` using release checksums and the deploy key. The generated formula records `X.Y.Z` explicitly; platform archive suffixes such as `x64` are not version identifiers. +8. **Update Homebrew.** Push the formula update to `kdlbs/homebrew-kandev` using release checksums and its deploy key. The generated formula records `X.Y.Z` explicitly; platform archive suffixes such as `x64` are not version identifiers. +9. **Update Scoop.** Push `bucket/kandev.json` to `kdlbs/scoop-kandev` using the Windows tarball checksum and its separate deploy key. The manifest records the exact Stable version, release URL, and SHA-256 value. -GHCR images are built before the GitHub Release. npm and Homebrew start only after the GitHub Release and may run in parallel. A late failure can therefore leave some channels complete and others missing. +GHCR images are built before the GitHub Release. npm, Homebrew, and Scoop start only after the GitHub Release and may run in parallel. A late failure can therefore leave some channels complete and others missing. Base image tags include `X.Y.Z`, `vX.Y.Z`, `sha-*`, and `latest`. Universal tags include `X.Y.Z-universal`, `vX.Y.Z-universal`, and the floating `universal`. The weekly universal rebuild updates only floating/dated weekly tags, never a version-specific release tag. @@ -83,14 +88,14 @@ share that immutable version, so an installed build identifies its source commit Nightlies are best-effort daily snapshots. A new one appears only when `main` has changed since the latest Stable release, so some days may have no new Nightly. A Nightly never moves npm's `latest` tag and does not publish a Git tag, GitHub Release, Homebrew formula, Desktop updater build or feed, -or container tag. See [CLI installation](cli.md#npm-nightly) to try Nightly or return to Stable. +or container tag. It never updates the Scoop bucket. See [CLI installation](cli.md#npm-nightly) to try Nightly or return to Stable.
Signing, verification, and partial-release recovery ## Signing and updater behavior -npm uses GitHub OIDC trusted publishers; there is no `NPM_TOKEN` release path. The main `kandev` package and all five `@kdlbs/runtime-*` packages publish provenance attestations. Homebrew requires its repository deploy key. +npm uses GitHub OIDC trusted publishers; there is no `NPM_TOKEN` release path. The main `kandev` package and all five `@kdlbs/runtime-*` packages publish provenance attestations. Homebrew and Scoop each require a separate repository deploy key. Store the Scoop private key as the `SCOOP_BUCKET_DEPLOY_KEY` repository secret; do not reuse the Homebrew key. Release-tag signing applies to future normal releases only. Backfills reuse the existing tag, and historical unsigned tags are not recreated or moved. @@ -106,6 +111,7 @@ After publication, verify: - GitHub notes, five runtime archives, checksums, expected desktop installers, and conditional `latest.json`; - all five runtime npm packages plus `kandev`, including a clean `npx kandev@latest`; - Homebrew install/upgrade, a `Cellar/kandev/X.Y.Z` install path, and `kandev --version`; +- Scoop install/update, the manifest version and hash, and `kandev --version`; - GHCR base and universal images on amd64 and arm64, including their immutable version tags; - desktop launch on affected platforms and signed/notarized status where configured; - backend health, a minimal task, agentctl startup, and Updates-screen behavior; @@ -152,7 +158,7 @@ git push origin vX.Y.Z Before pushing, confirm that `git tag -v` reports the full `RELEASE_GPG_FINGERPRINT` recorded with `.github/release-signing-key.asc`. An arbitrary clone cannot recover by pushing alone because the locally created tag existed only on the failed ephemeral runner. -Use `backfill_tag` only for the latest release when shipped source is correct and the failure is missing artifacts or a recoverable publication step. Backfill checks out application source from the tag, validates all version manifests, and uses the current workflow's control-plane helpers to rebuild or reconcile GitHub Release, GHCR, npm, desktop/updater, and Homebrew channels. Existing npm versions are not overwritten. +Use `backfill_tag` only for the latest release when shipped source is correct and the failure is missing artifacts or a recoverable publication step. Backfill checks out application source from the tag, validates all version manifests, and uses the current workflow's control-plane helpers to rebuild or reconcile GitHub Release, GHCR, npm, desktop/updater, Homebrew, and Scoop channels. Existing npm versions are not overwritten. Publish a new patch instead when code is defective, an immutable npm package or version-specific image is wrong, manifests disagree, or repair would require changing tagged source. Never delete/reuse a published tag or move an npm version as a routine fix. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 68faa80f55..a1a4301191 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -262,6 +262,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [mock-agent-slow-duration](mock-agent-slow-duration/spec.md) | shipped | | [session-subscription-recovery](session-subscription-recovery/spec.md) | draft | | [npm-nightly-channel](npm-nightly-channel/spec.md) | shipped | +| [scoop-release-automation](scoop-release-automation/spec.md) | shipped | | [agent-resume-runtime-recovery](agent-resume-runtime-recovery/spec.md) | shipped | | [agent-stall-recovery](agent-stall-recovery/spec.md) | approved | | [mcp-session-observability](mcp-session-observability/spec.md) | approved | diff --git a/docs/specs/npm-nightly-channel/spec.md b/docs/specs/npm-nightly-channel/spec.md index 3eba1ad2fc..7ce1642753 100644 --- a/docs/specs/npm-nightly-channel/spec.md +++ b/docs/specs/npm-nightly-channel/spec.md @@ -32,7 +32,7 @@ launcher and native runtime packages users install in production. - An npx run is managed only after it installs the service (for example, `npx -y kandev@nightly service install`); a transient `npx -y kandev@nightly` run alone neither creates a managed service nor persists an update-channel selection. -- Desktop, Homebrew, local-checkout, unknown, unmanaged, and system-service installations remain +- Desktop, Homebrew, Scoop, local-checkout, unknown, unmanaged, and system-service installations remain on the Stable channel. - Stable update discovery continues to use GitHub Releases. Nightly discovery follows npm's `kandev@nightly` dist-tag. @@ -40,7 +40,7 @@ launcher and native runtime packages users install in production. accepts it only while it still matches the selected channel's cached target, then installs that immutable version; a changed cache returns `409 Conflict` and requires a refresh. - Nightly publication creates no Git tag, GitHub Release, changelog commit, desktop update, - container tag, or Homebrew update. + container tag, Homebrew update, or Scoop update. Scoop remains a Stable-only channel. Decision: [ADR-2026-07-31-npm-nightly-release-channel](../../decisions/2026-07-31-npm-nightly-release-channel.md). diff --git a/docs/specs/scoop-release-automation/spec.md b/docs/specs/scoop-release-automation/spec.md new file mode 100644 index 0000000000..669011648f --- /dev/null +++ b/docs/specs/scoop-release-automation/spec.md @@ -0,0 +1,90 @@ +--- +status: shipped +created: 2026-08-11 +owner: Kandev +--- + +# Scoop Stable Release Automation + +## Why + +Windows users can install Kandev from the supported Scoop bucket. The bucket +can remain on an older version after Kandev publishes a new Stable release. +This delay prevents `scoop update kandev` from installing the current Stable +version. + +## What + +- Each Kandev Stable release updates `kdlbs/scoop-kandev` after the GitHub + Release and its Windows runtime checksum are available. +- The Scoop manifest uses the exact Stable version, release asset URL, and + published SHA-256 value for `kandev-windows-x64.tar.gz`. +- The Scoop update is a sibling publication channel to npm and Homebrew. +- A Stable release is incomplete until the Scoop update succeeds and its + manifest is correct. +- A release backfill reconciles the Scoop manifest for the selected existing + Stable tag. +- A repeated update for the same version succeeds without an empty commit. +- A Nightly, dry run, or Desktop validation run does not change the Scoop + bucket. +- The release workflow uses a separate repository-scoped credential for the + Scoop bucket. It does not use the Kandev workflow token for cross-repository + writes. + +## Permissions + +The release workflow reads Kandev release assets with its built-in GitHub +token. It writes only to `kdlbs/scoop-kandev` with the +`SCOOP_BUCKET_DEPLOY_KEY` repository secret. The matching public deploy key has +write access only to the Scoop bucket repository. + +## Failure modes + +- If the Windows archive or checksum is absent, the Scoop update stops before + it changes the bucket. +- If the checksum content is invalid, the Scoop update stops before it changes + the bucket. +- If the deploy key is absent or invalid, the Scoop publication job fails. +- If the bucket clone, commit, or push fails, the publication job fails. The + release workflow does not report the Stable release as complete. +- If the manifest already contains the requested version, URL, and hash, the + publication job succeeds without a commit. +- If another process changes the bucket during publication, the push fails. + A maintainer can rerun the latest Stable release with `backfill_tag`. + +## Persistence guarantees + +The generated manifest is stored as a Git commit on the Scoop bucket `main` +branch. A successful rerun does not change that commit when the manifest is +already correct. + +## Scenarios + +- **GIVEN** a Stable GitHub Release with a Windows runtime archive and valid + checksum, **WHEN** the Stable release workflow publishes package-manager + channels, **THEN** the Scoop manifest contains that version, URL, and hash. +- **GIVEN** a Stable release whose Scoop manifest is already correct, **WHEN** + the Scoop publication job runs again, **THEN** the job succeeds without a + new bucket commit. +- **GIVEN** a valid latest Stable tag with a stale Scoop manifest, **WHEN** a + maintainer runs `backfill_tag`, **THEN** the manifest is reconciled to that + tag without creating or moving a release tag. +- **GIVEN** a Nightly, dry run, or Desktop validation run, **WHEN** the release + workflow runs, **THEN** the Scoop repository does not change. +- **GIVEN** an absent or malformed Windows checksum, **WHEN** the Scoop + publication job runs, **THEN** the job fails before it commits a manifest. +- **GIVEN** an absent or invalid Scoop deploy key, **WHEN** the Scoop + publication job runs, **THEN** the job fails and the Stable release remains + incomplete. + +## Out of scope + +- A Scoop Nightly channel. +- Changes to the existing Scoop installation layout or launcher environment. +- Scoop support for Windows ARM64. +- Automation for Winget, Chocolatey, or other package repositories. +- A general scheduled update workflow in `kdlbs/scoop-kandev`. + +## Implementation plan + +See [the implementation plan](../../plans/scoop-release-automation/plan.md). diff --git a/scripts/release/update-scoop-bucket.sh b/scripts/release/update-scoop-bucket.sh new file mode 100755 index 0000000000..5746382861 --- /dev/null +++ b/scripts/release/update-scoop-bucket.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Update the kdlbs/scoop-kandev bucket with a Kandev Stable release. +# +# Usage: +# update-scoop-bucket.sh +# +# Environment: +# SCOOP_SOURCE_REPO Override the source repository (default: +# kdlbs/kandev). +# SCOOP_BUCKET_REPO Override the bucket repository (default: +# kdlbs/scoop-kandev). +# SCOOP_BUCKET_DEPLOY_KEY Private SSH key for the bucket repository. When set, +# clone and push with pinned GitHub host keys. +# +# Without SCOOP_BUCKET_DEPLOY_KEY, the script uses the authenticated `gh` CLI +# to clone the bucket and pushes through the clone's configured remote. This is +# intended for local maintenance and black-box tests; CI preflights the deploy +# key before invoking this script. +set -euo pipefail + +VERSION="${1:?Usage: $0 }" +TAG="${2:?Usage: $0 }" +SOURCE_REPO="${SCOOP_SOURCE_REPO:-kdlbs/kandev}" +BUCKET_REPO="${SCOOP_BUCKET_REPO:-kdlbs/scoop-kandev}" +ARCHIVE_NAME="kandev-windows-x64.tar.gz" +CHECKSUM_NAME="${ARCHIVE_NAME}.sha256" +WORK_DIR="" +BUCKET_DIR="" +SSH_KEY_FILE="" +KNOWN_HOSTS_FILE="" + +log() { + printf ' >> %s\n' "$*" +} + +log_ok() { + printf ' ok %s\n' "$*" +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + die "version must be a three-part SemVer value: $VERSION" +fi +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + die "tag must be a v-prefixed three-part SemVer value: $TAG" +fi +if [[ "$TAG" != "v$VERSION" ]]; then + die "tag must match version: expected v$VERSION, got $TAG" +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandev-scoop.XXXXXX")" +BUCKET_DIR="$WORK_DIR/bucket" + +cleanup() { + local exit_code=$? + trap - EXIT + if [[ -n "$SSH_KEY_FILE" ]]; then + rm -f -- "$SSH_KEY_FILE" || true + fi + if [[ -n "$KNOWN_HOSTS_FILE" ]]; then + rm -f -- "$KNOWN_HOSTS_FILE" || true + fi + if [[ -n "$WORK_DIR" ]]; then + rm -rf -- "$WORK_DIR" || true + fi + exit "$exit_code" +} +trap cleanup EXIT + +# -- Configure the deploy-key clone path ------------------------------------ + +if [[ -n "${SCOOP_BUCKET_DEPLOY_KEY:-}" ]]; then + SSH_KEY_FILE="$WORK_DIR/deploy-key" + umask 077 + printf '%s\n' "$SCOOP_BUCKET_DEPLOY_KEY" > "$SSH_KEY_FILE" + chmod 600 "$SSH_KEY_FILE" + + KNOWN_HOSTS_FILE="$WORK_DIR/known_hosts" + cat > "$KNOWN_HOSTS_FILE" <<'EOF' +github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl +github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= +github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= +EOF + chmod 600 "$KNOWN_HOSTS_FILE" + export GIT_SSH_COMMAND="ssh -i $SSH_KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS_FILE -o LogLevel=ERROR" + log "Using the Scoop bucket deploy key with pinned github.com host keys" +fi + +# -- Fetch the checksum from the exact GitHub release ------------------------ + +log "Fetching $CHECKSUM_NAME from release $TAG..." +if ! gh release download "$TAG" \ + --repo "$SOURCE_REPO" \ + --pattern "$CHECKSUM_NAME" \ + --dir "$WORK_DIR"; then + die "failed to download $CHECKSUM_NAME from release $TAG" +fi + +CHECKSUM_PATH="$WORK_DIR/$CHECKSUM_NAME" +if [[ ! -f "$CHECKSUM_PATH" ]]; then + die "release $TAG did not contain checksum file $CHECKSUM_NAME" +fi + +CHECKSUM_CONTENT="$(<"$CHECKSUM_PATH")" +CHECKSUM_PATTERN='^[0-9a-f]{64}[[:space:]]+\*?kandev-windows-x64\.tar\.gz[[:space:]]*$' +if [[ ! "$CHECKSUM_CONTENT" =~ $CHECKSUM_PATTERN ]]; then + die "invalid checksum for $ARCHIVE_NAME in release $TAG" +fi +SHA256="${CHECKSUM_CONTENT%%[[:space:]]*}" +log_ok "Checksum retrieved and validated" + +# -- Clone the bucket -------------------------------------------------------- + +log "Cloning $BUCKET_REPO..." +if [[ -n "${SCOOP_BUCKET_DEPLOY_KEY:-}" ]]; then + git clone "git@github.com:${BUCKET_REPO}.git" "$BUCKET_DIR" +else + gh repo clone "$BUCKET_REPO" "$BUCKET_DIR" +fi +if [[ ! -d "$BUCKET_DIR/.git" ]]; then + die "bucket clone did not produce a Git repository" +fi +git -C "$BUCKET_DIR" checkout main >/dev/null 2>&1 || die "bucket repository has no main branch" +log_ok "Bucket cloned" + +# -- Update only the version-dependent manifest fields ----------------------- + +MANIFEST_PATH="$BUCKET_DIR/bucket/kandev.json" +if [[ ! -f "$MANIFEST_PATH" ]]; then + die "bucket manifest is missing: bucket/kandev.json" +fi +if ! jq -e ' + (.version | type == "string") and + (.architecture["64bit"].url | type == "string") and + (.architecture["64bit"].hash | type == "string") +' "$MANIFEST_PATH" >/dev/null; then + die "bucket manifest does not contain the expected 64-bit fields" +fi + +RELEASE_URL="https://github.com/${SOURCE_REPO}/releases/download/${TAG}/${ARCHIVE_NAME}" +CURRENT_VERSION="$(jq -r '.version' "$MANIFEST_PATH")" +CURRENT_URL="$(jq -r '.architecture["64bit"].url' "$MANIFEST_PATH")" +CURRENT_HASH="$(jq -r '.architecture["64bit"].hash' "$MANIFEST_PATH")" + +if [[ "$CURRENT_VERSION" == "$VERSION" && "$CURRENT_URL" == "$RELEASE_URL" && "$CURRENT_HASH" == "$SHA256" ]]; then + log "Scoop manifest is already up to date; nothing to commit" + exit 0 +fi + +UPDATED_MANIFEST="$WORK_DIR/kandev.json" +jq --indent 4 \ + --arg version "$VERSION" \ + --arg url "$RELEASE_URL" \ + --arg hash "$SHA256" \ + '.version = $version | .architecture["64bit"].url = $url | .architecture["64bit"].hash = $hash' \ + "$MANIFEST_PATH" > "$UPDATED_MANIFEST" +mv "$UPDATED_MANIFEST" "$MANIFEST_PATH" + +cd "$BUCKET_DIR" +git config user.email "release-bot@kandev" +git config user.name "kandev release bot" +git add -- bucket/kandev.json + +if git diff --cached --quiet; then + log "Scoop manifest is already up to date; nothing to commit" + exit 0 +fi + +git commit -m "kandev $VERSION" +git push origin HEAD:main +log_ok "Scoop bucket updated to $VERSION" diff --git a/scripts/release/update-scoop-bucket.test.mjs b/scripts/release/update-scoop-bucket.test.mjs new file mode 100644 index 0000000000..cb16cb7efb --- /dev/null +++ b/scripts/release/update-scoop-bucket.test.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { + chmod, + mkdir, + mkdtemp, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const sourceRoot = path.resolve(import.meta.dirname, "../.."); +const publisherPath = path.join( + sourceRoot, + "scripts/release/update-scoop-bucket.sh", +); +const validHash = "0123456789abcdef".repeat(4); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + ...options, + encoding: "utf8", + }); + assert.equal( + result.status, + 0, + `${command} ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`, + ); + return result.stdout.trim(); +} + +async function writeExecutable(file, content) { + await writeFile(file, content); + await chmod(file, 0o755); +} + +async function createFixture() { + const root = await mkdtemp(path.join(tmpdir(), "kandev-scoop-")); + const remote = path.join(root, "bucket.git"); + const seed = path.join(root, "seed"); + const bin = path.join(root, "bin"); + await mkdir(bin, { recursive: true }); + + run("git", ["init", "--bare", remote]); + await mkdir(path.join(seed, "bucket"), { recursive: true }); + run("git", ["init", "--initial-branch=main", seed]); + run("git", ["-C", seed, "config", "user.email", "fixture@example.test"]); + run("git", ["-C", seed, "config", "user.name", "Fixture"]); + + const originalManifest = { + version: "0.85.0", + description: "Manage tasks and ship value", + homepage: "https://github.com/kdlbs/kandev", + license: "AGPL-3.0-only", + architecture: { + "64bit": { + url: "https://github.com/kdlbs/kandev/releases/download/v0.85.0/kandev-windows-x64.tar.gz", + hash: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + extract_dir: "kandev", + }, + }, + bin: "bin\\kandev.exe", + env_set: { + KANDEV_BUNDLE_DIR: "$dir", + KANDEV_VERSION: "$version", + }, + checkver: { + github: "https://github.com/kdlbs/kandev", + }, + autoupdate: { + architecture: { + "64bit": { + url: "https://github.com/kdlbs/kandev/releases/download/v$version/kandev-windows-x64.tar.gz", + }, + }, + hash: { + url: "$url.sha256", + }, + }, + }; + await writeFile( + path.join(seed, "bucket/kandev.json"), + `${JSON.stringify(originalManifest, null, 4)}\n`, + ); + run("git", ["-C", seed, "add", "bucket/kandev.json"]); + run("git", ["-C", seed, "commit", "-m", "fixture"]); + run("git", ["-C", seed, "remote", "add", "origin", remote]); + run("git", ["-C", seed, "push", "-u", "origin", "main"]); + run("git", ["--git-dir", remote, "symbolic-ref", "HEAD", "refs/heads/main"]); + + await writeExecutable( + path.join(bin, "gh"), + `#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1" == "release" && "$2" == "download" ]]; then + shift 2 + directory="" + pattern="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --dir) directory="$2"; shift 2 ;; + --pattern) pattern="$2"; shift 2 ;; + --repo) shift 2 ;; + *) shift ;; + esac + done + mkdir -p "$directory" + case "$MOCK_CHECKSUM" in + valid) printf '%s kandev-windows-x64.tar.gz\\n' "$MOCK_HASH" > "$directory/$pattern" ;; + malformed) printf '%s wrong-name.tar.gz\\n' "$MOCK_HASH" > "$directory/$pattern" ;; + absent) exit 0 ;; + *) printf 'unexpected checksum mode\\n' >&2; exit 2 ;; + esac + exit 0 +fi + +if [[ "$1" == "repo" && "$2" == "clone" ]]; then + git clone "$MOCK_BUCKET_REMOTE" "$4" + exit 0 +fi + +printf 'unexpected gh command: %s\\n' "$*" >&2 +exit 2 +`, + ); + + return { + root, + remote, + bin, + originalManifest, + }; +} + +function runPublisher( + fixture, + checksumMode = "valid", + version = "1.2.3", + tag = "v1.2.3", +) { + return spawnSync("bash", [publisherPath, version, tag], { + cwd: sourceRoot, + encoding: "utf8", + env: { + ...process.env, + GH_TOKEN: "fixture-token", + MOCK_BUCKET_REMOTE: fixture.remote, + MOCK_CHECKSUM: checksumMode, + MOCK_HASH: validHash, + PATH: `${fixture.bin}:${process.env.PATH}`, + SCOOP_BUCKET_REPO: "fixture/scoop-kandev", + }, + }); +} + +function remoteHead(fixture) { + return run("git", ["--git-dir", fixture.remote, "rev-parse", "refs/heads/main"]); +} + +function remoteManifest(fixture) { + const manifest = run("git", [ + "--git-dir", + fixture.remote, + "show", + "refs/heads/main:bucket/kandev.json", + ]); + return JSON.parse(manifest); +} + +test("updates the Scoop manifest and is idempotent", async (t) => { + const fixture = await createFixture(); + t.after(async () => rm(fixture.root, { recursive: true, force: true })); + + const first = runPublisher(fixture); + assert.equal( + first.status, + 0, + `publisher failed:\n${first.stdout}\n${first.stderr}`, + ); + const manifest = remoteManifest(fixture); + assert.equal(manifest.version, "1.2.3"); + assert.equal( + manifest.architecture["64bit"].url, + "https://github.com/kdlbs/kandev/releases/download/v1.2.3/kandev-windows-x64.tar.gz", + ); + assert.equal(manifest.architecture["64bit"].hash, validHash); + assert.deepEqual( + { + description: manifest.description, + homepage: manifest.homepage, + license: manifest.license, + extractDir: manifest.architecture["64bit"].extract_dir, + bin: manifest.bin, + envSet: manifest.env_set, + checkver: manifest.checkver, + autoupdate: manifest.autoupdate, + }, + { + description: fixture.originalManifest.description, + homepage: fixture.originalManifest.homepage, + license: fixture.originalManifest.license, + extractDir: fixture.originalManifest.architecture["64bit"].extract_dir, + bin: fixture.originalManifest.bin, + envSet: fixture.originalManifest.env_set, + checkver: fixture.originalManifest.checkver, + autoupdate: fixture.originalManifest.autoupdate, + }, + ); + assert.equal( + run("git", ["--git-dir", fixture.remote, "log", "-1", "--format=%s", "main"]), + "kandev 1.2.3", + ); + + const headAfterUpdate = remoteHead(fixture); + const second = runPublisher(fixture); + assert.equal( + second.status, + 0, + `idempotent rerun failed:\n${second.stdout}\n${second.stderr}`, + ); + assert.equal(remoteHead(fixture), headAfterUpdate); + assert.match(second.stdout, /unchanged|nothing to commit/i); +}); + +test("rejects an absent or malformed checksum before changing the bucket", async (t) => { + const fixture = await createFixture(); + t.after(async () => rm(fixture.root, { recursive: true, force: true })); + + const originalHead = remoteHead(fixture); + for (const checksumMode of ["malformed", "absent"]) { + const result = runPublisher(fixture, checksumMode); + assert.notEqual( + result.status, + 0, + `${checksumMode} checksum unexpectedly passed`, + ); + assert.match(result.stderr, /checksum/i); + assert.equal(remoteHead(fixture), originalHead); + } +}); + +test("rejects mismatched version and tag before changing the bucket", async (t) => { + const fixture = await createFixture(); + t.after(async () => rm(fixture.root, { recursive: true, force: true })); + + const originalHead = remoteHead(fixture); + const result = runPublisher(fixture, "valid", "1.2.3", "v1.2.4"); + + assert.notEqual( + result.status, + 0, + "mismatched version and tag unexpectedly passed", + ); + assert.match(result.stderr, /tag must match version/i); + assert.equal(remoteHead(fixture), originalHead); +});