diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..35f7e00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22] + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build dashboard + run: npm run build:dashboard + + - name: Verify + run: npm run verify + + - name: Verify package payload + run: npm run verify:package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..585544d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,299 @@ +name: Publish + +# Manual-only. Every publish and tag is bound to the version-bump commit, even +# when a rerun happens after main has advanced. +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Refuse to publish from a non-main ref + run: | + set -euo pipefail + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Publish must run from main; got ${GITHUB_REF}." + exit 1 + fi + + - name: Refresh current main + run: | + set -euo pipefail + git fetch origin main + git checkout --detach origin/main + + - name: Determine release action + id: release-state + run: | + set -euo pipefail + current="$(node -p "require('./package.json').version")" + tag="v${current}" + npm_error="$(mktemp)" + if npm_version="$(npm view "burnlist@${current}" version 2>"$npm_error")"; then + if [[ "$npm_version" != "$current" ]]; then + echo "npm returned version '$npm_version', expected '$current'." + exit 1 + fi + npm_state=present + elif grep -Eq '(^|[^[:alnum:]])E404([^[:alnum:]]|$)' "$npm_error"; then + npm_state=missing + else + echo "npm view failed while checking burnlist@${current}:" + cat "$npm_error" + exit 1 + fi + rm -f "$npm_error" + + if [[ "$npm_state" == "missing" ]]; then + action=publish + else + git_error="$(mktemp)" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >"$git_error" 2>&1; then + action=verify-tag + else + status=$? + if [[ "$status" -eq 2 ]]; then + action=tag + else + echo "git ls-remote failed while checking ${tag}:" + cat "$git_error" + exit 1 + fi + fi + rm -f "$git_error" + fi + echo "current=${current}" >> "$GITHUB_OUTPUT" + echo "action=${action}" >> "$GITHUB_OUTPUT" + echo "Release action: ${action} (${tag})" + + - name: Verify completed release tag + if: steps.release-state.outputs.action == 'verify-tag' + env: + VERSION: v${{ steps.release-state.outputs.current }} + run: | + set -euo pipefail + npm_error="$(mktemp)" + if ! artifact_commit="$(npm view "burnlist@${VERSION#v}" gitHead 2>"$npm_error")"; then + echo "npm view failed while reading ${VERSION} gitHead:" + cat "$npm_error" + exit 1 + fi + rm -f "$npm_error" + if [[ ! "$artifact_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "npm metadata for ${VERSION} does not contain a full gitHead SHA." + exit 1 + fi + git cat-file -e "${artifact_commit}^{commit}" + git merge-base --is-ancestor "$artifact_commit" origin/main + tag_refs="$(git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" "refs/tags/${VERSION}^{}")" + tag_target="$(awk -v ref="refs/tags/${VERSION}" '$2 == ref { direct = $1 } $2 == ref "^{}" { peeled = $1 } END { print peeled ? peeled : direct }' <<<"$tag_refs")" + if [[ ! "$tag_target" =~ ^[0-9a-f]{40}$ || "$tag_target" != "$artifact_commit" ]]; then + echo "Remote tag ${VERSION} does not point to npm artifact ${artifact_commit}." + exit 1 + fi + + - name: Bump version + id: bump + if: steps.release-state.outputs.action == 'verify-tag' + run: | + set -euo pipefail + next="$(npm version "${{ inputs.bump }}" --no-git-tag-version)" + echo "version=${next}" >> "$GITHUB_OUTPUT" + echo "Bumped to ${next}" + + - name: Set release version + id: release + env: + ACTION: ${{ steps.release-state.outputs.action }} + CURRENT_VERSION: ${{ steps.release-state.outputs.current }} + BUMPED_VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + version="v${CURRENT_VERSION}" + if [[ "$ACTION" == "verify-tag" ]]; then version="$BUMPED_VERSION"; fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Commit and push version bump + if: steps.release-state.outputs.action == 'verify-tag' + env: + NEXT_VERSION: ${{ steps.release.outputs.version }} + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + run: | + set -euo pipefail + git config user.name "$ACTOR" + git config user.email "${ACTOR_ID}+${ACTOR}@users.noreply.github.com" + git add package.json package-lock.json + git commit -m "chore(release): ${NEXT_VERSION}" + git push origin HEAD:main + + - name: Resolve and verify release commit + id: release-commit + env: + ACTION: ${{ steps.release-state.outputs.action }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + if [[ "$ACTION" == "verify-tag" ]]; then + release_commit="$(git rev-parse HEAD)" + elif [[ "$ACTION" == "tag" ]]; then + npm_error="$(mktemp)" + if ! release_commit="$(npm view "burnlist@${VERSION#v}" gitHead 2>"$npm_error")"; then + echo "npm view failed while reading ${VERSION} gitHead:" + cat "$npm_error" + exit 1 + fi + rm -f "$npm_error" + else + release_commit="$(git log -1 --format=%H --grep="^chore(release): ${VERSION}$" origin/main)" + if [[ -z "$release_commit" ]]; then + echo "Could not find the version-bump commit for ${VERSION} on origin/main." + exit 1 + fi + fi + if [[ ! "$release_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Release commit for ${VERSION} is not a full SHA." + exit 1 + fi + git cat-file -e "${release_commit}^{commit}" + git merge-base --is-ancestor "$release_commit" origin/main + commit_version="$(git show "${release_commit}:package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + if [[ "$commit_version" != "${VERSION#v}" ]]; then + echo "Release commit ${release_commit} does not contain version ${VERSION}." + exit 1 + fi + echo "sha=${release_commit}" >> "$GITHUB_OUTPUT" + + - name: Check out exact release commit + env: + RELEASE_COMMIT: ${{ steps.release-commit.outputs.sha }} + run: git checkout --detach "$RELEASE_COMMIT" + + # Install from the RELEASE commit's lockfile so gate + publish match the released tree. + - name: Install dependencies for the release commit + run: npm ci + + - name: Verify any existing remote tag + env: + VERSION: ${{ steps.release.outputs.version }} + RELEASE_COMMIT: ${{ steps.release-commit.outputs.sha }} + run: | + set -euo pipefail + git_error="$(mktemp)" + if tag_refs="$(git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" "refs/tags/${VERSION}^{}" 2>"$git_error")"; then + tag_target="$(awk -v ref="refs/tags/${VERSION}" '$2 == ref { direct = $1 } $2 == ref "^{}" { peeled = $1 } END { print peeled ? peeled : direct }' <<<"$tag_refs")" + if [[ ! "$tag_target" =~ ^[0-9a-f]{40}$ || "$tag_target" != "$RELEASE_COMMIT" ]]; then + echo "Remote tag ${VERSION} does not point to release commit ${RELEASE_COMMIT}." + exit 1 + fi + else + status=$? + if [[ "$status" -ne 2 ]]; then + echo "git ls-remote failed while checking ${VERSION}:" + cat "$git_error" + exit 1 + fi + fi + rm -f "$git_error" + + - name: Gate exact release commit + run: | + npm run build:dashboard + npm run verify + npm run verify:package + + - name: Publish to npm + if: steps.release-state.outputs.action != 'tag' + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Bind npm artifact to release commit + id: artifact + env: + VERSION: ${{ steps.release.outputs.version }} + RELEASE_COMMIT: ${{ steps.release-commit.outputs.sha }} + run: | + set -euo pipefail + npm_error="$(mktemp)" + if ! npm_version="$(npm view "burnlist@${VERSION#v}" version 2>"$npm_error")"; then + echo "npm view failed while confirming ${VERSION}:" + cat "$npm_error" + exit 1 + fi + if [[ "$npm_version" != "${VERSION#v}" ]]; then + echo "npm returned version '$npm_version', expected '${VERSION#v}'." + exit 1 + fi + if ! artifact_commit="$(npm view "burnlist@${VERSION#v}" gitHead 2>>"$npm_error")"; then + echo "npm view failed while reading ${VERSION} gitHead:" + cat "$npm_error" + exit 1 + fi + rm -f "$npm_error" + if [[ ! "$artifact_commit" =~ ^[0-9a-f]{40}$ || "$artifact_commit" != "$RELEASE_COMMIT" ]]; then + echo "npm artifact ${VERSION} is not bound to release commit ${RELEASE_COMMIT}." + exit 1 + fi + git cat-file -e "${artifact_commit}^{commit}" + git merge-base --is-ancestor "$artifact_commit" origin/main + echo "sha=${artifact_commit}" >> "$GITHUB_OUTPUT" + + - name: Tag published release + env: + NEXT_VERSION: ${{ steps.release.outputs.version }} + ARTIFACT_COMMIT: ${{ steps.artifact.outputs.sha }} + run: | + set -euo pipefail + git_error="$(mktemp)" + if tag_refs="$(git ls-remote --exit-code --tags origin "refs/tags/${NEXT_VERSION}" "refs/tags/${NEXT_VERSION}^{}" 2>"$git_error")"; then + tag_target="$(awk -v ref="refs/tags/${NEXT_VERSION}" '$2 == ref { direct = $1 } $2 == ref "^{}" { peeled = $1 } END { print peeled ? peeled : direct }' <<<"$tag_refs")" + if [[ ! "$tag_target" =~ ^[0-9a-f]{40}$ || "$tag_target" != "$ARTIFACT_COMMIT" ]]; then + echo "Remote tag ${NEXT_VERSION} does not point to npm artifact ${ARTIFACT_COMMIT}." + exit 1 + fi + echo "Tag ${NEXT_VERSION} already points to the npm artifact." + exit 0 + fi + status=$? + if [[ "$status" -ne 2 ]]; then + echo "git ls-remote failed while checking ${NEXT_VERSION}:" + cat "$git_error" + exit 1 + fi + rm -f "$git_error" + git tag "${NEXT_VERSION}" "${ARTIFACT_COMMIT}" + git push origin "${NEXT_VERSION}" diff --git a/README.md b/README.md index a2320d2..ed3336c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Burnlist ships with two default Ovens: - **Checklist** tracks the active work queue. - **Differential Testing** renders aligned reference and candidate series, optional aggregate telemetry, and optional exact-first evidence. -Custom Ovens use the same two-file package. An Oven cannot run commands, collect or transform project data, mutate project files, import arbitrary UI, or start an agent. See the [Oven contract](skills/burnlist/references/oven-contract.md) for the complete boundary. +Custom Ovens use the same two-file package and are scoped to the repository that owns their ignored local state; built-in Ovens are global. An Oven cannot run commands, collect or transform project data, mutate project files, import arbitrary UI, or start an agent. `--ovens-dir` overrides custom Oven storage only for the dashboard's launch repository, while other observed repositories continue to use their own `.local/burnlist/ovens/`. See the [Oven contract](skills/burnlist/references/oven-contract.md) for the complete boundary. ## Differential Testing diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 22da17c..1cbfc88 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -120,7 +120,7 @@ Usage: burnlist differential-testing validate-bundle burnlist differential-testing schema burnlist differential-testing sdk - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] @@ -137,7 +137,7 @@ Options: --auto-port Try the next available loopback port. --host Bind host; loopback is required by default. --state-dir Override ignored dashboard observer state. - --ovens-dir Override custom Oven storage. + --ovens-dir Override launch-repository custom Oven storage only. --runs-dir Override Run snapshot storage. --oven-data Bind one Oven to a read-only normalized JSON payload. --version, -v Print the installed Burnlist version. diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 06601f8..a068380 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -7,7 +7,7 @@ import type { Filter } from "@lib"; export function App() { const section = currentSection(); - const selected = useMemo(selectedBurnlist, [window.location.pathname]); + const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); const { projects, progress, error, loading } = useDashboardData({ section, selected }); diff --git a/dashboard/src/components/BurnOvens/BurnOvens.tsx b/dashboard/src/components/BurnOvens/BurnOvens.tsx index fee7510..4b100b6 100644 --- a/dashboard/src/components/BurnOvens/BurnOvens.tsx +++ b/dashboard/src/components/BurnOvens/BurnOvens.tsx @@ -25,6 +25,8 @@ import { X, } from "lucide-react"; import { Button } from "@layout"; +import { ovenActionUrl, ovenCatalogKey, ovenTargetRepoRoot } from "@lib/oven-identity.mjs"; +import type { OvenSummary, RepoSummary } from "@lib/types"; import { Card, CardContent, @@ -33,15 +35,6 @@ import { CardTitle, } from "@layout"; -type OvenSummary = { - id: string; - name: string; - description: string; - builtIn: boolean; -}; - -type RepoSummary = { name: string; root: string }; - type DetailSection = { id: string; title: string; @@ -573,14 +566,19 @@ export function NewOvenPage() { cells: [], }); const [writeToken, setWriteToken] = useState(""); + const [repos, setRepos] = useState([]); + const [repoKey, setRepoKey] = useState(""); const [status, setStatus] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { - fetch("/api/ovens") - .then((response) => response.json()) - .then((payload) => setWriteToken(payload.writeToken || "")) + Promise.all([fetch("/api/ovens").then((response) => response.json()), fetch("/api/repos").then((response) => response.json())]) + .then(([ovensPayload, reposPayload]) => { + setWriteToken(ovensPayload.writeToken || ""); + setRepos(reposPayload.repos || []); + setRepoKey(reposPayload.repos?.[0]?.repoKey || ""); + }) .catch(() => setError("Could not initialize Oven saving.")); }, []); @@ -626,7 +624,7 @@ export function NewOvenPage() { "content-type": "application/json", "x-burnlist-token": writeToken, }, - body: JSON.stringify({ id, name, instructions, detail }), + body: JSON.stringify({ id, name, instructions, detail, ...(repoKey ? { repoKey } : {}) }), }); const payload = await response.json(); if (!response.ok) { @@ -657,6 +655,12 @@ export function NewOvenPage() {
+