diff --git a/.gitignore b/.gitignore index 2b61f04..dda1949 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ build/ *.nzip !test/fixtures/*.nzip .claude/settings.local.json +.bin/ +.node/ +.tangled-ci/ +.release.json +.bundle-ci/ diff --git a/.tangled/workflows/ci.yml b/.tangled/workflows/ci.yml new file mode 100644 index 0000000..006fc8b --- /dev/null +++ b/.tangled/workflows/ci.yml @@ -0,0 +1,19 @@ +when: + - event: ["push", "pull_request"] + branch: ["main"] + +engine: microvm +image: nixos + +dependencies: + # atcli needs node >= 26.11.1, which nixos-unstable has. + - github:nixos/nixpkgs/nixos-unstable#nodejs_26 + - git + +steps: + - name: install + command: npm ci --no-audit --no-fund + - name: typecheck + command: npm run typecheck + - name: test + command: npm test diff --git a/.tangled/workflows/release.yml b/.tangled/workflows/release.yml new file mode 100644 index 0000000..a307c66 --- /dev/null +++ b/.tangled/workflows/release.yml @@ -0,0 +1,22 @@ +# Release on every pushed version tag (an annotated one: npm version && git push --follow-tags). +# A version that has been released already is left as it is. +# Run by hand (tangled run trigger --workflow release.yml), it is a dry run: it +# builds and checks everything, and uploads, attests and publishes nothing. +# See tools/release.sh for what it does and the secrets it needs. +when: + - event: ["push"] + tag: ["v*"] + - event: ["manual"] + +engine: microvm +image: nixos + +dependencies: + # bundle needs node >= 26.11.1, which nixos-unstable has. + - github:nixos/nixpkgs/nixos-unstable#nodejs_26 + - git + - curl + +steps: + - name: release + command: ./tools/release.sh diff --git a/README.md b/README.md index 6297739..b8b13d3 100644 --- a/README.md +++ b/README.md @@ -241,21 +241,41 @@ setting is where that file is: `ATCLI_CONFIG_DIR` moves it, for tests and for keeping separate setups apart. Beyond that, atcli honours the usual conventions of the platform: `NO_COLOR`, `XDG_CONFIG_HOME`. -## Building the signed archive +## Releasing + +CI (`.tangled/workflows/ci.yml`) typechecks and tests every push and pull +request to `main`. Releases are made the way tangled releases itself: ```sh -npm run check # typecheck and test -npm run manifest # the file list: computed, then checked against recorded runs -npm run pack:nzip # build/atcli.unsigned.nzip, behind bundle's launcher -npm run client-metadata # build/client-metadata.json: this release's OAuth client -npm run audit # review it (with the audit-bundle skill, or by hand: npm run approve) -npm run sign # build/atcli.nzip, signed through sigstore -tangled release upload v$(node -p 'require("./package.json").version') build/atcli.nzip build/client-metadata.json +npm version patch # bumps package.json and makes an annotated tag +git push --follow-tags # the tag starts .tangled/workflows/release.yml ``` -`npm run release` runs everything up to the audit. The archive holds the TypeScript -sources as they are, about 100 kB in all; there is no build step and nothing -generated. +`tools/release.sh` then checks the tag matches package.json, tests, builds +the archive reproducibly (dated by the commit, in UTC), attaches it to the +release as `atcli.nzip`, attests it as published, and points the +`@/atcli` bundle listing at it. A version that already has `atcli.nzip` +is left as it is. Run by hand (`tangled run trigger --workflow release.yml`), +it is a dry run, and so is `RELEASE_DRY_RUN=1 ./tools/release.sh` locally. + +It needs two secrets (`tangled secret set `): `TANGLED_HANDLE` and +`TANGLED_APP_PASSWORD`, an app password for that account. It uploads with the +tangled CLI, installed from its listing only if its author has attested it, +and with bundle's state kept inside the run, so a local dry run leaves your +own installs alone. + +This release's OAuth client document (`build/client-metadata.json`) is built +but not yet attached: the upload line in `tools/release.sh` is commented out +until tangled.org serves release files with their content type +([tangled.org/core#2264](https://tangled.org/tangled.org/core/pulls/2264)). +Until then atcli signs in through `https://ordner.fyi/bundles/atcli.json`. + +To build and try the archive by hand: + +```sh +npm run release # check, manifest, pack, client metadata, audit +./build/atcli.unsigned.nzip --version +``` ## Licence diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..b730c97 --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,151 @@ +#!/bin/sh +# Release atcli 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 atcli.nzip, this +# says so and stops, rather than replacing what people may have installed. +# +# Secrets (set them with `tangled secret set `): +# 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: atcli) +# TANGLED_RELEASE_TAG the tag (default: TANGLED_REF_NAME, set by the spindle) +set -eu + +# 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:-}}" +if [ -z "$TAG" ] && [ -n "$DRY_RUN" ]; then TAG="$version"; fi +: "${TAG:?no tag to release}" +LISTING="${BUNDLE_LISTING:-atcli}" +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 1 +fi + +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_PASSWORD +fi + +# 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 PATH +fi +echo "* 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 .bin +XDG_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/tangled +tangled="$PWD/.bin/tangled" +export 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. + 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 === "atcli.nzip") ? 0 : 1); + ' "$TAG"; then + echo "* $TAG is released already; leaving it as it is" + exit 0 + fi +fi + +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_EPOCH +npm run manifest +npm run pack:nzip +cp build/atcli.unsigned.nzip build/atcli.nzip +npm run client-metadata + +npx bundle verify build/atcli.nzip >/dev/null 2>&1 || [ $? -eq 3 ] # 3: unsigned, as meant +./build/atcli.nzip --version + +if [ -n "$DRY_RUN" ]; then + echo "* dry run: built and checked $(./build/atcli.nzip --version); nothing uploaded, attested or published" + exit 0 +fi + +"$tangled" release upload --json "$TAG" build/atcli.nzip > .release.json +cat .release.json + +# This release's OAuth client, which atcli tries first when signing in. Not +# yet: tangled.org serves release files without their content type until +# https://tangled.org/tangled.org/core/pulls/2264 is deployed, and atcli falls +# back to https://ordner.fyi/bundles/atcli.json meanwhile. Once it is, uncomment +# this and make a patch release. +# "$tangled" release upload "$TAG" build/client-metadata.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 "atcli $TAG" build/atcli.nzip +npx bundle publish --as "$BUNDLE_ATPROTO_IDENTIFIER" --title atcli \ + --description 'Bluesky and the atproto network from the command line, for people and agents' \ + "$LISTING" "$url" + +echo "* released $TAG: bundle install @$BUNDLE_ATPROTO_IDENTIFIER/$LISTING"