# Manual publication workflow (workflow_dispatch) — the outward, irreversible # release steps a human triggers AFTER the automated `release.yaml` build has # produced downloadable assets (and they've been eyeballed as good). # # Why manual + separate from release.yaml: # * Publishing to a public registry is irreversible (crates.io versions can # only be *yanked*, never deleted) — a human pulls this lever, and the # registry token never sits on every tag push. # * Our release is split (Linux/Windows on the tag, macOS dispatched), so a # human is the natural "all assets are up — go" gate. crates.io publish # reads SOURCE so it doesn't strictly need the release, but binstall's # metadata points at the release assets — hence run this once builds exist. # # Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run # independently and one failing (or a newly-added one) never breaks another. # Every job is IDEMPOTENT — re-dispatching when a target is already published is # a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later. name: publish on: workflow_dispatch: inputs: tag: description: 'Release tag to publish (e.g. v0.2.0)' required: true jobs: crates-io: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.tag }} - name: publish to crates.io (idempotent) shell: bash env: TAG: ${{ inputs.tag }} # A crate-scoped, publish-update crates.io token, stored as a Gitea # Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env. CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | set -euo pipefail # Source of truth = the [package] version at the checked-out tag # (toolchain-free read; same approach as release.yaml's guard, which # avoids the flake devShell's stdout banner corrupting a parse). VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/') [ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; } if [ "$TAG" != "v$VER" ]; then echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2 exit 1 fi # Idempotency: if this version is already on crates.io, no-op. # (crates.io requires a descriptive User-Agent per its data policy; # without one the API returns 403.) Only an explicit 200 means # "already there" — anything else proceeds, and `cargo publish` is the # final backstop (it refuses to overwrite an existing version). UA="rdbms-playground-release-ci (oliver@sturmnet.org)" code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \ "https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000) if [ "$code" = "200" ]; then echo "rdbms-playground $VER is already on crates.io — nothing to do." exit 0 fi echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish." echo "publishing rdbms-playground $VER to crates.io ..." nix develop -c cargo publish --locked echo "published rdbms-playground $VER to crates.io." # Update the lazyeval Scoop bucket (Windows). Renders the manifest from the # release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes # with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via # the bot's org-team membership — to the lazyeval package repos only, so a # leak cannot touch oli/rdbms-playground. scoop-bucket: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - uses: actions/checkout@v4 # default ref (main) — current render script - name: update the lazyeval Scoop bucket (idempotent) shell: bash env: TAG: ${{ inputs.tag }} # Passed via env, never inlined into the script, so the value stays # masked in logs; it only materialises in the clone URL at runtime. PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }} run: | set -euo pipefail VER="${TAG#v}" echo "scoop: targeting rdbms-playground $VER ($TAG)" base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" fetch_hash() { local asset="$1" line echo "scoop: fetching $asset.sha256" >&2 line=$(curl -fsSL "$base/$asset.sha256") \ || { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; } # First whitespace-delimited field is the hash. `read` is a bash # builtin (no awk, which the slim CI image may lack). local hash _ read -r hash _ <<<"$line" printf '%s' "$hash" } h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe") h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe") echo "scoop: rendering manifest" bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \ || { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; } work=$(mktemp -d) echo "scoop: cloning lazyeval/scoop-bucket" git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work" cp /tmp/rdbms-playground.json "$work/rdbms-playground.json" cd "$work" git config user.name "lazyeval-ci" git config user.email "ci@lazyeval.net" git add rdbms-playground.json if git diff --cached --quiet; then echo "scoop: manifest already at $VER — nothing to commit." exit 0 fi git commit -m "rdbms-playground $VER" # Push to main explicitly: a freshly-created (empty) repo clone may put # the first commit on a differently-named local branch. Assumes the # bucket/tap default branch is `main` (Gitea's default for new repos). git push origin HEAD:main echo "scoop: bucket updated to rdbms-playground $VER." # Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket; # writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap. homebrew-tap: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - uses: actions/checkout@v4 - name: update the lazyeval Homebrew tap (idempotent) shell: bash env: TAG: ${{ inputs.tag }} PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }} run: | set -euo pipefail VER="${TAG#v}" echo "homebrew: targeting rdbms-playground $VER ($TAG)" base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" fetch_hash() { local asset="$1" line echo "homebrew: fetching $asset.sha256" >&2 line=$(curl -fsSL "$base/$asset.sha256") \ || { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; } # First whitespace-delimited field is the hash. `read` is a bash # builtin (no awk, which the slim CI image may lack). local hash _ read -r hash _ <<<"$line" printf '%s' "$hash" } mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin") mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin") lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl") lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl") echo "homebrew: rendering formula" bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \ > /tmp/rdbms-playground.rb grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \ || { echo "ERROR: rendered formula looks malformed" >&2; exit 1; } work=$(mktemp -d) echo "homebrew: cloning lazyeval/homebrew-tap" git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work" mkdir -p "$work/Formula" cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb" cd "$work" git config user.name "lazyeval-ci" git config user.email "ci@lazyeval.net" git add Formula/rdbms-playground.rb if git diff --cached --quiet; then echo "homebrew: formula already at $VER — nothing to commit." exit 0 fi git commit -m "rdbms-playground $VER" # Push to main explicitly: a freshly-created (empty) repo clone may put # the first commit on a differently-named local branch. Assumes the # bucket/tap default branch is `main` (Gitea's default for new repos). git push origin HEAD:main echo "homebrew: tap updated to rdbms-playground $VER." # Update the winget package (Windows) by opening a PR to microsoft/winget-pkgs # with komac. Unlike scoop-bucket/homebrew-tap (which push to OUR repos and are # live at once), winget is a PR into Microsoft's central, human-gated catalog — # asynchronous, and re-submitting the same version would open a DUPLICATE PR. So # this job guards on both already-merged versions AND already-open PRs before # submitting, which keeps a repeated `publish` dispatch safe. # # Auth: komac needs a CLASSIC GitHub PAT with `public_repo` (fine-grained tokens # cannot open the cross-fork PR — komac #310). It is held on a dedicated GitHub # bot account (a leak can't reach other repos — same reasoning as lazyeval-ci) # and referenced ONLY in this job's env (job-level secret scoping keeps it out # of the other publish jobs). # # PREREQUISITE: the package `LazyEvaluation.RdbmsPlayground` must already exist # in winget-pkgs via a one-time `komac new` (interactive — run manually once; # see ADR-0056 Amendment 4). This job only does the per-release `komac update`. winget: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - name: submit the winget update PR (idempotent) shell: bash env: TAG: ${{ inputs.tag }} # Classic public_repo PAT for the winget bot account. komac reads it # from GITHUB_TOKEN; the API guards below read it via a curl config file # so the token never appears in a command line / process list. GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | set -euo pipefail VER="${TAG#v}" PKG="LazyEvaluation.RdbmsPlayground" echo "winget: targeting $PKG $VER ($TAG)" # Auth header in a 0600 curl config (keeps the token out of argv/logs). umask 077 printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\n' \ "$GITHUB_TOKEN" > /tmp/gh-curlrc api="https://api.github.com" # Guard 1 — already merged into winget-pkgs? # (manifests////) merged=$(curl -sS -o /dev/null -w '%{http_code}' --config /tmp/gh-curlrc \ "$api/repos/microsoft/winget-pkgs/contents/manifests/l/LazyEvaluation/RdbmsPlayground/$VER" || echo 000) if [ "$merged" = "200" ]; then echo "winget: $PKG $VER already in winget-pkgs — nothing to do." exit 0 fi # Guard 2 — an open PR for this exact id+version already? (avoid a dup) # curl -G --data-urlencode does the URL-encoding (no jq in the image). curl -sS -G --config /tmp/gh-curlrc \ --data-urlencode "q=repo:microsoft/winget-pkgs type:pr state:open in:title \"$PKG\" \"$VER\"" \ "$api/search/issues" -o /tmp/winget-search.json open=$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("/tmp/winget-search.json","utf8")).total_count||0))') if [ "$open" != "0" ]; then echo "winget: an open PR for $PKG $VER already exists — skipping." exit 0 fi # Install pinned komac (prebuilt glibc binary; the CI image has no # cargo/komac). Pinned for reproducibility — bump deliberately. KOMAC_VER=2.16.0 curl -fsSL -o /tmp/komac.tgz \ "https://github.com/russellbanks/Komac/releases/download/v$KOMAC_VER/komac-$KOMAC_VER-x86_64-unknown-linux-gnu.tar.gz" tar -xzf /tmp/komac.tgz -C /tmp komac_bin=$(find /tmp -maxdepth 2 -type f -name komac | head -1) [ -n "$komac_bin" ] || { echo "ERROR: komac binary not found after extract" >&2; exit 1; } base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" echo "winget: submitting update PR via komac $KOMAC_VER" # NB: confirm flags against `komac update --help` on first run — komac # evolves; --version/--urls/--submit are the stable core. komac infers # architecture + the `portable` installer type from the binaries. "$komac_bin" update "$PKG" \ --version "$VER" \ --urls "$base/rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe" \ "$base/rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe" \ --submit echo "winget: update PR submitted for $PKG $VER (Microsoft review is async)." # No `needs:` between jobs — each is independent and idempotent, so one failing # or being added never breaks another.