From 7ab10b5f5a67b02f1e77e108c0deda60f65b8a09 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 23:58:12 -0600 Subject: [PATCH 1/3] chore(ci): pin every workflow action to a commit SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow builds the artifacts and creates the draft release, and it named its actions by mutable reference: `actions/checkout@v4`, `dtolnay/rust-toolchain@stable`, `actions/cache@v4`, `actions/upload-artifact@v4`, `actions/download-artifact@v4` and `cloudflare/wrangler-action@v3`. A tag can be moved and a branch moves on its own, so the release path executed code that could change with no diff to review here. All 19 action references now name a 40-hex commit SHA, with the version kept in a trailing comment. The rule is enforced, not reviewed: `scripts/validate_workflow_pins.py` walks the workflow directory and fails with the file and line of every unpinned reference, and it runs in the backup CI's `changes` job — the job every push and pull request executes, and the one `fmt` and `gate` depend on. `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one deliberately broken input (a mutable tag, a mutable branch, a `docker://` image, a short SHA) and the two inputs the validator must accept. The deploy workflow triggered on a push that touched the workflow file itself, so every edit to it published the marketing site. The trigger is now the content it publishes — `site/**` and `wrangler.toml` — and a deliberate redeploy is `workflow_dispatch`. Decision 0099; ticket T27-57. Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 30 +++--- .github/workflows/deploy-site.yml | 10 +- .github/workflows/release.yml | 12 +-- docs/TASKS.md | 1 + .../0099-workflow-actions-are-pinned.md | 77 +++++++++++++++ docs/release/EXECUTION-BOARD.md | 2 +- docs/tasks/T27-57-workflow-hardening.md | 35 +++++-- scripts/validate_workflow_pins-selftest.sh | 73 ++++++++++++++ scripts/validate_workflow_pins.py | 97 +++++++++++++++++++ 9 files changed, 309 insertions(+), 28 deletions(-) create mode 100644 docs/decisions/0099-workflow-actions-are-pinned.md create mode 100644 scripts/validate_workflow_pins-selftest.sh create mode 100644 scripts/validate_workflow_pins.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b2b29e..8f4d7ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: code_changed: ${{ steps.classify.outputs.code_changed }} steps: - name: Checkout full history - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Classify changed paths (fail closed on unknown/empty diff) @@ -73,6 +73,14 @@ jobs: echo "code_changed=true" >> "$GITHUB_OUTPUT" fi printf 'Changed files:\n%s\n' "$files" + # Every action reference is pinned to a commit SHA. This runs in the + # `changes` job — which every push and pull request executes, and + # which the `fmt` and `gate` jobs depend on — so an unpinned action + # fails the workflow before any job builds or publishes anything. + - name: Every action is pinned to a commit SHA + run: | + python3 scripts/validate_workflow_pins.py .github/workflows + bash scripts/validate_workflow_pins-selftest.sh docs: name: docs-only-check runs-on: ubuntu-latest @@ -80,7 +88,7 @@ jobs: if: needs.changes.outputs.docs_only == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate whitespace and merge markers run: | set -euo pipefail @@ -95,9 +103,9 @@ jobs: if: needs.changes.outputs.code_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt - name: Check formatting @@ -111,9 +119,9 @@ jobs: if: needs.changes.outputs.code_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt - name: Install Aegis @@ -128,24 +136,24 @@ jobs: - name: Install wasm32 target run: rustup target add wasm32-unknown-unknown - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/registry key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/git key: cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache build artifacts - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: target key: cargo-build-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: cargo-build-${{ runner.os }}- - name: Cache cargo-llvm-cov id: cache-llvc - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/bin/cargo-llvm-cov key: cargo-llvm-cov-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} @@ -154,7 +162,7 @@ jobs: run: cargo install cargo-llvm-cov --locked - name: Cache trunk id: cache-trunk - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/bin/trunk key: trunk-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index cbfc4b5f..d3743d22 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -3,10 +3,14 @@ name: Deploy FireCraft marketing site on: push: branches: [main] + # The trigger is the deployed content, not this file. The workflow's own + # source is not part of the Pages deployment — the deploy step publishes + # `site/` — so listing it here made every edit to this file a production + # publication. Production deploys stay tied to a change in what is + # published; a deliberate redeploy is `workflow_dispatch`. paths: - "site/**" - "wrangler.toml" - - ".github/workflows/deploy-site.yml" workflow_dispatch: permissions: @@ -22,7 +26,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Validate static site run: | test -s site/index.html @@ -33,7 +37,7 @@ jobs: grep -q 'prefers-reduced-motion' site/styles.css grep -q 'loadRelease' site/app.js - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27d83e21..d6bdaf91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,13 +44,13 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} @@ -178,7 +178,7 @@ jobs: cargo run --quiet --package xtask -- dist --verify-manifest - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-artifacts path: | @@ -212,10 +212,10 @@ jobs: # yet hold a .git, so a checkout placed after the download erases # the artifacts it just downloaded. - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-artifacts path: artifact-tree/ diff --git a/docs/TASKS.md b/docs/TASKS.md index 0760f2b0..fc6035a3 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -58,6 +58,7 @@ |---|---|---|---|---|---| | 5 | 19.6 inert-menu wiring campaign | [tasks/T5-inert-burndown.md](tasks/T5-inert-burndown.md) | `DONE — 2026-11-11` | UI/verb lane | Batches 1–17 are complete. The current pinned census is 2 inert rows of 296; eight other rows remain explicitly feature/evidence-gated and are not part of an active wiring campaign (`menus::tests::inert_count_never_rises`, `menus::tests::menu_item_total_is_pinned`). | | 194 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate (decision 0074) | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md) | `IMPLEMENTED` — the content-keyed gate and its decision record are in the tree; the gate has been exercised on this branch's tree | CI/tooling lane | **landed in `main` (#18)** and merged here; attach a run id before claiming the fleet-wide state | +| — | Release workflow hardening: every action pinned to a commit SHA, and a fail-closed pin check in CI (decision 0099) | [decisions/0099-workflow-actions-are-pinned.md](decisions/0099-workflow-actions-are-pinned.md) | `IMPLEMENTED` — the 19 action references are pinned and the validator plus its self-test pass locally; the CI receipt is pending | CI/tooling lane | land the branch on both receipts; the check runs in the backup CI's `changes` job, and mirroring it into the GitForge pipeline waits for the `:2` pipeline shape (PR #123, PR #129) | | 27 | Cross-platform release engineering: Windows, macOS, Linux package matrix, Android feasibility, source, SBOM/provenance, signing, CI aggregation and certification | [tasks/T27-cross-platform-release.md](tasks/T27-cross-platform-release.md) | `IN PROGRESS` — implementation is ahead of verification; **no target is `CERTIFIED`** and nothing is signed | release-workflow lane | land an end-to-end tag run of the repaired workflow and attach the run id as the RP-07 receipt; consolidate the duplicated `dist --source`/`--sbom` verbs (B-3) | | 26 | Refresh user-visible CS6 parity matrix | [tasks/T26-parity-matrix.md](tasks/T26-parity-matrix.md) | `IMPLEMENTED` — matrix published | parity lane | every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state is classified with an evidence citation and an owning ticket in [`CS6-PARITY.md`](CS6-PARITY.md); the browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22 | | 22 | Phase 22: CS6 shell fidelity and screenshot audit | [tasks/T22-screenshot-ui.md](tasks/T22-screenshot-ui.md) | `IN PROGRESS` | UI lane | screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip landed on `main` with the shell-lineage train (#107); 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | diff --git a/docs/decisions/0099-workflow-actions-are-pinned.md b/docs/decisions/0099-workflow-actions-are-pinned.md new file mode 100644 index 00000000..faa6f997 --- /dev/null +++ b/docs/decisions/0099-workflow-actions-are-pinned.md @@ -0,0 +1,77 @@ +# 0099 — Every workflow action is pinned to a commit, and a production deploy is triggered by content, not by its own workflow file + +Date: 2026-11-11 · Ticket: [T27-57](../tasks/T27-57-workflow-hardening.md) · Issue [#97](https://github.com/aliasfoxkde/FireCraft/issues/97) · Status: accepted + +## Context + +The release campaign's rule is that a release must fail closed: the artifacts +published under a tag are the artifacts the repository's own code produced, and +nothing in the build path may change without a review of this repository. Two +holes in that rule sat in the GitHub Actions workflows. + +1. **Every third-party action was named by a mutable reference.** + `actions/checkout@v4`, `actions/cache@v4`, `actions/upload-artifact@v4`, + `actions/download-artifact@v4`, `cloudflare/wrangler-action@v3` and + `dtolnay/rust-toolchain@stable` all resolve, at job-run time, to whatever + the upstream tag or branch points at that day. A tag can be moved; a branch + moves on its own. The release workflow — the one that builds the desktop, + CLI, web and source artifacts, writes the SBOM, and creates the draft + release — therefore executed code that can change without a diff to review + here. The Aegis scanner was already SHA-pinned; the actions were not, and + the ticket that asks for the hardening (`T27-57`) names exactly this as its + first acceptance criterion. +2. **Editing the deploy workflow published to production.** `deploy-site.yml` + triggered on a push to `main` that touched `site/**`, `wrangler.toml` *or + `.github/workflows/deploy-site.yml`*. The deploy step publishes `site/`; the + workflow's own source is not part of the Pages deployment. Listing it in the + trigger paths meant that every edit to the workflow file — including a + one-line pin — was a production publication of the marketing site. The + campaign keeps production publication gated for explicit maintainer + approval, so a workflow edit must not silently become a deploy. + +## Decision + +- **Every `uses:` that names an action is `owner/repo@<40-hex-sha>`.** The + version the SHA corresponds to is recorded in a trailing comment, so the + pin is auditable without a network call. +- **A local action (`./path`) is allowed.** It is this repository's own code + and it is reviewed in the diff. +- **The rule is enforced by a script, not by review.** `scripts/validate_workflow_pins.py` + walks `.github/workflows/*.yml`, and fails with the file and line of every + unpinned reference. It runs in the backup CI's `changes` job — the job every + push and pull request executes, and the job the `fmt` and `gate` jobs depend + on — so an unpinned action fails the workflow before any job builds or + publishes anything. The script is stdlib-only, like the pipeline validator + beside it. +- **The failure modes are demonstrated, not asserted.** + `scripts/validate_workflow_pins-selftest.sh` feeds the validator one + deliberately broken input per failure mode — a mutable tag, a mutable + branch, a `docker://` image, a short SHA — plus the two inputs it must + accept (a pinned SHA, a local action), and checks the exit code each time. + It runs in the same CI step as the validator itself. +- **A production deploy is triggered by the content it publishes.** The + `deploy-site.yml` trigger paths are `site/**` and `wrangler.toml`; the + workflow's own file is no longer one of them. A deliberate redeploy is + `workflow_dispatch`. + +## Consequences + +- The release workflow's code path is fixed at review time. A new action + version is a deliberate commit: bump the SHA and the version comment. +- `dtolnay/rust-toolchain` is pinned to the SHA of its `stable` branch at the + time of the change. The action's code is fixed; the Rust toolchain it + installs still follows its own default, so the toolchain version can move + under a pinned action. Pinning the action is the part that closes the + supply-chain hole; pinning the toolchain version is a separate decision + that would need the CI image rebuilt with it. +- The pin check runs in the GitHub backup CI. It is not yet in the GitForge + pipeline (the primary CI), because `.gitforge.yml` is being restructured in + flight (PR #123, PR #129); mirroring the step there is a one-line change + once that settles. Both CIs must be green before a merge, so the rule is + still enforced on every pull request. +- The self-test uses `mktemp -d` and removes its fixtures; it writes nothing + outside the temporary directory. +- The deploy workflow no longer redeploys the site when only the workflow + changes. If a future change to `deploy-site.yml` must reach production, it + is a `workflow_dispatch` run — a deliberate act rather than a side effect + of a merge. diff --git a/docs/release/EXECUTION-BOARD.md b/docs/release/EXECUTION-BOARD.md index 683757fc..8f9473a0 100644 --- a/docs/release/EXECUTION-BOARD.md +++ b/docs/release/EXECUTION-BOARD.md @@ -234,7 +234,7 @@ condition/verification. **RP** is the release-plan work package the row belongs | T27-54 | Generate and verify a machine-readable release manifest | P0 | IMPLEMENTED | T27-53 | `release-manifest.json` carries version, tag, source SHA, target triple, format, size, SHA-256, run URL and verification status; missing and corrupted assets are rejected | `jq -r '.version,.artifacts[0].sha256' dist/release-manifest.json` | Implemented on `feat/release-manifest` ([decision 0089](../decisions/0089-release-manifest-shape.md)): `dist --manifest` writes `dist/release-manifest.json` for the eight published assets, `dist --verify-manifest` re-checks it against the files, and `release.yml` publishes it as a ninth asset and validates it against the downloads. Unit tests cover the shape, the determinism and every rejection path. **No CI receipt and no published manifest yet** | — | — | | T27-55 | Regenerate and validate the CycloneDX SBOM | P0 | NOT STARTED | T27-06 | The SBOM regenerates from the locked graph, validates against the CycloneDX schema, and is compared component-by-component against `Cargo.lock`; it is published and the download is verified | `cargo run -p xtask -- dist --sbom && jq .metadata.component dist/sbom/firecraft-0.2.5-sbom.cdx.json` | `00fdd61` marks the updated SBOM verification pending; the earlier artifact was JSON-validated only | — | — | | T27-56 | Generate and independently verify build provenance | P1 | NOT STARTED | T27-53 | A provenance format is defined; each attestation binds an artifact digest to the source SHA, workflow and runner; attestations verify independently | `gh attestation verify --repo aliasfoxkde/FireCraft` | Not implemented | — | — | -| T27-57 | Harden the release workflows | P1 | NOT STARTED | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists | `grep -n 'uses:' .github/workflows/*.yml` | `ci.yml`/`release.yml` still use mutable tags (`@v4`, `@stable`) | — | — | +| T27-57 | Harden the release workflows | P1 | IN PROGRESS | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists | `python3 scripts/validate_workflow_pins.py .github/workflows` | **Pinning implemented 2026-11-11** (decision [0099](../decisions/0099-workflow-actions-are-pinned.md)): all 19 action references in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA with the version in a trailing comment; `scripts/validate_workflow_pins.py` fails the CI on an unpinned reference, and `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one deliberately broken input. Least-privilege permissions were already declared (`contents: read` at workflow level, `contents: write` only for the draft-release job) and the aggregate gate already fails closed on a missing, empty, misnamed or digest-mismatched artifact. The deploy trigger no longer includes the workflow's own file, so a workflow edit is not a production publication. **Outstanding**: the "skipped required platform" check and a safe test-release mode are not yet in the workflow | — | 2026-11-11: pins implemented and locally exercised; the CI receipt is pending | ### RP-07 — Release aggregation and publication diff --git a/docs/tasks/T27-57-workflow-hardening.md b/docs/tasks/T27-57-workflow-hardening.md index cb9b5b1d..e4a4ff9a 100644 --- a/docs/tasks/T27-57-workflow-hardening.md +++ b/docs/tasks/T27-57-workflow-hardening.md @@ -1,6 +1,6 @@ # T27-57 — Harden the release workflows -> **RP:** RP-06 · **Priority:** P1 · **Status:** NOT STARTED +> **RP:** RP-06 · **Priority:** P1 · **Status:** IN PROGRESS > **Board row:** [EXECUTION-BOARD.md §6](../release/EXECUTION-BOARD.md) > **Depends on:** T27-18 @@ -19,12 +19,33 @@ artifacts bind to one tag SHA; the gate fails on missing outputs, a version mism a checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists. -**Verification:** `grep -n 'uses:' .github/workflows/*.yml` - -**Evidence:** `ci.yml` and `release.yml` still reference mutable tags (`@v4`, -`@stable`); only the Aegis scanner is SHA-pinned. - -**Blockers:** T27-18. +**Verification:** `python3 scripts/validate_workflow_pins.py .github/workflows` and +`bash scripts/validate_workflow_pins-selftest.sh` + +**Evidence (2026-11-11):** decision +[0099](../decisions/0099-workflow-actions-are-pinned.md) — all 19 action references +in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA (the +version is kept in a trailing comment). `scripts/validate_workflow_pins.py` fails +the workflow on an unpinned reference and runs in the backup CI's `changes` job — +the job every push and pull request runs, and the job `fmt` and `gate` depend on. +`scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one +deliberately broken input (a mutable tag, a mutable branch, a `docker://` image, a +short SHA) and the two accepted inputs (a pinned SHA, a local action). The deploy +trigger no longer lists the workflow's own file, so a workflow edit is not a +production publication. + +**Already satisfied before this change:** least-privilege permissions (`contents: read` +at workflow level, `contents: write` only for the draft-release job); build and +publish are separate jobs; the artifacts bind to the tag SHA through the checkout; +the aggregate gate fails closed on a missing, empty, misnamed or digest-mismatched +artifact and on a version mismatch. + +**Outstanding:** the "skipped required platform" check is not in the workflow, and a +safe test-release mode is not yet a documented workflow state. The pin check is in +the GitHub backup CI; mirroring it into the GitForge pipeline waits for the `:2` +pipeline shape (PR #123, PR #129). + +**Blockers:** none for the pinning; the remaining two criteria are unstarted work. **Stopping condition:** every `uses:` is a full commit SHA, the permission set is least-privilege, and the failure-mode checks are demonstrated with one deliberately diff --git a/scripts/validate_workflow_pins-selftest.sh b/scripts/validate_workflow_pins-selftest.sh new file mode 100644 index 00000000..a81ffe86 --- /dev/null +++ b/scripts/validate_workflow_pins-selftest.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Self-test for scripts/validate_workflow_pins.py. +# +# The ticket that asks for the workflow hardening asks for the failure +# modes to be *demonstrated* — one deliberately broken input each — so +# this script feeds the validator the inputs it must reject and the +# inputs it must accept, and checks the exit code each time. +# +# Usage: bash scripts/validate_workflow_pins-selftest.sh +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +validator="$root/scripts/validate_workflow_pins.py" +work="$(mktemp -d)" +trap 'find "$work" -type f -delete; find "$work" -type d -empty -delete' EXIT + +failures=0 + +expect() { + # expect