Skip to content

Commit 175018a

Browse files
authored
Merge branch 'main' into fix/rerun-wait-timeout-stdout-pr
2 parents 1937605 + 2708a40 commit 175018a

83 files changed

Lines changed: 8000 additions & 699 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Check out all text files with LF on every platform. Tests compare bytes
2+
# from checked-out files (skill templates, snapshots); a CRLF working tree
3+
# (core.autocrlf on Windows) broke those comparisons.
4+
* text=auto eol=lf
5+
6+
*.png binary

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ agree on the approach before you invest time — see CONTRIBUTING.md.
1212

1313
## Related issue
1414

15-
<!-- e.g. "Fixes #123" or "Refs #123". Link an issue for non-trivial changes. -->
15+
<!-- e.g. "Closes #123". Required for features and behavior changes — open an
16+
issue first and get it assigned (comment `/assign` there); see
17+
CONTRIBUTING.md → Contribution model. Docs and small fixes don't need one. -->
1618

1719
## Type of change
1820

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# backport-dispatch.yml — OPTIONAL latency optimization for DEV-352's
2+
# auto-backport bot. Authored in atlas (release/public path), ships to the
3+
# PUBLIC repo via the snapshot per invariant I0 — this workflow only ever
4+
# runs on the public repo, never on atlas (see the repo guard below).
5+
#
6+
# NOT YET ARMED (2026-07-15): the atlas-side auto-backport.yml has a
7+
# scheduled SWEEP (every 30 min) that scans merged public PRs anonymously
8+
# and backports anything unabsorbed — the whole pipeline is fully
9+
# functional with ZERO public-repo credentials via that path alone. This
10+
# workflow instantly notifies atlas the moment a PR merges instead of
11+
# waiting for the next sweep (seconds vs. up to 30 minutes) — a nice-to-have,
12+
# not a requirement.
13+
#
14+
# Arming it means placing a GitHub App credential with contents:write on
15+
# the PRIVATE atlas repo into THIS PUBLIC repo's secrets — a real trust
16+
# decision (a compromised public-repo secret store would let an attacker
17+
# push to atlas) that is deliberately left to the operator, not decided by
18+
# this file. Until TESTSPRITE_HOB_APP_ID/TESTSPRITE_HOB_PRIVATE_KEY (or the
19+
# ALFHEIM_AGENT_* fallback) exist on the PUBLIC repo with contents:write on
20+
# atlas, this workflow no-ops cleanly (see the "not armed" step) — it does
21+
# NOT fail loud, so it stays quiet for every contributor watching the
22+
# Actions tab until the operator decides to enable it.
23+
#
24+
# SECURITY: this workflow reads ONLY event metadata (PR number, merge SHA,
25+
# base ref) — it never checks out the merged PR's code. That is what makes
26+
# `pull_request_target` safe here: the base-repo context (secrets, a
27+
# write-capable token) is required because a fork-originated merged PR gets
28+
# ZERO secrets under a plain `pull_request` trigger, even for the `closed`
29+
# activity type — but nothing in this job ever runs untrusted fork code, so
30+
# the classic pull_request_target RCE/secret-exfiltration risk (checking
31+
# out and executing the fork's own head ref under a privileged context)
32+
# does not apply. Do not add actions/checkout to this workflow.
33+
name: Notify atlas of a merged public PR
34+
35+
on:
36+
pull_request_target:
37+
types: [closed]
38+
39+
permissions:
40+
contents: read
41+
42+
jobs:
43+
notify:
44+
if: github.repository == 'TestSprite/testsprite-cli' && github.event.pull_request.merged == true
45+
runs-on: ubuntu-latest
46+
steps:
47+
- id: app-token
48+
continue-on-error: true
49+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
50+
with:
51+
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
52+
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
53+
owner: TestSprite
54+
repositories: testsprite-cli-atlas
55+
# Minimum required for POST /repos/{owner}/{repo}/dispatches (verified
56+
# against docs.github.com/en/rest/using-the-rest-api/permissions-required-for-github-apps —
57+
# the endpoint is gated on Contents:write, not Contents:read).
58+
permission-contents: write
59+
60+
- name: Dispatch to atlas
61+
if: steps.app-token.outputs.token != ''
62+
env:
63+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
64+
# These two fields are fork-influenced (merge_commit_sha/base.ref come
65+
# from a merged PR's event payload) — routed through env instead of
66+
# spliced into the run: string to avoid the GHA script-injection class
67+
# (a maliciously-crafted value could otherwise break out of the -F
68+
# argument and inject arbitrary shell). PR_NUMBER is left inline: it is
69+
# a GitHub-assigned integer, not attacker-authorable content.
70+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
71+
BASE_REF: ${{ github.event.pull_request.base.ref }}
72+
run: |
73+
set -euo pipefail
74+
gh api repos/TestSprite/testsprite-cli-atlas/dispatches \
75+
-f event_type=public-pr-merged \
76+
-F 'client_payload[pr_number]=${{ github.event.pull_request.number }}' \
77+
-F "client_payload[merge_commit_sha]=$MERGE_SHA" \
78+
-F "client_payload[base_ref]=$BASE_REF"
79+
80+
- name: Not armed yet — the atlas sweep will pick this up
81+
if: steps.app-token.outputs.token == ''
82+
run: |
83+
echo "::notice::No cross-repo credential configured on this repo yet — atlas's scheduled sweep (auto-backport.yml, every 30 min) will pick up this merge instead of an instant dispatch. See DEV-352 / DEV-348 / DEV-350 for the operator decision to arm this."

.github/workflows/ci-nudge.yml

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# "Fix this and we merge" nudge (InsForge `agent-zhang-beihai` pattern, part 3).
2+
# When a PR's CI finishes red, posts ONE sticky comment listing exactly which
3+
# jobs failed and the local one-liner that reproduces/fixes each (the automated
4+
# version of the hand-written "run `npm run format` and you're green" review
5+
# comments). The comment flips to a green confirmation once all checks pass.
6+
#
7+
# Runs in base-repo context via workflow_run — no PR code is checked out, so
8+
# fork PRs are safe. State is recomputed from check-runs each time, so the two
9+
# CI workflows (CI + Test Coverage) can complete in any order.
10+
#
11+
# Bot identity: same App-token-first / GITHUB_TOKEN-fallback pattern as
12+
# pr-triage.yml. The App has had the "Pull requests: Read & write" permission
13+
# since 2026-07-02 (corrected 2026-07-15; this comment previously said the
14+
# permission was still pending) — the fallback path stays as defense-in-depth
15+
# for whenever the App/secrets are absent.
16+
name: CI failure nudge
17+
18+
on:
19+
workflow_run:
20+
workflows: ['CI', 'Test Coverage']
21+
types: [completed]
22+
23+
permissions:
24+
checks: read # read the head SHA's check-run state
25+
pull-requests: write
26+
issues: write # PR comments ride the issues API
27+
28+
env:
29+
MARKER: '<!-- hob:ci-nudge -->'
30+
31+
jobs:
32+
nudge:
33+
# Public repo only; PR-triggered runs only (pushes to main have no PR to nudge).
34+
if: >-
35+
github.repository == 'TestSprite/testsprite-cli' &&
36+
github.event.workflow_run.event == 'pull_request'
37+
runs-on: ubuntu-latest
38+
steps:
39+
- id: app-token
40+
continue-on-error: true
41+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
42+
with:
43+
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
44+
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
45+
# The script below only ever calls the App client (`appClient`) for
46+
# rest.issues.updateComment/createComment — PR comments ride the
47+
# issues API (see the header comment above). All reads (checks,
48+
# pulls, commit->PR lookup) go through the ambient `github` client,
49+
# governed by this workflow's top-level `permissions:` block, not
50+
# this minted token. Scoping to checks:read/pull-requests:write here
51+
# (matching the job's top-level block literally) would risk the mint
52+
# itself failing if the App's installation doesn't hold Checks
53+
# permission — which would silently drop the App-token path
54+
# entirely (continue-on-error swallows the failure) and always fall
55+
# back to github-actions[bot], defeating this bot's own purpose.
56+
permission-issues: write
57+
58+
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
59+
env:
60+
APP_TOKEN: ${{ steps.app-token.outputs.token }}
61+
with:
62+
script: |
63+
const { owner, repo } = context.repo;
64+
const run = context.payload.workflow_run;
65+
const marker = process.env.MARKER;
66+
67+
const appClient = process.env.APP_TOKEN
68+
? new (github.constructor)({ auth: process.env.APP_TOKEN })
69+
: null;
70+
async function write(fn) {
71+
if (appClient) {
72+
try { return await fn(appClient.rest); }
73+
catch (e) { if (e.status !== 403 && e.status !== 404) throw e; }
74+
}
75+
return await fn(github.rest);
76+
}
77+
78+
// Resolve the PR for this run. `workflow_run.pull_requests` is empty
79+
// for fork PRs, so fall back to the commit→PRs lookup.
80+
let pr = (run.pull_requests || [])[0];
81+
if (!pr) {
82+
const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit(
83+
{ owner, repo, commit_sha: run.head_sha });
84+
pr = data.find(p => p.state === 'open');
85+
} else {
86+
pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
87+
}
88+
if (!pr || pr.state !== 'open' || pr.user.type === 'Bot') return;
89+
90+
// Job name → the local command that reproduces/fixes it. Also the
91+
// allowlist of CI jobs this nudge watches — keep in sync with ci.yml.
92+
const FIX = {
93+
'Lint & Format': 'run `npm run lint:fix && npm run format`, then commit',
94+
'Typecheck': 'run `npm run typecheck` and fix the reported type errors',
95+
'Unit Tests': 'run `npm test` and fix the failing tests',
96+
'Build': 'run `npm run build` and fix the compile errors',
97+
'Local E2E Tests': 'run `npm run test:e2e` (it builds first)',
98+
'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%',
99+
'Secret scan (gitleaks)': 'run `gitleaks detect --no-git --redact --source .` locally (config: .gitleaks.toml) and remove the secret — or, for a provable false positive, add a narrowly-anchored allowlist regex',
100+
};
101+
// Matrix jobs publish check runs as "Unit Tests (Node 20)" etc. —
102+
// exact lookup alone would silently skip them (and the nudge would
103+
// say "all green" while they are red). Try exact first, then the
104+
// name with a trailing parenthetical stripped (review round 2).
105+
const fixFor = (name) => FIX[name] ?? FIX[name.replace(/\s*\([^)]*\)$/, '')];
106+
107+
// Recompute full CI state from this SHA's check runs, narrowed to the
108+
// CI jobs above — other workflows (e.g. pr-triage) also attach
109+
// github-actions check runs to the PR head and must not count here.
110+
const checks = await github.paginate(github.rest.checks.listForRef,
111+
{ owner, repo, ref: run.head_sha, per_page: 100 });
112+
const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && fixFor(c.name));
113+
const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion));
114+
const pending = ours.filter(c => c.status !== 'completed');
115+
116+
const comments = await github.paginate(github.rest.issues.listComments,
117+
{ owner, repo, issue_number: pr.number, per_page: 100 });
118+
const sticky = comments.find(c => (c.body || '').includes(marker));
119+
120+
if (failing.length === 0) {
121+
// Only speak up on success if we previously flagged a failure, and
122+
// only once everything has actually finished.
123+
if (sticky && pending.length === 0 && !sticky.body.includes('all green')) {
124+
await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id,
125+
body: `${marker}\n✅ CI is **all green** now — thanks, @${pr.user.login}!` }));
126+
}
127+
return;
128+
}
129+
130+
const lines = failing
131+
.sort((a, b) => a.name.localeCompare(b.name))
132+
.map(c => {
133+
const fix = fixFor(c.name) || 'see the logs for details';
134+
return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`;
135+
});
136+
const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — `
137+
+ `here's what failed and how to reproduce it locally:\n\n${lines.join('\n')}\n\n`
138+
+ `Everything runs on Node 22 after \`npm ci\`. Push a fix and this comment `
139+
+ `flips green automatically once all checks pass.`;
140+
141+
if (sticky) {
142+
if (sticky.body !== body) {
143+
await write(rest => rest.issues.updateComment(
144+
{ owner, repo, comment_id: sticky.id, body }));
145+
}
146+
} else {
147+
await write(rest => rest.issues.createComment(
148+
{ owner, repo, issue_number: pr.number, body }));
149+
}

.github/workflows/ci.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
runs-on: ubuntu-latest
1818
steps:
1919
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
20+
with:
21+
persist-credentials: false
2022

2123
- name: Setup Node.js
2224
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
@@ -37,6 +39,8 @@ jobs:
3739
runs-on: ubuntu-latest
3840
steps:
3941
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
42+
with:
43+
persist-credentials: false
4044

4145
- name: Setup Node.js
4246
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
@@ -55,6 +59,8 @@ jobs:
5559
node-version: [20, 22]
5660
steps:
5761
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
62+
with:
63+
persist-credentials: false
5864

5965
- name: Setup Node.js
6066
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
@@ -68,6 +74,27 @@ jobs:
6874
env:
6975
CI: true
7076

77+
test-windows:
78+
name: Unit Tests (Windows)
79+
runs-on: windows-latest
80+
steps:
81+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
82+
with:
83+
persist-credentials: false
84+
85+
- name: Setup Node.js
86+
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
87+
with:
88+
node-version: 22
89+
cache: 'npm'
90+
91+
- run: npm ci
92+
- run: npm run build
93+
- run: npm run typecheck
94+
- run: npm test
95+
env:
96+
CI: true
97+
7198
build:
7299
name: Build (Node ${{ matrix.node-version }})
73100
runs-on: ubuntu-latest
@@ -76,6 +103,8 @@ jobs:
76103
node-version: [20, 22]
77104
steps:
78105
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
106+
with:
107+
persist-credentials: false
79108

80109
- name: Setup Node.js
81110
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
@@ -96,6 +125,8 @@ jobs:
96125
runs-on: ubuntu-latest
97126
steps:
98127
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
128+
with:
129+
persist-credentials: false
99130

100131
- name: Setup Node.js
101132
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
@@ -107,3 +138,36 @@ jobs:
107138
- run: npm run test:e2e
108139
env:
109140
CI: true
141+
142+
gitleaks:
143+
name: Secret scan (gitleaks)
144+
runs-on: ubuntu-latest
145+
steps:
146+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
147+
with:
148+
persist-credentials: false
149+
150+
# Same pinned version + checksum-verified install as
151+
# divergence-sentinel.yml (the more recent/secure of the two gitleaks
152+
# invocations already in this repo — release-build.yml pins an older
153+
# 8.21.2 with no checksum check). Continuous, per-PR/per-push gate: a
154+
# working-tree scan (--no-git), not a full-history scan — history
155+
# scanning is too slow to run on every PR, and this mirrors the mode
156+
# scripts/make-public-snapshot.sh already uses for the release-time scan
157+
# (`gitleaks detect --no-git --no-banner --redact --source <tree>`).
158+
- name: Install gitleaks
159+
env:
160+
GITLEAKS_VERSION: '8.28.0'
161+
run: |
162+
set -euo pipefail
163+
BASE="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
164+
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
165+
curl -sSL -o "$TARBALL" "${BASE}/${TARBALL}"
166+
curl -sSL -o checksums.txt "${BASE}/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
167+
grep " ${TARBALL}\$" checksums.txt | sha256sum -c -
168+
tar -xzf "$TARBALL" gitleaks
169+
sudo mv gitleaks /usr/local/bin/gitleaks
170+
gitleaks version
171+
172+
- name: Scan working tree for secrets
173+
run: gitleaks detect --no-git --no-banner --redact --source .

0 commit comments

Comments
 (0)