feat(content): activate the launch achievement ladder + quest reprici… #3056
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: CI | |
| # P0-C3 — every PR runs build/test/lint/typecheck/audit and blocks on failure. | |
| # Required checks (make green before merge): frontend, onchain-rust, build-server. | |
| # The `integration` job builds the pinocchio program (default + fresh-id) and | |
| # replays all instructions against it on LiteSVM. | |
| # | |
| # Path filtering (P3): a `changes` job (dorny/paths-filter) computes which areas | |
| # a push/PR touched; each job is JOB-LEVEL `if:`-gated on those outputs. A job | |
| # skipped this way reports a "skipped" conclusion, which branch protection | |
| # treats as PASSING — so the required-check names stay intact with no | |
| # branch-protection change. (Do NOT convert this to workflow-level `on.paths`: | |
| # that leaves required checks stuck "pending" and blocks merges.) A | |
| # workflow_dispatch always runs everything. | |
| on: | |
| push: | |
| # `**` = every branch (gives fork branches CI before the upstream PR). | |
| # `!program-hash` excludes the CI-owned hash-record branch so pushes there | |
| # never spin up CI — defense-in-depth vs a future PAT swap (GITHUB_TOKEN | |
| # pushes already don't trigger workflows). Negation MUST follow `**` to win. | |
| branches: ["**", "!program-hash"] | |
| pull_request: | |
| branches: [main] | |
| # `labeled` lets the verifiable-build job (gated on the `verifiable-build` | |
| # label) re-run when the label is added mid-PR. Other jobs ignore the label. | |
| types: [opened, synchronize, reopened, labeled] | |
| # Manual trigger for on-demand reproducible build + program-hash publishing. | |
| workflow_dispatch: | |
| # Cancel superseded runs on the same ref to save CI minutes. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| NODE_VERSION: "20" | |
| jobs: | |
| # ── Change detection: which areas did this push/PR touch? ─────────────────── | |
| # Downstream jobs gate on these outputs (see the header note on why this is | |
| # job-level `if:` and not workflow-level `on.paths`). | |
| changes: | |
| name: Detect changes | |
| runs-on: ubuntu-latest | |
| if: github.event.action != 'labeled' | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| frontend: ${{ steps.filter.outputs.frontend }} | |
| onchain: ${{ steps.filter.outputs.onchain }} | |
| buildserver: ${{ steps.filter.outputs.buildserver }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 | |
| id: filter | |
| with: | |
| filters: | | |
| frontend: | |
| - 'apps/web/**' | |
| # The `Unit tests` step runs `pnpm -r test`, which covers | |
| # content-schema and content-lint (that was the #374 fix). Without | |
| # `packages/**` here the whole job is skipped for a packages-only | |
| # PR, so those suites never run on precisely the changes they | |
| # exist to guard — #638 shipped two new test files with zero CI | |
| # coverage. Keep this in sync with what `pnpm -r` reaches. | |
| - 'packages/**' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| - 'tsconfig*.json' | |
| # The brace-glob probe runs inside this job (#905). Any edit to it | |
| # must re-run it, and `package.json` / `pnpm-lock.yaml` above are | |
| # already the inputs it guards (the pnpm.overrides pins). | |
| - 'scripts/brace-glob-probe.mjs' | |
| - '.github/workflows/ci.yml' | |
| onchain: | |
| - 'onchain-academy/**' | |
| - '.github/workflows/ci.yml' | |
| buildserver: | |
| - 'apps/build-server/**' | |
| - '.github/workflows/ci.yml' | |
| # ── Frontend / monorepo: prettier, eslint, tsc, vitest ────────────────────── | |
| frontend: | |
| name: Frontend (lint · typecheck · test) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| # The `labeled` PR event only exists to trigger verifiable-build; skip the | |
| # heavy jobs so adding a label doesn't re-run the whole matrix. Also skip | |
| # when no frontend-relevant files changed (workflow_dispatch forces a run). | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.frontend == 'true' || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10, version read from package.json "packageManager" | |
| - name: Setup Node | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Brace-glob probe (minimatch / brace-expansion resolution) | |
| # #905. The ONLY detector for a regression class the green suites | |
| # provably miss: #820's `brace-expansion: ^5` override passed 2354 tests | |
| # and 6/6 typechecks while breaking every brace glob at runtime, because | |
| # non-brace patterns short-circuit before expansion is ever called. This | |
| # step drives real brace patterns through every minimatch / | |
| # brace-expansion / glob / test-exclude copy the lockfile RESOLVES to | |
| # (orphaned store dirs are reported as INFO and never fail the build), | |
| # asserts the CVE-2026-14257 bound holds in each, and checks every | |
| # resolved version still satisfies its root `pnpm.overrides` pin. | |
| # | |
| # Hard gate on purpose — a dependency change that reintroduces a | |
| # vulnerable or incompatible copy must fail the build, not become tribal | |
| # knowledge. Runs right after install so it fails before the slow steps. | |
| run: pnpm probe:brace-glob | |
| - name: Prettier | |
| # RATCHET: report-only until the repo is prettier-clean. Run `pnpm format` | |
| # once, commit, then remove continue-on-error to make this a hard gate. | |
| continue-on-error: true | |
| run: pnpm format:check | |
| - name: Lint | |
| run: pnpm lint | |
| - name: Content bundle freshness | |
| # Recompile the committed content bundle from the SHA in content.lock and | |
| # fail if it drifts from what is checked in — a hand-edit of the generated | |
| # files or a stale bundle after a lock bump is caught here. Fetches the | |
| # public academy-courses tarball; GITHUB_TOKEN only raises the rate limit. | |
| # If GitHub is unreachable the step fails loudly (acceptable — deploys read | |
| # the committed bundle, not this fetch). | |
| # | |
| # `git status --porcelain` (not `git diff --exit-code`): it stays clean when | |
| # public/content-assets is absent (zero live images leave the dir uncreated, | |
| # which `git diff` treats as a fatal missing-pathspec) AND it detects newly | |
| # emitted, still-untracked asset files that `git diff` would miss. | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| pnpm --filter web compile-content | |
| drift="$(git status --porcelain -- apps/web/src/content/generated apps/web/public/content-assets apps/web/content.lock)" | |
| if [ -n "$drift" ]; then | |
| echo "::error::Content bundle is stale — recompile drifted from the committed bundle." | |
| echo "$drift" | |
| git --no-pager diff -- apps/web/src/content/generated apps/web/public/content-assets apps/web/content.lock | |
| exit 1 | |
| fi | |
| - name: Typecheck | |
| run: pnpm typecheck | |
| - name: Unit tests | |
| # P1-1 landed: the frontend suite is green and is now a hard gate. | |
| # `pnpm -r test` runs the `test` script of EVERY workspace package that | |
| # defines one — @superteam-lms/web, content-schema, and content-lint — | |
| # so all vitest suites gate CI. The old web-only `--filter` silently | |
| # skipped content-schema (93 tests) and content-lint (32) (fixes #374). | |
| # Env defaults below mirror apps/web/vitest.setup.ts so the web suite | |
| # also runs without prod secrets (see P1-1 / P0-C2). | |
| env: | |
| NEXT_PUBLIC_PROGRAM_ID: 7NeJaSRyb4Wxay3Tcd9bdpD7T3GWYUQSFyrhG8SgwE8V | |
| NEXT_PUBLIC_SOLANA_NETWORK: devnet | |
| NEXT_PUBLIC_SOLANA_RPC_URL: https://api.devnet.solana.com | |
| NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: test-anon-key | |
| NEXT_PUBLIC_APP_URL: http://localhost:3000 | |
| run: pnpm -r test | |
| # ── E2E: Playwright, the 3 critical learner paths (#781) ──────────────────── | |
| # Runs the learner-path specs against a PRODUCTION build (`next build && | |
| # next start`) fronted by a mock Supabase — the runtime coverage the gate's | |
| # manual smokes used to give, now permanent. Change-gated on `frontend` (same | |
| # as the frontend job) so it does NOT run on every push of every PR — only when | |
| # apps/web (or the shared deps that reach it) change, or on manual dispatch. | |
| # See apps/web/e2e/README.md for the harness + mock-boundary map. | |
| e2e: | |
| name: E2E (Playwright · learner paths) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| timeout-minutes: 15 | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.frontend == 'true' || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - name: Setup Node | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Cache ONLY the webpack BUILD cache so `next build` inside the harness | |
| # stays incremental (the cold build is the bulk of the E2E wall-clock; the | |
| # specs are seconds). Deliberately NOT `.next/cache` wholesale: that would | |
| # restore `.next/cache/fetch-cache` — the `unstable_cache` DATA cache — and | |
| # a restored stale deployment snapshot would let the catalog spec no-op on | |
| # last-run's data (the gate's finding). The harness also purges fetch-cache | |
| # before `next start`; this narrower path is the belt to that suspenders. | |
| - name: Cache Next build (webpack only, never fetch-cache) | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: apps/web/.next/cache/webpack | |
| key: nextcache-e2e-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/src/**') }} | |
| restore-keys: | | |
| nextcache-e2e-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- | |
| nextcache-e2e-${{ runner.os }}- | |
| # Pinned via package.json (@playwright/test 1.59.1); --with-deps pulls the | |
| # OS libs the browser needs on the runner. openssl (for the mock cert) is | |
| # pre-installed on ubuntu-latest. | |
| - name: Install Playwright browser | |
| run: pnpm --filter web exec playwright install --with-deps chromium | |
| - name: Run E2E (build + start + specs) | |
| # The harness (apps/web/e2e/harness/serve.mjs) generates the cert, starts | |
| # the mock, runs `next build`, then `next start`. Single source of the | |
| # E2E env, so build-time NEXT_PUBLIC_* and runtime agree. | |
| run: pnpm --filter web e2e | |
| - name: Upload Playwright report | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: playwright-report | |
| path: | | |
| apps/web/playwright-report | |
| apps/web/test-results | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| # ── On-chain program: fmt, clippy, Rust unit tests ────────────────────────── | |
| onchain-rust: | |
| name: On-chain (fmt · clippy · cargo test) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.onchain == 'true' || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable channel, resolved 2026-06-30 | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: | | |
| onchain-academy | |
| - name: Format check (program workspace) | |
| run: cargo fmt --manifest-path onchain-academy/Cargo.toml --all --check | |
| - name: Clippy (program workspace) | |
| # Enforced: the program crate is clippy-clean under -D warnings as of | |
| # P2-3 (added the unexpected_cfgs + ambiguous_glob_reexports allows). | |
| # Kept in lockstep with the pre-commit hook (.husky/pre-commit). | |
| run: cargo clippy --manifest-path onchain-academy/Cargo.toml --all-targets -- -D warnings | |
| # Host-side pinocchio tests: state layouts, discriminators, error | |
| # codes/log lines, and hand-rolled CPI wire bytes are asserted against | |
| # real spl/mpl builders. | |
| - name: Pinocchio host tests (layouts · discriminators · CPI bytes) | |
| run: cargo test --manifest-path onchain-academy/Cargo.toml -p onchain-academy-pinocchio | |
| # Same tests under the `fresh-id` flavor: proves the self-owned deploy | |
| # id / CONFIG_PDA / CONFIG_BUMP triple is internally consistent and the | |
| # flavor differs only in identity (the deploy artifact is built with it). | |
| - name: Pinocchio host tests (fresh-id flavor) | |
| run: cargo test --manifest-path onchain-academy/Cargo.toml -p onchain-academy-pinocchio --features fresh-id | |
| # ── Build server (apps/build-server): fmt, clippy, tests ──────────────────── | |
| build-server: | |
| name: Build server (fmt · clippy · cargo test) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.buildserver == 'true' || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable channel, resolved 2026-06-30 | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: apps/build-server | |
| - name: Format check | |
| run: cargo fmt --manifest-path apps/build-server/Cargo.toml --all --check | |
| - name: Clippy | |
| # Enforced: build-server is clippy-clean under -D warnings. Kept in | |
| # lockstep with the pre-commit hook (.husky/pre-commit). | |
| run: cargo clippy --manifest-path apps/build-server/Cargo.toml --all-targets -- -D warnings | |
| - name: Tests | |
| run: cargo test --manifest-path apps/build-server/Cargo.toml | |
| # ── Build-server image: full Docker bake (#614) ───────────────────────────── | |
| # Proves what fmt/clippy/test cannot: the host toolchain install, the Agave | |
| # install, the `cargo-build-sbf --tools-version v1.54` pre-cache of the | |
| # anchor template in programs/ (an independent crate no other job compiles), | |
| # and the setuid-strip verification — all baked into the Dockerfile. Build | |
| # only: no push, no registry, no credentials. The deep runtime check (POST a | |
| # real submission to /build) stays an enable-time gate per #610; the smoke | |
| # here just boots the image under the production runtime contract | |
| # (read-only rootfs, tmpfs /tmp) and hits the public /health. | |
| build-server-image: | |
| name: Build server (docker build · smoke) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| timeout-minutes: 30 | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.buildserver == 'true' || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Needed for the GHA layer cache below (the default docker driver cannot | |
| # export type=gha). | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Build image | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: apps/build-server | |
| push: false | |
| load: true # export into the local daemon for the smoke step | |
| tags: academy-build-server:ci | |
| # Default mode=min caches only final-image layers — which includes | |
| # every expensive one (apt, rustup, Agave, platform-tools bake) while | |
| # keeping the GHA cache footprint bounded. The stage-1 server build | |
| # re-runs cold, but a src/ change would bust it anyway. | |
| cache-from: type=gha,scope=build-server-image | |
| cache-to: type=gha,scope=build-server-image | |
| # Mirrors the enable-time gate command from #614/#610 minus the /build | |
| # POST: boot with the hardened runtime flags and require /health to answer. | |
| - name: Smoke test (read-only rootfs · /health) | |
| run: | | |
| docker run -d --name bs-smoke --read-only --tmpfs /tmp:exec \ | |
| -e BUILDS_DIR=/tmp/academy-builds -e ACADEMY_API_KEY=ci-smoke \ | |
| -p 8080:8080 academy-build-server:ci | |
| ok="" | |
| for _ in $(seq 1 30); do | |
| if curl -sf http://127.0.0.1:8080/health; then ok=1; break; fi | |
| sleep 1 | |
| done | |
| echo | |
| docker logs bs-smoke | |
| docker rm -f bs-smoke >/dev/null | |
| if [ -z "$ok" ]; then | |
| echo "::error::/health did not respond within 30s under --read-only rootfs" | |
| exit 1 | |
| fi | |
| # ── Dependency audit ──────────────────────────────────────────────────────── | |
| # Non-blocking for now: the pinned Solana 2.0.x tree carries known transitive | |
| # advisories. Triage them, then flip continue-on-error to false to make this a | |
| # hard gate (tracked under the P0-C3 / G-2 hardening work). | |
| audit: | |
| name: Dependency audit | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.frontend == 'true' || | |
| needs.changes.outputs.onchain == 'true' || | |
| needs.changes.outputs.buildserver == 'true' || | |
| github.event_name == 'workflow_dispatch') | |
| # RATCHET: step-level continue-on-error keeps this check GREEN (with warnings) | |
| # while advisories are triaged. Job-level continue-on-error does NOT — the check | |
| # still reports red. Remove the per-step flags to make audit a hard gate (G-2). | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - name: Setup Node | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: pnpm audit | |
| continue-on-error: true | |
| run: pnpm audit --audit-level=high | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable channel, resolved 2026-06-30 | |
| # Prebuilt binary (taiki-e/install-action) instead of `cargo install | |
| # cargo-audit` from source — saves the ~1-2 min compile every run. | |
| - name: Install cargo-audit | |
| uses: taiki-e/install-action@288e746965032cfcc232e09af2daf5f23c14d780 # v2.86.1 | |
| with: | |
| tool: cargo-audit | |
| - name: cargo audit (program) | |
| continue-on-error: true | |
| run: cargo audit --file onchain-academy/Cargo.lock | |
| - name: cargo audit (build server) | |
| continue-on-error: true | |
| run: cargo audit --file apps/build-server/Cargo.lock | |
| # ── Integration: pinocchio program on LiteSVM (Gate 2) ────────────────────── | |
| # Replays all 18 instructions (happy + error paths) against the pinocchio | |
| # .so in a single in-process LiteSVM and asserts results, normalized logs | |
| # (byte-identical events), and account bytes. Builds BOTH the default and | |
| # fresh-id artifacts so the fresh-id init smoke actually runs. | |
| integration: | |
| name: Integration (pinocchio · LiteSVM) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| github.event.action != 'labeled' && | |
| (needs.changes.outputs.onchain == 'true' || github.event_name == 'workflow_dispatch') | |
| env: | |
| # Matches the local dev toolchain; cargo-build-sbf fetches the pinned | |
| # platform-tools (v1.54 = rustc 1.89) the pinocchio crate requires. | |
| SOLANA_VERSION: "3.1.10" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable channel, resolved 2026-06-30 | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: | | |
| onchain-academy | |
| onchain-academy/tests/differential | |
| # Cache the Solana CLI install + the SBF platform-tools (v1.54, hundreds | |
| # of MB) so cargo-build-sbf doesn't re-download them every run. | |
| - name: Cache Solana CLI + platform-tools | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: | | |
| ~/.local/share/solana/install | |
| ~/.cache/solana | |
| key: solana-${{ runner.os }}-${{ env.SOLANA_VERSION }}-tools-v1.54 | |
| - name: Install Solana CLI | |
| run: | | |
| if [ ! -x "$HOME/.local/share/solana/install/active_release/bin/solana" ]; then | |
| # Download then run so a bad URL (e.g. a nonexistent version) fails | |
| # the step loudly — `sh -c "$(curl ...)"` swallows curl's exit code. | |
| curl -sSfL "https://release.anza.xyz/v${SOLANA_VERSION}/install" -o /tmp/solana-install.sh | |
| sh /tmp/solana-install.sh | |
| fi | |
| BIN="$HOME/.local/share/solana/install/active_release/bin" | |
| echo "$BIN" >> "$GITHUB_PATH" | |
| # Sanity-check the toolchain is actually present (explicit path — the | |
| # GITHUB_PATH addition only applies to later steps). | |
| "$BIN/cargo-build-sbf" --version | |
| # Lint the integration crate (a separate workspace not covered by | |
| # onchain-rust's program-workspace clippy). | |
| - name: Format check (integration crate) | |
| run: cargo fmt --manifest-path onchain-academy/tests/differential/Cargo.toml --all --check | |
| - name: Clippy (integration crate) | |
| run: cargo clippy --manifest-path onchain-academy/tests/differential/Cargo.toml --all-targets -- -D warnings | |
| - name: Build pinocchio program (default + fresh-id artifacts) | |
| working-directory: onchain-academy | |
| run: | | |
| M=programs/onchain-academy-pinocchio/Cargo.toml | |
| # fresh-id first, copy it aside, then restore the default artifact in | |
| # the shared slot (the differential suite loads the default .so; the | |
| # fresh_id_smoke test loads the _fresh.so). | |
| cargo build-sbf --manifest-path "$M" --tools-version v1.54 --features fresh-id | |
| cp target/deploy/onchain_academy_pinocchio.so target/deploy/onchain_academy_pinocchio_fresh.so | |
| cargo build-sbf --manifest-path "$M" --tools-version v1.54 | |
| - name: Integration scenarios | |
| run: cargo test --manifest-path onchain-academy/tests/differential/Cargo.toml | |
| # ── Verifiable build + program-hash publishing (P1-4, feeds G-3) ──────────── | |
| # Produces toolchain-reproducible bytecode via `cargo build-sbf` with a pinned | |
| # platform-tools version + the committed Cargo.lock, and publishes the SHA-256 | |
| # of the resulting `.so` so the deployed program can be independently verified: | |
| # solana program dump <PROGRAM_ID> onchain.so && sha256sum onchain.so | |
| # must match the hash printed below. The build is the source of the guarantee; | |
| # the hash is the comparable artifact. | |
| # | |
| # Heavy (cold Rust build), so it is NOT run on every push. Triggers: push to | |
| # main, manual dispatch, or a PR carrying the `verifiable-build` label. | |
| verifiable-build: | |
| name: Verifiable build (program hash) | |
| runs-on: ubuntu-latest | |
| # No job-level `permissions:` here on purpose. This job runs PR-controlled | |
| # build code — `cargo build-sbf` executes the crate's build.rs / proc-macros | |
| # — on same-repo PRs carrying the `verifiable-build` label. It therefore | |
| # inherits the workflow's read-only `contents: read` and NEVER holds a | |
| # write-capable token in a job that runs untrusted code. The durable publish | |
| # (which needs write) is a SEPARATE job, `publish-hash`, that runs only on | |
| # trusted `main` and never checks out PR code. See #140 / G-3. | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| (github.event_name == 'pull_request' && | |
| contains(github.event.pull_request.labels.*.name, 'verifiable-build')) | |
| env: | |
| # Agave 3.x: matches local dev; required for the pinocchio build's | |
| # pinned platform-tools (v1.54 = rustc 1.89). | |
| SOLANA_VERSION: "3.1.10" | |
| outputs: | |
| # Non-secret record fields consumed by the `publish-hash` job — plain | |
| # strings (hash, byte size, toolchain versions), no artifact plumbing. | |
| sha256: ${{ steps.hash.outputs.sha256 }} | |
| size: ${{ steps.hash.outputs.size }} | |
| solana: ${{ steps.hash.outputs.solana }} | |
| platform_tools: ${{ steps.hash.outputs.platform_tools }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable channel, resolved 2026-06-30 | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: onchain-academy | |
| - name: Cache Solana CLI + platform-tools | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: | | |
| ~/.local/share/solana/install | |
| ~/.cache/solana | |
| key: solana-${{ runner.os }}-${{ env.SOLANA_VERSION }}-tools-v1.54 | |
| - name: Install Solana CLI | |
| run: | | |
| if [ ! -x "$HOME/.local/share/solana/install/active_release/bin/solana" ]; then | |
| curl -sSfL "https://release.anza.xyz/v${SOLANA_VERSION}/install" -o /tmp/solana-install.sh | |
| sh /tmp/solana-install.sh | |
| fi | |
| BIN="$HOME/.local/share/solana/install/active_release/bin" | |
| echo "$BIN" >> "$GITHUB_PATH" | |
| "$BIN/cargo-build-sbf" --version | |
| # Pinocchio artifact hash: cargo-build-sbf with a PINNED platform-tools | |
| # version (v1.54) + the committed Cargo.lock is toolchain-reproducible. | |
| # (A Docker-deterministic solana-verify pipeline can replace this once a | |
| # base image with rustc >= 1.89 is pinned.) | |
| - name: Verifiable build (pinocchio) | |
| run: cargo build-sbf --manifest-path onchain-academy/programs/onchain-academy-pinocchio/Cargo.toml --tools-version v1.54 | |
| - name: Compute program hash | |
| id: hash | |
| working-directory: onchain-academy | |
| run: | | |
| SO_PATH="target/deploy/onchain_academy_pinocchio.so" | |
| if [ ! -f "$SO_PATH" ]; then | |
| echo "::error::Expected program artifact not found at $SO_PATH" | |
| ls -la target/deploy || true | |
| exit 1 | |
| fi | |
| SHA="$(sha256sum "$SO_PATH" | awk '{print $1}')" | |
| SIZE="$(wc -c < "$SO_PATH" | tr -d ' ')" | |
| # Expose the record fields as job outputs for the `publish-hash` job. | |
| { | |
| echo "sha256=$SHA" | |
| echo "size=$SIZE" | |
| echo "solana=$SOLANA_VERSION" | |
| echo "platform_tools=v1.54" | |
| } >> "$GITHUB_OUTPUT" | |
| # Plain artifact for downstream comparison / scripting. | |
| printf '%s %s\n' "$SHA" "onchain_academy_pinocchio.so" > program-hash.txt | |
| { | |
| echo "## Verifiable build — program hash" | |
| echo "" | |
| echo "**Toolchain-reproducible build** via \`cargo build-sbf\` with pinned platform-tools + committed Cargo.lock." | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| echo "| Artifact | \`onchain_academy_pinocchio.so\` |" | |
| echo "| Size (bytes) | \`$SIZE\` |" | |
| echo "| SHA-256 | \`$SHA\` |" | |
| echo "| Solana | \`$SOLANA_VERSION\` |" | |
| echo "| platform-tools | \`v1.54\` |" | |
| echo "" | |
| echo "Verify against the deployed program:" | |
| echo '```bash' | |
| echo "solana program dump <PROGRAM_ID> onchain.so" | |
| echo "sha256sum onchain.so # must equal the SHA-256 above" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: verifiable-build | |
| path: | | |
| onchain-academy/target/deploy/onchain_academy_pinocchio.so | |
| onchain-academy/idl/*.json | |
| onchain-academy/program-hash.txt | |
| if-no-files-found: error | |
| # ── Durable hash record (feeds #140 / G-3) ────────────────────────────────── | |
| # SEPARATE job from verifiable-build so the write-capable token is never present | |
| # while PR-controlled build code runs. verifiable-build (which runs cargo | |
| # build-sbf, i.e. PR build.rs / proc-macros on labeled same-repo PRs) stays | |
| # read-only; ONLY this job holds `contents: write`, it runs ONLY on trusted | |
| # `main`, and it never checks out PR code. It renders the record purely from | |
| # verifiable-build's non-secret string outputs plus git facts re-derived from | |
| # its OWN clean `main` checkout. | |
| # | |
| # The upload-artifact in verifiable-build is EPHEMERAL; this makes the hash | |
| # durable by publishing it to the CI-owned `program-hash` branch (see | |
| # docs/PROGRAM-HASH.md for why a branch, not a commit to protected `main`). | |
| # | |
| # Loop / spam safety (verifiable-build runs on EVERY main push, path-filter free): | |
| # 1. Idempotence (primary): publish only when the hash CHANGED — a doc-only or | |
| # any hash-unchanged main push short-circuits with no commit. | |
| # 2. Structural: the push targets `program-hash` (excluded from `on.push`, | |
| # never `main`), so it cannot re-trigger this main-gated job. | |
| # 3. GITHUB_TOKEN pushes never trigger workflow runs (GitHub recursion guard; | |
| # cf. claude-code-review.yml). | |
| # 4. `[skip ci]` in the bot commit message — a belt for a future PAT swap. | |
| publish-hash: | |
| name: Publish program hash | |
| needs: verifiable-build | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| env: | |
| NEW_SHA: ${{ needs.verifiable-build.outputs.sha256 }} | |
| SIZE: ${{ needs.verifiable-build.outputs.size }} | |
| SOLANA_VERSION: ${{ needs.verifiable-build.outputs.solana }} | |
| PLATFORM_TOOLS: ${{ needs.verifiable-build.outputs.platform_tools }} | |
| PUBLISH_BRANCH: program-hash | |
| RECORD_PATH: docs/PROGRAM-HASH.md | |
| PROGRAM_ID: 7NeJaSRyb4Wxay3Tcd9bdpD7T3GWYUQSFyrhG8SgwE8V | |
| steps: | |
| # Clean checkout of the TRUSTED main ref (the push's own commit) — never PR | |
| # code. persist-credentials (default true) supplies the push credential. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Publish record to the program-hash branch | |
| run: | | |
| set -euo pipefail | |
| # --- Idempotence guard: compare against the currently published hash --- | |
| OLD_SHA="" | |
| if git ls-remote --exit-code --heads origin "$PUBLISH_BRANCH" >/dev/null 2>&1; then | |
| git fetch --no-tags --depth=1 origin \ | |
| "+refs/heads/${PUBLISH_BRANCH}:refs/remotes/origin/${PUBLISH_BRANCH}" | |
| OLD_SHA="$(git show "refs/remotes/origin/${PUBLISH_BRANCH}:${RECORD_PATH}" 2>/dev/null \ | |
| | grep -oiE '^[0-9a-f]{64}' | head -1 || true)" | |
| fi | |
| if [ -n "$OLD_SHA" ] && [ "$OLD_SHA" = "$NEW_SHA" ]; then | |
| echo "Program hash unchanged ($NEW_SHA); nothing to publish." | |
| exit 0 | |
| fi | |
| # Git facts re-derived from THIS job's clean main checkout (HEAD == the | |
| # pushed main commit) — nothing sourced from PR-controlled state. | |
| SRC_COMMIT="$(git rev-parse HEAD)" | |
| BUILD_DATE="$(git show -s --format=%cI HEAD)" # committer date, not wall clock | |
| # --- Render the record (echo-group style, matching the hash step) --- | |
| RECORD="$(mktemp)" | |
| { | |
| echo "<!--" | |
| echo " CI-MAINTAINED — DO NOT EDIT BY HAND. Published by .github/workflows/ci.yml" | |
| echo " (publish-hash job) on every push to main whose program hash changed." | |
| echo " The human-readable spec + reproduction guide lives on main at" | |
| echo " docs/PROGRAM-HASH.md." | |
| echo "-->" | |
| echo "" | |
| echo "# Verifiable build — program hash (feeds #140 / G-3)" | |
| echo "" | |
| echo "SHA-256 of the toolchain-reproducible \`onchain_academy_pinocchio.so\`," | |
| echo "published by CI so the deployed bytes can be independently verified." | |
| echo "" | |
| echo '```' | |
| echo "${NEW_SHA} onchain_academy_pinocchio.so" | |
| echo '```' | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| echo "| Artifact | \`onchain_academy_pinocchio.so\` |" | |
| echo "| Size (bytes) | \`${SIZE}\` |" | |
| echo "| SHA-256 | \`${NEW_SHA}\` |" | |
| echo "| Program ID | \`${PROGRAM_ID}\` |" | |
| echo "| Solana | \`${SOLANA_VERSION}\` |" | |
| echo "| platform-tools | \`${PLATFORM_TOOLS}\` |" | |
| echo "| Source commit | \`${SRC_COMMIT}\` |" | |
| echo "| Build date | \`${BUILD_DATE}\` |" | |
| echo "" | |
| echo "## Reproduce" | |
| echo "" | |
| echo '```bash' | |
| echo "cargo build-sbf --manifest-path onchain-academy/programs/onchain-academy-pinocchio/Cargo.toml --tools-version ${PLATFORM_TOOLS}" | |
| echo "sha256sum onchain-academy/target/deploy/onchain_academy_pinocchio.so" | |
| echo '```' | |
| echo "" | |
| echo "## Verify against the deployed program" | |
| echo "" | |
| echo '```bash' | |
| echo "solana program dump ${PROGRAM_ID} onchain.so" | |
| echo "sha256sum onchain.so # must equal the SHA-256 above" | |
| echo '```' | |
| } > "$RECORD" | |
| # --- Commit + push in an isolated worktree (never touches the checkout) --- | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| WT="$(mktemp -d)" | |
| cleanup() { | |
| git worktree remove --force "$WT" >/dev/null 2>&1 || true | |
| rm -rf "$WT" >/dev/null 2>&1 || true | |
| } | |
| trap cleanup EXIT | |
| if [ -n "$OLD_SHA" ] || git ls-remote --exit-code --heads origin "$PUBLISH_BRANCH" >/dev/null 2>&1; then | |
| git worktree add "$WT" "refs/remotes/origin/${PUBLISH_BRANCH}" | |
| git -C "$WT" checkout -B "$PUBLISH_BRANCH" | |
| else | |
| # First ever run: start the branch as an orphan (no repo history baggage). | |
| git worktree add --detach "$WT" | |
| git -C "$WT" checkout --orphan "$PUBLISH_BRANCH" | |
| git -C "$WT" rm -rf --quiet . 2>/dev/null || true | |
| fi | |
| install -D -m 0644 "$RECORD" "$WT/${RECORD_PATH}" | |
| git -C "$WT" add "$RECORD_PATH" | |
| if git -C "$WT" diff --cached --quiet; then | |
| echo "docs/PROGRAM-HASH.md unchanged; skipping commit." | |
| else | |
| git -C "$WT" commit -q -m "chore(ci): record program hash ${NEW_SHA} [skip ci]" | |
| git -C "$WT" push origin "HEAD:refs/heads/${PUBLISH_BRANCH}" | |
| echo "Published program hash ${NEW_SHA} to ${PUBLISH_BRANCH}." | |
| fi |