Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/staging-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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}"
10 changes: 7 additions & 3 deletions browser-extension/tests/helpers/extension-script-harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion docs/staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions web/lock-page.browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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")));
Expand All @@ -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.
Expand Down
Loading