feat: automate Scoop stable releases (#2517)
This commit is contained in:
Executable
+175
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user