diff --git a/.github/workflows/staging-check.yml b/.github/workflows/staging-check.yml index 1dadaa79..49bb27ca 100644 --- a/.github/workflows/staging-check.yml +++ b/.github/workflows/staging-check.yml @@ -6,15 +6,28 @@ name: staging-check # # pull_request_target so fork pull requests can receive a status. This job never checks out # or runs pull request code; it only reads the list of changed paths. +# +# Run by hand, a maintainer (PROD_DEPLOYERS) marks a commit that was tested on staging another +# way, such as an infra/ change deployed with its own deploy.sh. The reason is recorded on the +# status. Statuses set from a personal account do not count: main requires this app's status. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + sha: + description: Full SHA of the pull request's head commit + required: true + reason: + description: How it was tested on staging + required: true permissions: contents: read jobs: staging: + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -30,7 +43,7 @@ jobs: set -euo pipefail [[ "${NUMBER}" =~ ^[0-9]+$ && "${SHA}" =~ ^[0-9a-f]{40}$ ]] # Paths that never reach the running service. - exempt='^(docs/|tests/|workflow_evals/|\.github/(pull_request_template\.md|review-replies\.md|CODEOWNERS)$|[^/]+\.md$|LICENSE$)' + exempt='^(docs/|tests/|workflow_evals/|\.github/(pull_request_template\.md|review-replies\.md|CODEOWNERS)$|[^/]+\.md$|LICENSE$)|(^|/)tests/|\.test\.(js|cjs|mjs)$' service="$(gh api --paginate "repos/${REPO}/pulls/${NUMBER}/files" --jq '.[].filename' | grep -vE "${exempt}" || true)" if [[ -z "${service}" ]]; then @@ -45,3 +58,28 @@ jobs: -f context=staging \ -f description="Deploy this commit to staging and test it (deploy-staging)" fi + + attest: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write + steps: + - env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ inputs.sha }} + REASON: ${{ inputs.reason }} + TRIGGERED_BY: ${{ github.triggering_actor }} + PROD_DEPLOYERS: ${{ vars.PROD_DEPLOYERS }} + run: | + set -euo pipefail + if [[ ",${PROD_DEPLOYERS}," != *",${TRIGGERED_BY},"* ]]; then + echo "${TRIGGERED_BY} is not in PROD_DEPLOYERS; only maintainers mark staging by hand" >&2 + exit 1 + fi + [[ "${SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "a full commit SHA is required" >&2; exit 1; } + description="${TRIGGERED_BY}: ${REASON}" + gh api --method POST "repos/${REPO}/statuses/${SHA}" -f state=success \ + -f context=staging -f description="${description:0:140}" diff --git a/browser-extension/tests/helpers/extension-script-harness.cjs b/browser-extension/tests/helpers/extension-script-harness.cjs index be7a1b78..7073729b 100644 --- a/browser-extension/tests/helpers/extension-script-harness.cjs +++ b/browser-extension/tests/helpers/extension-script-harness.cjs @@ -35,12 +35,16 @@ async function settle(turns = 6) { } } -async function eventually(predicate, message = "condition was not met") { - for (let attempt = 0; attempt < 100; attempt += 1) { +// Waits by time, not by event-loop turns: real async work (hashing, timers) can take more +// than a fixed number of turns on a loaded CI runner. +async function eventually(predicate, message = "condition was not met", timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + for (;;) { if (await predicate()) return; + if (Date.now() > deadline) throw new Error(message); await settle(1); + await new Promise((resolve) => setTimeout(resolve, 1)); } - throw new Error(message); } function defaultCookies() { diff --git a/docs/staging.md b/docs/staging.md index c0840b98..8a6d66c0 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -18,7 +18,8 @@ Merging to `main` deploys to production, so staging is where a change is tested A change to the deploy scripts themselves (`infra/`) cannot be tried this way, because deploy-staging always runs `main`'s scripts. Run the branch's `infra/deploy.sh` with `TIN_LITE_DEPLOY_TARGET=staging` from a workstation, and a maintainer sets the `staging` -status by hand. +status by running **staging-check** by hand with the commit and how it was tested. A +status set from a personal account does not count. Deploying marks that exact commit with the `staging` status that `main` requires. A new push resets it. Changes that cannot reach the running service (docs, tests, evals, diff --git a/web/lock-page.browser.test.js b/web/lock-page.browser.test.js index 086f8f11..78ec8649 100644 --- a/web/lock-page.browser.test.js +++ b/web/lock-page.browser.test.js @@ -15,7 +15,7 @@ const WAITING = {agent: {connected_at: null, client: null}, setup: {run_id: null async function serve({ lockEnabled, projectWorkflows, runs = [], connections = false, agentSetup = null }) { const project = {id: "project-1", name: "QA’s project", workspace_id: "ws", workspace_name: "QA", member_count: 1, hidden: false}; // The agent's progress the lock page polls; a test moves it along by replacing `.value`. - const setup = {value: agentSetup}; + const setup = {value: agentSetup, polls: 0}; const business = {...project, id: "project-acme", name: "Acme"}; const businessRun = {id: "run-acme", workflow_id: "wf-onboarding", workflow: "growth.onboarding", status: "needs_input", created_at: "2026-10-11T21:42:00Z"}; const integrations = connections ? [ @@ -60,7 +60,7 @@ async function serve({ lockEnabled, projectWorkflows, runs = [], connections = f } const planReady = Boolean(setup.value?.setup?.plan_ready); if (url.pathname === "/api/projects") return send(connections ? [project, {...project, id: "project-2", name: "Second project"}] : planReady ? [project, business] : [project]); - if (url.pathname.endsWith("/agent-setup")) return send(setup.value || WAITING); + if (url.pathname.endsWith("/agent-setup")) { setup.polls += 1; return send(setup.value || WAITING); } if (/\/api\/projects\/[^/]+\/workflows$/.test(url.pathname)) return send(projectWorkflows); if (url.pathname === `/api/projects/${business.id}/runs`) return send([businessRun]); if (/\/api\/projects\/[^/]+\/runs$/.test(url.pathname)) return send(runs); @@ -156,6 +156,16 @@ async function stepTitles(page) { return page.locator(".lock-step-title > span").evaluateAll(items => items.map(item => item.lastChild.textContent)); } +// The page counts a step only when it watched it happen, so a test moves the agent along +// only after the page's first poll has seen the waiting state. +async function firstPollAnswered(setup) { + const deadline = Date.now() + 10000; + while (setup.polls === 0) { + if (Date.now() > deadline) throw new Error("the lock page never polled its agent setup"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + // Polls run every 5 s; coming back to the tab polls at once, as after the terminal. async function pollNow(page) { await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); @@ -172,9 +182,11 @@ test("lock page: the steps tick as the agent connects, sets up, and the plan ope try { for (const theme of shots ? ["light", "dark"] : ["light"]) { setup.value = null; + setup.polls = 0; writes.length = 0; const {page, context, errors} = await open(browser, base, {theme}); await page.locator(".lock-step").first().waitFor(); + await firstPollAnswered(setup); if (shots) await page.screenshot({path: path.join(shots, `lock-a-${theme}.png`)}); // B: the agent's first signed-in project call. The install line folds away.