Vault roll #467
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Vault roll | |
| on: | |
| schedule: | |
| - cron: "*/20 * * * *" | |
| workflow_dispatch: | |
| concurrency: | |
| group: vault-roll | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| jobs: | |
| roll: | |
| name: Close settled cycle and open the next | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| defaults: | |
| run: | |
| working-directory: keeper | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup Node | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: keeper/package-lock.json | |
| - name: Install keeper dependencies | |
| run: npm ci | |
| - name: Rebase latest receipts before rolling | |
| run: git pull --rebase origin main | |
| working-directory: ${{ github.workspace }} | |
| - name: Validate Vercel deploy token before rolling | |
| working-directory: ${{ github.workspace }} | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| const token = process.env.VERCEL_TOKEN; | |
| const teamId = process.env.VERCEL_ORG_ID; | |
| const projectId = process.env.VERCEL_PROJECT_ID; | |
| if (!token) throw new Error("VERCEL_TOKEN secret is required before vault roll"); | |
| if (!teamId) throw new Error("VERCEL_ORG_ID secret is required before vault roll"); | |
| if (!projectId) throw new Error("VERCEL_PROJECT_ID secret is required before vault roll"); | |
| const response = await fetch(`https://api.vercel.com/v10/projects/${projectId}?teamId=${teamId}`, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| throw new Error(`VERCEL_TOKEN preflight failed with ${response.status}: ${text}`); | |
| } | |
| const project = await response.json(); | |
| console.log("CAPLETFI_VERCEL_TOKEN_VALID"); | |
| console.log(JSON.stringify({ project: project.name, projectId }, null, 2)); | |
| NODE | |
| - name: Roll the vault | |
| run: npx tsx src/vaultCycle.ts roll | |
| env: | |
| VAULT_OPERATOR_PRIVATE_KEY: ${{ secrets.VAULT_OPERATOR_PRIVATE_KEY }} | |
| - name: Commit fill receipts | |
| id: receipts | |
| run: | | |
| if [ -n "$(git status --porcelain data/vault_fills.ndjson)" ]; then | |
| echo "changed=1" >> "$GITHUB_OUTPUT" | |
| cp data/vault_fills.ndjson ../web/public/vault_fills.ndjson | |
| cd .. | |
| npm run verify:ledger | |
| git config user.name qdee | |
| git config user.email iyandaadedolapo@gmail.com | |
| git add keeper/data/vault_fills.ndjson web/public/vault_fills.ndjson | |
| # Do not use [skip ci]: Vercel treats it as a deploy skip, so live receipts go stale. | |
| # The main CI workflow ignores receipt-only commits to keep scheduled rolls cheap. | |
| git commit -m "receipts: vault roll fill ledger" | |
| git pull --rebase origin main | |
| git push origin main | |
| else | |
| echo "changed=0" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Deploy fresh receipts to Vercel | |
| if: steps.receipts.outputs.changed == '1' | |
| working-directory: web | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| run: | | |
| test -n "$VERCEL_ORG_ID" || { echo "VERCEL_ORG_ID secret is required for receipt freshness"; exit 1; } | |
| test -n "$VERCEL_PROJECT_ID" || { echo "VERCEL_PROJECT_ID secret is required for receipt freshness"; exit 1; } | |
| test -n "$VERCEL_TOKEN" || { echo "VERCEL_TOKEN secret is required for receipt freshness"; exit 1; } | |
| npx --yes vercel@latest deploy --prod --yes --token="$VERCEL_TOKEN" | |
| - name: Verify live receipt freshness | |
| if: steps.receipts.outputs.changed == '1' | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| import { readFileSync } from "node:fs"; | |
| const rows = readFileSync("web/public/vault_fills.ndjson", "utf8") | |
| .split("\n") | |
| .map((line) => line.trim()) | |
| .filter(Boolean) | |
| .map((line) => JSON.parse(line)); | |
| const latest = rows.at(-1); | |
| if (!latest?.tx) throw new Error("committed receipt ledger has no latest tx"); | |
| let lastLive = ""; | |
| for (let attempt = 1; attempt <= 20; attempt += 1) { | |
| const response = await fetch("https://capletfi.vercel.app/api/vault/ledger", { | |
| headers: { "cache-control": "no-cache" }, | |
| }); | |
| if (response.ok) { | |
| const live = await response.json(); | |
| lastLive = live.summary?.latestTx ?? ""; | |
| if (lastLive === latest.tx) { | |
| console.log("CAPLETFI_RECEIPT_DEPLOY_FRESH"); | |
| console.log(JSON.stringify({ latestTx: latest.tx, attempt }, null, 2)); | |
| process.exit(0); | |
| } | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, 15_000)); | |
| } | |
| throw new Error(`live ledger latestTx ${lastLive || "(missing)"} did not match committed ${latest.tx}`); | |
| NODE |