Skip to content

feat(content): activate the launch achievement ladder + quest reprici… #3056

feat(content): activate the launch achievement ladder + quest reprici…

feat(content): activate the launch achievement ladder + quest reprici… #3056

Workflow file for this run

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