feat: automate Scoop stable releases (#2517)

This commit is contained in:
Carlos Florêncio
2026-08-16 21:12:19 +00:00
committed by GitHub
parent c9ab80ab94
commit ac08c1df5c
19 changed files with 1043 additions and 38 deletions
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Update the kdlbs/scoop-kandev bucket with a Kandev Stable release.
#
# Usage:
# update-scoop-bucket.sh <version> <tag>
#
# 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 <version> <tag>}"
TAG="${2:?Usage: $0 <version> <tag>}"
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"
@@ -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);
});