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
83 changes: 83 additions & 0 deletions .github/workflows/backport-dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# backport-dispatch.yml — OPTIONAL latency optimization for DEV-352's
# auto-backport bot. Authored in atlas (release/public path), ships to the
# PUBLIC repo via the snapshot per invariant I0 — this workflow only ever
# runs on the public repo, never on atlas (see the repo guard below).
#
# NOT YET ARMED (2026-07-15): the atlas-side auto-backport.yml has a
# scheduled SWEEP (every 30 min) that scans merged public PRs anonymously
# and backports anything unabsorbed — the whole pipeline is fully
# functional with ZERO public-repo credentials via that path alone. This
# workflow instantly notifies atlas the moment a PR merges instead of
# waiting for the next sweep (seconds vs. up to 30 minutes) — a nice-to-have,
# not a requirement.
#
# Arming it means placing a GitHub App credential with contents:write on
# the PRIVATE atlas repo into THIS PUBLIC repo's secrets — a real trust
# decision (a compromised public-repo secret store would let an attacker
# push to atlas) that is deliberately left to the operator, not decided by
# this file. Until TESTSPRITE_HOB_APP_ID/TESTSPRITE_HOB_PRIVATE_KEY (or the
# ALFHEIM_AGENT_* fallback) exist on the PUBLIC repo with contents:write on
# atlas, this workflow no-ops cleanly (see the "not armed" step) — it does
# NOT fail loud, so it stays quiet for every contributor watching the
# Actions tab until the operator decides to enable it.
#
# SECURITY: this workflow reads ONLY event metadata (PR number, merge SHA,
# base ref) — it never checks out the merged PR's code. That is what makes
# `pull_request_target` safe here: the base-repo context (secrets, a
# write-capable token) is required because a fork-originated merged PR gets
# ZERO secrets under a plain `pull_request` trigger, even for the `closed`
# activity type — but nothing in this job ever runs untrusted fork code, so
# the classic pull_request_target RCE/secret-exfiltration risk (checking
# out and executing the fork's own head ref under a privileged context)
# does not apply. Do not add actions/checkout to this workflow.
name: Notify atlas of a merged public PR

on:
pull_request_target:
types: [closed]

permissions:
contents: read

jobs:
notify:
if: github.repository == 'TestSprite/testsprite-cli' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
owner: TestSprite
repositories: testsprite-cli-atlas
# Minimum required for POST /repos/{owner}/{repo}/dispatches (verified
# against docs.github.com/en/rest/using-the-rest-api/permissions-required-for-github-apps —
# the endpoint is gated on Contents:write, not Contents:read).
permission-contents: write

- name: Dispatch to atlas
if: steps.app-token.outputs.token != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# These two fields are fork-influenced (merge_commit_sha/base.ref come
# from a merged PR's event payload) — routed through env instead of
# spliced into the run: string to avoid the GHA script-injection class
# (a maliciously-crafted value could otherwise break out of the -F
# argument and inject arbitrary shell). PR_NUMBER is left inline: it is
# a GitHub-assigned integer, not attacker-authorable content.
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
gh api repos/TestSprite/testsprite-cli-atlas/dispatches \
-f event_type=public-pr-merged \
-F 'client_payload[pr_number]=${{ github.event.pull_request.number }}' \
-F "client_payload[merge_commit_sha]=$MERGE_SHA" \
-F "client_payload[base_ref]=$BASE_REF"

- name: Not armed yet — the atlas sweep will pick this up
if: steps.app-token.outputs.token == ''
run: |
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."
30 changes: 25 additions & 5 deletions .github/workflows/ci-nudge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
# CI workflows (CI + Test Coverage) can complete in any order.
#
# Bot identity: same App-token-first / GITHUB_TOKEN-fallback pattern as
# pr-triage.yml (the App needs the "Pull requests: Read & write" permission to
# post as testsprite-hob[bot]; until then comments come from github-actions[bot]).
# pr-triage.yml. The App has had the "Pull requests: Read & write" permission
# since 2026-07-02 (corrected 2026-07-15; this comment previously said the
# permission was still pending) — the fallback path stays as defense-in-depth
# for whenever the App/secrets are absent.
name: CI failure nudge

on:
Expand Down Expand Up @@ -40,8 +42,20 @@ jobs:
with:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
# The script below only ever calls the App client (`appClient`) for
# rest.issues.updateComment/createComment — PR comments ride the
# issues API (see the header comment above). All reads (checks,
# pulls, commit->PR lookup) go through the ambient `github` client,
# governed by this workflow's top-level `permissions:` block, not
# this minted token. Scoping to checks:read/pull-requests:write here
# (matching the job's top-level block literally) would risk the mint
# itself failing if the App's installation doesn't hold Checks
# permission — which would silently drop the App-token path
# entirely (continue-on-error swallows the failure) and always fall
# back to github-actions[bot], defeating this bot's own purpose.
permission-issues: write

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
with:
Expand Down Expand Up @@ -82,14 +96,20 @@ jobs:
'Build': 'run `npm run build` and fix the compile errors',
'Local E2E Tests': 'run `npm run test:e2e` (it builds first)',
'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%',
'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',
};
// Matrix jobs publish check runs as "Unit Tests (Node 20)" etc. —
// exact lookup alone would silently skip them (and the nudge would
// say "all green" while they are red). Try exact first, then the
// name with a trailing parenthetical stripped (review round 2).
const fixFor = (name) => FIX[name] ?? FIX[name.replace(/\s*\([^)]*\)$/, '')];

// Recompute full CI state from this SHA's check runs, narrowed to the
// CI jobs above — other workflows (e.g. pr-triage) also attach
// github-actions check runs to the PR head and must not count here.
const checks = await github.paginate(github.rest.checks.listForRef,
{ owner, repo, ref: run.head_sha, per_page: 100 });
const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && FIX[c.name]);
const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && fixFor(c.name));
const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion));
const pending = ours.filter(c => c.status !== 'completed');

Expand All @@ -110,7 +130,7 @@ jobs:
const lines = failing
.sort((a, b) => a.name.localeCompare(b.name))
.map(c => {
const fix = FIX[c.name] || 'see the logs for details';
const fix = fixFor(c.name) || 'see the logs for details';
return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`;
});
const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — `
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand All @@ -37,6 +39,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand All @@ -55,6 +59,8 @@ jobs:
node-version: [20, 22]
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand All @@ -68,6 +74,27 @@ jobs:
env:
CI: true

test-windows:
name: Unit Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 22
cache: 'npm'

- run: npm ci
- run: npm run build
- run: npm run typecheck
- run: npm test
env:
CI: true

build:
name: Build (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
Expand All @@ -76,6 +103,8 @@ jobs:
node-version: [20, 22]
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand All @@ -96,6 +125,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand All @@ -107,3 +138,36 @@ jobs:
- run: npm run test:e2e
env:
CI: true

gitleaks:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

# Same pinned version + checksum-verified install as
# divergence-sentinel.yml (the more recent/secure of the two gitleaks
# invocations already in this repo — release-build.yml pins an older
# 8.21.2 with no checksum check). Continuous, per-PR/per-push gate: a
# working-tree scan (--no-git), not a full-history scan — history
# scanning is too slow to run on every PR, and this mirrors the mode
# scripts/make-public-snapshot.sh already uses for the release-time scan
# (`gitleaks detect --no-git --no-banner --redact --source <tree>`).
- name: Install gitleaks
env:
GITLEAKS_VERSION: '8.28.0'
run: |
set -euo pipefail
BASE="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSL -o "$TARBALL" "${BASE}/${TARBALL}"
curl -sSL -o checksums.txt "${BASE}/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " ${TARBALL}\$" checksums.txt | sha256sum -c -
tar -xzf "$TARBALL" gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
gitleaks version

- name: Scan working tree for secrets
run: gitleaks detect --no-git --no-banner --redact --source .
12 changes: 10 additions & 2 deletions .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,18 @@ env:

jobs:
triage:
# Public repo only — this file also lives in the private mirror (atlas),
# where issue-first claiming doesn't apply; the three sibling bots
# (pr-triage.yml, ci-nudge.yml, stale.yml) all carry this same fence and
# this one was missing it (found in the 2026-07-15 OSS-P2 audit — the
# /assign bot was live on atlas too until this fix).
# issues only (issue_comment also fires on PRs), and never react to a bot's own
# comment — incl. our own testsprite-hob[bot], which (unlike GITHUB_TOKEN) would
# otherwise re-trigger this workflow. `type == 'Bot'` covers both bot identities.
if: ${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' }}
if: >-
github.repository == 'TestSprite/testsprite-cli' &&
!github.event.issue.pull_request &&
github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
steps:
# Mint a token for the testsprite-hob App so comments post as testsprite-hob[bot].
Expand All @@ -49,7 +57,7 @@ jobs:
# of inheriting the App's full installation permissions.
permission-issues: write

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
# App token when available (→ testsprite-hob[bot]); else the default
# GITHUB_TOKEN (→ github-actions[bot]). Either way the logic is identical.
Expand Down
24 changes: 17 additions & 7 deletions .github/workflows/pr-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
# Runs with NO checkout — metadata-only, so it is safe under pull_request_target
# (which is required for fork PRs to get a write-capable token).
#
# Bot identity: posts as `testsprite-hob[bot]` when the App token works. The
# App currently has Issues R/W + Metadata only — commenting/labeling a PULL
# REQUEST needs the "Pull requests: Read & write" App permission (issues-API
# endpoints are permission-checked by target type). Until an org admin adds
# that permission and re-approves the installation, every call gracefully falls
# back to the default GITHUB_TOKEN and posts as github-actions[bot].
# Bot identity: posts as `testsprite-hob[bot]` when the App token works (the
# App has had the "Pull requests: Read & write" permission since 2026-07-02 —
# corrected 2026-07-15; this comment previously said the permission was still
# pending). The App-token-first / GITHUB_TOKEN-fallback code path below is
# kept regardless, as defense-in-depth for whenever the App/secrets are
# absent or a future installation loses the permission.
# Secrets: TESTSPRITE_HOB_* preferred; the ALFHEIM_AGENT_* fallbacks are the
# original names from before the App was renamed (same App ID + key).
name: PR triage (issue-link gate)
Expand Down Expand Up @@ -60,8 +60,18 @@ jobs:
with:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
# Every write() call below (createComment/updateComment/addLabels/
# removeLabel) is an `issues.*` Octokit method — GitHub gates all
# four (comments AND labels, even on a PR number) on the "Issues"
# repository permission, not "Pull requests" (verified against
# docs.github.com/en/rest/using-the-rest-api/permissions-required-for-github-apps).
# Scoping to issues:write alone is therefore both sufficient and
# minimal — narrower than this job's top-level `permissions:` block,
# which also carries pull-requests:write for the ambient-token
# fallback path (unused by this specific App-token mint).
permission-issues: write

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
with:
Expand Down
28 changes: 24 additions & 4 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,33 @@ jobs:
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# npm trusted publishing (OIDC) requires npm >= 11.5.1; Node 22 bundles npm 10.x
- run: npm install -g npm@latest
# npm trusted publishing (OIDC) requires npm >= 11.5.1; Node 22 never bundles
# that (it ships 10.9.x). Pin an EXACT version rather than floating on
# @latest: npm 12.0.0 shipped with a broken workspace symlink that pulled the
# wrong `sigstore` dependency into its own release tarball, so every
# `npm publish --provenance` failed with `Cannot find module 'sigstore'`
# (npm/cli#9722) — this is exactly what our v0.2.0 and v0.3.0 release.yaml
# runs hit, both AFTER `npm install -g npm@latest` reported success. Fixed
# in 12.0.1 (2026-07-10); bump this pin deliberately, never float it again.
- run: npm install -g npm@11.6.2
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run format:check
- run: npm run test:coverage
- run: npm run build
# Auth via npm trusted publishing (OIDC) — no token needed; provenance is implied
- run: npm publish --provenance --access public
# Auth via npm trusted publishing (OIDC) — no token needed; provenance is
# implied. A prerelease tag (e.g. v0.3.0-rc.1, contains "-") must publish
# under an explicit dist-tag: current npm already refuses an implicit
# `latest` for a prerelease version, but failing mid-release is worse than
# not needing the guard, so this still passes --tag itself rather than
# relying on that enforcement alone.
- name: Publish
env:
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "$REF_NAME" == *-* ]]; then
npm publish --provenance --access public --tag next
else
npm publish --provenance --access public
fi
2 changes: 2 additions & 0 deletions .github/workflows/test-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
Expand Down
Loading
Loading