Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161#!/bin/sh# Release s3cli from CI, the way tangled releases itself: build the bundle,# attach it to the tag being released, vouch for it with an atproto# attestation, and point the bundle listing at it — all signed in with app# passwords, so a spindle can do it.## The bundle itself is not signed: who stands behind it is said by the# attestation (`bundle attest --kind published`), which installs check.## A version is released once: when the tag already carries s3cli.nzip, this# says so and stops, rather than replacing what people may have installed.## Secrets (set them with `tangled secret set <NAME>`):# TANGLED_HANDLE, TANGLED_APP_PASSWORD the account that uploads the release file# (the repository's owner, or a collaborator)# Optional secrets:# BUNDLE_ATPROTO_IDENTIFIER, BUNDLE_ATPROTO_PASSWORD# another account to attest and publish as# (default: the uploading account)# BUNDLE_LISTING the listing name (default: s3cli)# TANGLED_RELEASE_TAG the tag (default: TANGLED_REF_NAME, set by the spindle)set -eu
# Retry a command through eurosky's createSession rate-limit window: four# simultaneous releases 429'd the PDS login endpoint, and the tools' own quick# retries cannot outlast the window. Generous sleeps, then fail loudly.with_backoff() { tries=4 delay=45 i=1 while :; do if "$@"; then return 0; fi [ "$i" -ge "$tries" ] && { echo "error: still failing after $i attempts: $*" >&2; return 1; } echo "* attempt $i failed - retrying in ${delay}s (rate-limit backoff)" >&2 sleep "$delay" i=$((i + 1)); delay=$((delay * 2)) done}
# A run started by hand is a dry run: everything but uploading, attesting and publishing.DRY_RUN="${RELEASE_DRY_RUN:-}"if [ "${TANGLED_PIPELINE_KIND:-}" = "manual" ]; then DRY_RUN=1; fi
version="v$(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json)"TAG="${TANGLED_RELEASE_TAG:-${TANGLED_REF_NAME:-}}"export TAGif [ -z "$TAG" ] && [ -n "$DRY_RUN" ]; then TAG="$version"; fi: "${TAG:?no tag to release}"LISTING="${BUNDLE_LISTING:-s3cli}"NODE_VERSION="${NODE_VERSION:-26.11.1}"# The tangled CLI that uploads the release must be one its author vouched for.TANGLED_ATTESTER="${TANGLED_ATTESTER:-published@did:plc:rwypz6edxmzyhob2k3rsl2fw}"
echo "* releasing $TAG"
# The tag names the version the bundle reports; they must agree.if [ -z "$DRY_RUN" ] && [ "$TAG" != "$version" ]; then echo "error: the tag is $TAG, but package.json says $version; bump the version (npm version) and tag that" >&2 exit 1fi
if [ -z "$DRY_RUN" ]; then : "${TANGLED_HANDLE:?set the TANGLED_HANDLE secret}" : "${TANGLED_APP_PASSWORD:?set the TANGLED_APP_PASSWORD secret}" BUNDLE_ATPROTO_IDENTIFIER="${BUNDLE_ATPROTO_IDENTIFIER:-$TANGLED_HANDLE}" BUNDLE_ATPROTO_PASSWORD="${BUNDLE_ATPROTO_PASSWORD:-$TANGLED_APP_PASSWORD}" export BUNDLE_ATPROTO_IDENTIFIER BUNDLE_ATPROTO_PASSWORDfi
# bundle needs node >= 26.11.1 (for --experimental-vfs). The workflow brings# it from nixpkgs; elsewhere, nodejs.org's build will do — but not on NixOS,# which cannot run it.if ! node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>26||(a===26&&b>=11)?0:1)' 2>/dev/null; then if [ -e /etc/NIXOS ]; then echo "error: node $(node --version 2>/dev/null || echo missing) is older than $NODE_VERSION; add a newer nodejs to the workflow's dependencies" >&2 exit 1 fi arch="$(uname -m)"; case "$arch" in x86_64) arch=x64;; aarch64) arch=arm64;; esac echo "* fetching node $NODE_VERSION ($arch)" mkdir -p .node curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${arch}.tar.xz" | tar -xJ -C .node --strip-components=1 PATH="$PWD/.node/bin:$PATH"; export PATHfiecho "* node $(node --version)"
npm ci --no-audit --no-fund
# The tangled CLI, to upload with: installed from its listing, and only if# its author has attested it as published. bundle keeps its own state# (install records, attestation cache, policy) in a directory of the run's,# so the run never touches the machine's own installs: an install under a# name already installed moves it.mkdir -p .binXDG_STATE_HOME="$PWD/.bundle-ci/state" XDG_DATA_HOME="$PWD/.bundle-ci/data" XDG_CONFIG_HOME="$PWD/.bundle-ci/config" \ npx bundle install --yes --no-shell --attester "$TANGLED_ATTESTER" --dir "$PWD/.bin" @pipobscure.com/tangledtangled="$PWD/.bin/tangled"export tangledexport TANGLED_CONFIG_DIR="$PWD/.tangled-ci"
if [ -z "$DRY_RUN" ]; then # Sign in to upload the release file. The session is the CI run's alone. with_backoff sh -c 'printf "%s\n" "$TANGLED_APP_PASSWORD" | "$tangled" auth login --app-password --password-stdin "$TANGLED_HANDLE"'
# Released already? Then there is nothing to do. if "$tangled" release list --json | node -e ' const tag = process.argv[1]; const files = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(files.some((file) => file.tag === tag && file.name === "s3cli.nzip") ? 0 : 1); ' "$TAG"; then echo "* $TAG is released already; leaving it as it is" exit 0 fifi
npm run check
# The same commit makes the same archive: every member dated no later than# the commit, and ZIP times written in UTC.TZ=UTC SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"export TZ SOURCE_DATE_EPOCHnpm run manifestnpm run pack:nzipcp build/s3cli.unsigned.nzip build/s3cli.nzip
npx bundle verify build/s3cli.nzip >/dev/null 2>&1 || [ $? -eq 3 ] # 3: unsigned, as meant./build/s3cli.nzip --version
if [ -n "$DRY_RUN" ]; then echo "* dry run: built and checked $(./build/s3cli.nzip --version); nothing uploaded, attested or published" exit 0fi
with_backoff sh -c '"$tangled" release upload --json "$TAG" build/s3cli.nzip > .release.json'cat .release.json
# tangled.org serves the file once it has seen the record, which may take a# while; the uploader's PDS serves it at once. Prefer the first, wait a little# for it, and fall back to the second.url="$(node -e ' const [entry] = JSON.parse(require("fs").readFileSync(".release.json", "utf8")); const deadline = Date.now() + 5 * 60_000; (async () => { for (;;) { // tangled.org answers HEAD with 405, and some errors with a 200 and // an HTML fragment: only the file itself, at its size, counts. const response = await fetch(entry.url).catch(() => undefined); const bytes = response?.ok ? Buffer.from(await response.arrayBuffer()) : undefined; if (bytes?.length === entry.size && bytes.subarray(0, 2).toString() === "#!") return console.log(entry.url); if (Date.now() > deadline) return console.log(entry.blobUrl); await new Promise((resolve) => setTimeout(resolve, 10_000)); } })();')"echo "* release file at $url"
npx bundle attest --kind published --note "s3cli $TAG" build/s3cli.nzipnpx bundle publish --as "$BUNDLE_ATPROTO_IDENTIFIER" --title s3cli \ --description 'S3 and S3-compatible object storage from the command line, with an ftp-style interactive shell' \ "$LISTING" "$url"
echo "* released $TAG: bundle install @$BUNDLE_ATPROTO_IDENTIFIER/$LISTING"