Skip to content
Open
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
345 changes: 345 additions & 0 deletions .github/workflows/e2e-dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
name: e2e-dispatch

# End-to-end pipeline test: drive a synthetic feature through the full SDD
# lifecycle on a dedicated staging repo, assert the terminal state, and report
# duration and a coarse cost estimate. See docs/sdd/e2e.md and issue #89.
#
# This is a plain GitHub Actions workflow, NOT a gh-aw source: it orchestrates
# the run with deterministic `gh` calls and a Python assert script, and never
# invokes an agent itself. The agents run on the STAGING repo, dispatched by
# the SDD wrappers that quick-setup installs there, pinned to the spectacles
# ref under test.
#
# Provisioning is by ref, not by lock push: the staging wrappers call the
# hosted locks via `uses: norrietaylor/spectacles/...@<ref>`. Re-pinning the
# wrappers to the PR head SHA (which carries the PR's recompiled locks) makes
# the staging run exercise the PR's code.

on:
# /e2e [scenario] from a write-access author on a spectacles issue or PR.
issue_comment:
types: [created]
# Nightly at a low-traffic hour, default scenario.
schedule:
- cron: '0 7 * * *'
# Manual run with an explicit scenario.
workflow_dispatch:
inputs:
scenario:
description: Scenario fixture under tests/fixtures/e2e/
required: false
default: happy-path-feature

permissions:
contents: read

concurrency:
# Serialize per scenario; never cancel an in-flight E2E run.
group: e2e-dispatch-${{ github.event.inputs.scenario || 'happy-path-feature' }}
cancel-in-progress: false

jobs:
e2e:
# Token-strict match on `/e2e` for comment events (enumerate boundary chars
# since Actions expressions have no regex); non-comment events always pass
# this job-level gate and are filtered inside the resolve step.
if: |
github.event_name != 'issue_comment'
|| github.event.comment.body == '/e2e'
|| startsWith(github.event.comment.body, '/e2e ')
|| startsWith(github.event.comment.body, '/e2e\n')
|| startsWith(github.event.comment.body, '/e2e\r\n')
|| startsWith(github.event.comment.body, '/e2e\t')
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
pull-requests: read
steps:
Comment on lines +54 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n .github/workflows/e2e-dispatch.yml | head -100

Repository: norrietaylor/spectacles

Length of output: 4689


🏁 Script executed:

cat -n .github/workflows/e2e-dispatch.yml | sed -n '100,200p'

Repository: norrietaylor/spectacles

Length of output: 5431


🌐 Web query:

GitHub API check-runs endpoint permission scope checks read

💡 Result:

The permissions required to access the GitHub Check Runs API depend on the type of authentication used [1][2][3]. For GitHub Apps: GitHub Apps must be granted the Checks repository permission to interact with check runs [4]. - Read Access: To get check runs or annotations, the app must have the checks:read permission on a private repository or pull access to a public repository [2][3]. - Write Access: To create or update check runs, the app must have the checks:write permission [5][2][3]. For OAuth Apps and Authenticated Users (Personal Access Tokens): OAuth apps and users cannot create or manage check runs (such as creating them or re-requesting them); these actions are exclusive to GitHub Apps [1][5][6]. However, they can view check runs and check suites using the repo scope [1][2][3]. For private repositories, a token with the repo scope is required to access these endpoints [1]. In summary, if you are attempting to perform a read-only operation (like listing or getting details of a check run), a GitHub App requires checks:read permissions (or appropriate repository access), while an OAuth app or user requires the repo scope [2][3]. If you require write operations, you must use a GitHub App with checks:write permissions [1][5][7].

Citations:


🌐 Web query:

GitHub Actions github.token default permissions scopes job permissions

💡 Result:

The GITHUB_TOKEN is an automatically generated, short-lived secret used to authenticate API requests within GitHub Actions workflows [1][2]. Its permissions can be managed at the repository or organization level, as well as explicitly defined within workflow files to follow the principle of least privilege [1][3][4]. Default Permissions The default permissions for the GITHUB_TOKEN depend on repository and organization settings [5][4]. Newer repositories (created since early 2023) generally default to a restricted setting, typically providing read-only access to repository contents and packages [6][4]. Organizations and administrators can choose between: - Restricted: Read access to contents and packages [4]. - Permissive: Read and write access to all available scopes [1][4]. Explicit Job and Workflow Permissions You can explicitly define GITHUB_TOKEN permissions in your workflow YAML file to override default settings [3][2]. This can be done at the entire workflow level or for individual jobs [1][7]. - Syntax: Use the permissions key in the YAML file [1][3]. - Behavior: When the permissions key is explicitly defined, all unspecified permissions are set to none, with the exception of the metadata scope, which always retains read access [8][2]. - Scoping: Permissions defined at the job level override any permissions defined at the workflow level [9][7]. They do not merge; the job-level declaration replaces the workflow-level declaration entirely [9]. Special Cases - Forks: Pull requests from public forks are always granted a read-only token, regardless of the repository's configured default or explicitly defined workflow permissions, as a security measure to prevent unauthorized access [1][8]. - Metadata: The metadata scope is always granted read access and cannot be revoked [8][9]. Best Practices To improve security, it is recommended to follow the principle of least privilege by explicitly defining the minimum required permissions for each job, rather than relying on default settings [3][10]. If a task requires permissions beyond those available to the GITHUB_TOKEN (e.g., access to other repositories), consider using a GitHub App or a fine-grained personal access token [3].

Citations:


🏁 Script executed:

cat -n .github/workflows/e2e-dispatch.yml | tail -50

Repository: norrietaylor/spectacles

Length of output: 2682


🏁 Script executed:

grep -n "check-runs\|permissions:" .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 175


Add checks: read permission to the job.

The Layer-0 gate at line 140 calls repos/$REPO/commits/$SHA/check-runs with github.token, but the job permissions do not grant Checks read access. When permissions are explicitly defined in a workflow job, all unspecified permissions default to none (except metadata), causing this API call to fail on private or restricted repositories.

Proposed fix
    permissions:
      contents: read
+     checks: read
      issues: read
      pull-requests: read
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
permissions:
contents: read
issues: read
pull-requests: read
steps:
permissions:
contents: read
checks: read
issues: read
pull-requests: read
steps:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 54 - 58, In the workflow job
permissions block (currently containing contents, issues, and pull-requests read
permissions), add a new permission line for `checks: read`. This will grant the
workflow job the necessary permission to access the check-runs API endpoint that
is called by the Layer-0 gate, which prevents API call failures when the
workflow runs on private or restricted repositories.

- name: Check out spectacles (fixtures + scripts)
uses: actions/checkout@v4
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the file and the specific lines in context
cat -n .github/workflows/e2e-dispatch.yml | head -80

Repository: norrietaylor/spectacles

Length of output: 3705


🏁 Script executed:

# Also check if there are other action usages to understand the pattern
rg "uses:" .github/workflows/e2e-dispatch.yml -A 1

Repository: norrietaylor/spectacles

Length of output: 369


🏁 Script executed:

# Verify if the SHA exists for actions/checkout
web_search
GitHub actions/checkout v4 commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5

Repository: norrietaylor/spectacles

Length of output: 163


🏁 Script executed:

# Let's see more context around line 59-60 to check for any existing 'with' clauses
sed -n '58,65p' .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 277


🏁 Script executed:

# Check all action usages in the workflow to see the pinning pattern
rg "uses:" .github/workflows/e2e-dispatch.yml -B 1

Repository: norrietaylor/spectacles

Length of output: 465


🌐 Web query:

actions/checkout v4 commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5

💡 Result:

The commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5 corresponds to version v4.3.1 of the official actions/checkout GitHub Action [1][2][3]. It is frequently used by organizations and developers to pin the actions/checkout dependency to a specific, immutable version [1][3][4]. By using the full 40-character commit SHA instead of a floating tag (like @v4), users can prevent potential security issues caused by unauthorized modifications or force-pushes to version tags [3][5]. It is standard practice to accompany this SHA with a comment indicating the version (e.g., # v4.3.1) to maintain clarity and facilitate automated updates via tools like Dependabot [3][5].

Citations:


🌐 Web query:

GitHub actions/checkout@v4 latest releases SHA pinning security

💡 Result:

Pinning GitHub Actions, including actions/checkout@v4, to a full-length commit SHA is a recommended security best practice for ensuring the immutability and integrity of your CI/CD workflows [1][2]. Security Rationale Using a version tag (like @v4) is convenient but mutable; maintainers can move tags, or a compromised repository could be used to redirect a tag to malicious code [3][4]. Pinning to a full-length (40-character) commit SHA ensures that the exact code you audited and intended to run is the code executed, protecting against such supply chain attacks [1][3][2]. How to Pin Replace the tag with the full commit SHA, while optionally including the original version tag in a comment for readability and compatibility with dependency update tools [1][5]: - uses: actions/checkout@b4ffde6 # v4.1.1 Policy Enforcement GitHub now provides organization and repository-level policies that administrators can enable to enforce full commit SHA pinning [6][7]. When this policy is active, any workflow attempting to use a mutable reference (tags or branches) will fail [6][7]. Management and Updates To mitigate the maintenance burden of manually updating pinned SHAs, you should use automated dependency management tools like Dependabot or Renovate [1][8][4]. These tools recognize SHA-pinned actions and can open pull requests to update the SHA whenever a new version is released, ensuring your workflows remain secure while benefiting from the latest updates [6][8][5]. Top results: [1][6][2][7][4]

Citations:


Pin checkout action to SHA and disable persisted credentials.

The workflow uses an unpinned third-party action (actions/checkout@v4) while other actions in the workflow are pinned to commit SHAs (e.g., actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1). Additionally, the checkout step does not disable credential persistence, which can leak the GitHub token to the workspace. Both of these violate security best practices and the repository's action-pinning policy.

Proposed fix
       - name: Check out spectacles (fixtures + scripts)
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Check out spectacles (fixtures + scripts)
uses: actions/checkout@v4
- name: Check out spectacles (fixtures + scripts)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 59-60: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 60-60: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 59 - 60, The checkout action
step named "Check out spectacles (fixtures + scripts)" uses an unpinned version
(actions/checkout@v4) and does not disable credential persistence. To fix this,
pin the actions/checkout action to a specific commit SHA (format:
actions/checkout@<full-sha>) consistent with how other actions are pinned in the
workflow, and add a with block containing persist-credentials: false to prevent
the GitHub token from being persisted in the workspace.

Sources: Learnings, Linters/SAST tools


- name: Resolve inputs and gate
id: gate
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
DISPATCH_SCENARIO: ${{ github.event.inputs.scenario }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
IS_PR: ${{ github.event.issue.pull_request != null }}
REPO: ${{ github.repository }}
E2E_DISABLED: ${{ vars.SPECTACLES_E2E_DISABLED }}
STAGING_REPO: ${{ vars.SPECTACLES_E2E_STAGING_REPO }}
run: |
set -euo pipefail

emit() { echo "$1=$2" >> "$GITHUB_OUTPUT"; }
stop() { emit should_run false; echo "e2e-dispatch: $1"; exit 0; }

# Off switch: silences the nightly without a workflow edit.
case "${E2E_DISABLED:-}" in
1|true|TRUE|True) stop "SPECTACLES_E2E_DISABLED is set; skipping." ;;
esac

if [ -z "${STAGING_REPO:-}" ]; then
stop "SPECTACLES_E2E_STAGING_REPO is unset; nothing to target."
fi
Comment on lines +86 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject unsafe staging repo configuration before any write token is minted.

STAGING_REPO is only checked for non-empty, but later steps provision and mutate it with a write/admin App token. Add guards so a misconfigured variable cannot target the source repo or a repo outside the expected owner.

Proposed fix
           if [ -z "${STAGING_REPO:-}" ]; then
             stop "SPECTACLES_E2E_STAGING_REPO is unset; nothing to target."
           fi
+          case "$STAGING_REPO" in
+            */*) : ;;
+            *) stop "SPECTACLES_E2E_STAGING_REPO must be owner/name, got '$STAGING_REPO'." ;;
+          esac
+          if [ "$STAGING_REPO" = "$REPO" ]; then
+            stop "SPECTACLES_E2E_STAGING_REPO must point at a dedicated staging repo, not $REPO."
+          fi
+          if [ "${STAGING_REPO%%/*}" != "${REPO%%/*}" ]; then
+            stop "staging repo must be owned by ${REPO%%/*}; got $STAGING_REPO."
+          fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [ -z "${STAGING_REPO:-}" ]; then
stop "SPECTACLES_E2E_STAGING_REPO is unset; nothing to target."
fi
if [ -z "${STAGING_REPO:-}" ]; then
stop "SPECTACLES_E2E_STAGING_REPO is unset; nothing to target."
fi
case "$STAGING_REPO" in
*/*) : ;;
*) stop "SPECTACLES_E2E_STAGING_REPO must be owner/name, got '$STAGING_REPO'." ;;
esac
if [ "$STAGING_REPO" = "$REPO" ]; then
stop "SPECTACLES_E2E_STAGING_REPO must point at a dedicated staging repo, not $REPO."
fi
if [ "${STAGING_REPO%%/*}" != "${REPO%%/*}" ]; then
stop "staging repo must be owned by ${REPO%%/*}; got $STAGING_REPO."
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 86 - 88, The current
validation for STAGING_REPO only checks if it is empty, but does not validate
its content before write/admin tokens are minted in later steps. Add additional
guard conditions after the empty check to ensure STAGING_REPO is properly
scoped: verify it targets a repo within the expected owner and confirm it is not
the source repo itself. These validation checks should use the stop function to
fail fast if the STAGING_REPO configuration is unsafe or misconfigured.


# Resolve the scenario and the spectacles ref under test.
scenario="happy-path-feature"
spectacles_ref="main"
origin_issue=""

if [ "$EVENT_NAME" = "issue_comment" ]; then
# Write-access gate: accept write|maintain|admin only.
perm="$(gh api "repos/$REPO/collaborators/$COMMENT_AUTHOR/permission" \
--jq '.permission' 2>/dev/null || echo none)"
case "$perm" in
write|maintain|admin) : ;;
*) stop "author $COMMENT_AUTHOR lacks write access (perm=$perm)." ;;
esac

# /e2e [scenario] — second token is the optional scenario.
arg="$(printf '%s' "$COMMENT_BODY" | awk 'NR==1{print $2}')"
[ -n "$arg" ] && scenario="$arg"
origin_issue="$ISSUE_NUMBER"

if [ "$IS_PR" = "true" ]; then
spectacles_ref="$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER" --jq '.head.sha')"
fi
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
[ -n "${DISPATCH_SCENARIO:-}" ] && scenario="$DISPATCH_SCENARIO"
fi
# schedule: defaults stand (happy-path-feature on main).

fixture_dir="tests/fixtures/e2e/$scenario"
if [ ! -f "$fixture_dir/issue.md" ] || [ ! -f "$fixture_dir/expectations.yml" ]; then
stop "scenario '$scenario' has no fixture at $fixture_dir."
fi

emit should_run true
emit scenario "$scenario"
emit spectacles_ref "$spectacles_ref"
emit origin_issue "$origin_issue"
emit staging_repo "$STAGING_REPO"
echo "e2e-dispatch: scenario=$scenario ref=$spectacles_ref staging=$STAGING_REPO"

- name: Layer-0 gate — require green checks on the PR head
if: ${{ steps.gate.outputs.should_run == 'true' && steps.gate.outputs.spectacles_ref != 'main' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.gate.outputs.spectacles_ref }}
run: |
set -euo pipefail
# The cheap Layer-0 checks (lint.yml jobs) must be green before the
# expensive Layer-1 E2E run provisions staging. A failing or pending
# required check fails fast here.
failing="$(gh api "repos/$REPO/commits/$SHA/check-runs" \
--jq '[.check_runs[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out")] | length')"
if [ "$failing" -gt 0 ]; then
echo "e2e-dispatch: $failing Layer-0 check(s) not green on $SHA; refusing to run." >&2
exit 1
fi
Comment on lines +140 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat pending check runs as not green.

The comment says pending required checks fail fast, but the query only counts completed failures. A queued/in-progress Layer-0 check lets the expensive staging run start before the PR head is actually green.

Proposed fix
-          failing="$(gh api "repos/$REPO/commits/$SHA/check-runs" \
-            --jq '[.check_runs[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out")] | length')"
-          if [ "$failing" -gt 0 ]; then
-            echo "e2e-dispatch: $failing Layer-0 check(s) not green on $SHA; refusing to run." >&2
+          not_green="$(gh api "repos/$REPO/commits/$SHA/check-runs" \
+            --jq '[.check_runs[] | select(.status!="completed" or (.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped"))] | length')"
+          if [ "$not_green" -gt 0 ]; then
+            echo "e2e-dispatch: $not_green Layer-0 check(s) not green on $SHA; refusing to run." >&2
             exit 1
           fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 140 - 145, The jq filter in
the gh api check-runs query only counts check runs with explicit failure,
cancelled, or timed_out conclusions, but ignores pending or in-progress check
runs that haven't yet completed. Modify the select condition to also include
check runs where conclusion is null or status is not "completed", so that any
queued or in-progress Layer-0 checks are treated as not green and prevent the
staging run from starting.


- name: Mint App token (spectacles + staging)
id: app-token
if: ${{ steps.gate.outputs.should_run == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
# Cover both the spectacles repo (post the summary comment) and the
# staging repo (provision, seed, poll). Names are bare repo names.
repositories: |
${{ github.event.repository.name }}
${{ steps.gate.outputs.staging_repo }}
Comment on lines +157 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the e2e-dispatch.yml file
find . -name "e2e-dispatch.yml" -type f

Repository: norrietaylor/spectacles

Length of output: 104


🏁 Script executed:

# Also check the structure of the workflows directory
ls -la .github/workflows/ 2>/dev/null || echo "No workflows found in standard location"

Repository: norrietaylor/spectacles

Length of output: 2574


🏁 Script executed:

# Search for e2e-dispatch.yml in the repository
git ls-files | grep -i "e2e-dispatch"

Repository: norrietaylor/spectacles

Length of output: 102


🏁 Script executed:

# Read the e2e-dispatch.yml file, focusing on the relevant sections
wc -l .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 106


🏁 Script executed:

# Read the file around lines 157-159 to see the current state
sed -n '150,170p' .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 1041


🏁 Script executed:

# Search for where staging_repo is emitted/generated
grep -n "staging_repo" .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 666


🏁 Script executed:

# Search for where STAGING_REPO is set
grep -n "STAGING_REPO" .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 406


🏁 Script executed:

# Look for the gate step to understand the context
grep -n "steps.gate" .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 1696


🏁 Script executed:

# Read the gate step to see what format STAGING_REPO has
sed -n '70,130p' .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 2777


🏁 Script executed:

# Check if there's any documentation or examples of what SPECTACLES_E2E_STAGING_REPO should be
grep -r "SPECTACLES_E2E_STAGING_REPO" . --include="*.md" --include="*.yml"

Repository: norrietaylor/spectacles

Length of output: 379


🏁 Script executed:

# Look for the create-github-app-token action to understand requirements
grep -B5 -A15 "create-github-app-token" .github/workflows/e2e-dispatch.yml

Repository: norrietaylor/spectacles

Length of output: 932


🏁 Script executed:

# Check the e2e.md documentation to understand the format expectations
sed -n '1,50p' ./docs/sdd/e2e.md | grep -A5 -B5 "SPECTACLES_E2E_STAGING_REPO"

Repository: norrietaylor/spectacles

Length of output: 49


🏁 Script executed:

# Search for more context about the STAGING_REPO usage in other parts of the workflow
grep -n "permission" .github/workflows/e2e-dispatch.yml | head -20

Repository: norrietaylor/spectacles

Length of output: 465


🏁 Script executed:

# Check if there are any other workflows that use create-github-app-token to see patterns
grep -r "create-github-app-token" .github/workflows/ -A10 | grep -A10 "repositories:"

Repository: norrietaylor/spectacles

Length of output: 41626


Extract the bare staging repository name for the App-token action.

The staging_repo output is currently emitted in owner/name format, but the create-github-app-token action's repositories field requires bare repo names (see the workflow comment on lines 153-154). Passing owner/name instead of just the repo name prevents the minted token from covering the staging repository.

Proposed fix
           emit should_run true
           emit scenario "$scenario"
           emit spectacles_ref "$spectacles_ref"
           emit origin_issue "$origin_issue"
           emit staging_repo "$STAGING_REPO"
+          emit staging_repo_name "${STAGING_REPO#*/}"
           echo "e2e-dispatch: scenario=$scenario ref=$spectacles_ref staging=$STAGING_REPO"
           repositories: |
             ${{ github.event.repository.name }}
-            ${{ steps.gate.outputs.staging_repo }}
+            ${{ steps.gate.outputs.staging_repo_name }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
repositories: |
${{ github.event.repository.name }}
${{ steps.gate.outputs.staging_repo }}
repositories: |
${{ github.event.repository.name }}
${{ steps.gate.outputs.staging_repo_name }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 157 - 159, The staging_repo
output from steps.gate is currently in owner/name format, but the
create-github-app-token action's repositories field requires only the bare
repository name. Extract just the repository name portion (the part after the
forward slash) from the ${{ steps.gate.outputs.staging_repo }} output before
passing it to the repositories field. This can be done by using string
manipulation to split on the "/" character and take only the second part, or by
modifying the gate step to output both the full owner/name format and a separate
bare repository name value that can be referenced here.

permission-contents: write
permission-issues: write
permission-pull-requests: write
permission-actions: read
permission-administration: write

- name: Provision staging (pin wrappers to the ref under test)
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
REF: ${{ steps.gate.outputs.spectacles_ref }}
run: |
set -euo pipefail
bash scripts/quick-setup.sh --target-repo "$STAGING" --suite sdd --ref "$REF" --direct

- name: Reset staging (close stale E2E issues)
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
run: |
set -euo pipefail
# Close any open tracking issue from a prior run so this run starts
# from a clean slate. Idempotent; a fresh staging repo closes nothing.
gh issue list --repo "$STAGING" --state open --label e2e:seed --json number \
--jq '.[].number' | while read -r n; do
[ -n "$n" ] && gh issue close "$n" --repo "$STAGING" --comment "Superseded by a new E2E run."
done
Comment on lines +176 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Reset persisted staging artifacts, not just stale issues.

The reset step only closes prior e2e:seed issues. Since scripts/e2e-assert.py checks artifact globs against the entire default branch, old docs/specs/**/*.md files can satisfy the current run’s artifact assertions and mask a regression.

Reset the staging default branch to a known baseline, or make artifact assertions scoped to files produced by this seeded run. Based on the provided assertion contract, check_artifacts() scans the default branch without using --since.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-dispatch.yml around lines 176 - 188, The reset step
only closes prior e2e:seed issues but leaves old artifact files on the staging
default branch, allowing scripts/e2e-assert.py to find and validate artifacts
from previous runs instead of the current one, which masks regressions. Add
logic to either reset the staging repository default branch to a clean baseline
by removing old artifact files (particularly docs/specs/**/*.md) before the E2E
run begins, or alternatively modify the artifact assertion logic in
scripts/e2e-assert.py to scope checks only to artifacts produced by the current
seeded run instead of scanning the entire default branch history.


- name: Seed the synthetic tracking issue
id: seed
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
SCENARIO: ${{ steps.gate.outputs.scenario }}
run: |
set -euo pipefail
pip install --quiet pyyaml
fixture="tests/fixtures/e2e/$SCENARIO"

# Seed time bounds the cost/PR queries to this run's activity.
echo "since=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

# The feature issue template applies its labels only in the web UI;
# gh issue create does not, so pass the fixture's seed_labels plus an
# e2e:seed marker explicitly.
labels="$(python -c "import yaml,sys; print(','.join(yaml.safe_load(open(sys.argv[1])).get('seed_labels',[])))" "$fixture/expectations.yml")"
# Ensure the marker label exists, then create the issue.
gh label create e2e:seed --repo "$STAGING" --color ededed \
--description "Synthetic E2E tracking issue (issue #89)." --force >/dev/null 2>&1 || true

url="$(gh issue create --repo "$STAGING" \
--title "feature: e2e $SCENARIO" \
--body-file "$fixture/issue.md" \
--label "e2e:seed${labels:+,$labels}")"
number="${url##*/}"
echo "issue=$number" >> "$GITHUB_OUTPUT"
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "e2e-dispatch: seeded $url"

- name: Poll lifecycle to terminal state
id: poll
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
ISSUE: ${{ steps.seed.outputs.issue }}
TIMEOUT_MIN: ${{ vars.SPECTACLES_E2E_TIMEOUT_MIN || '30' }}
run: |
set -euo pipefail
deadline=$(( SECONDS + TIMEOUT_MIN * 60 ))
state="(none)"
while [ "$SECONDS" -lt "$deadline" ]; do
labels="$(gh issue view "$ISSUE" --repo "$STAGING" --json labels --jq '.labels[].name')"
# Terminal on sdd:done (success) or needs-human (escalation halt).
if echo "$labels" | grep -qx "sdd:done"; then state="sdd:done"; break; fi
if echo "$labels" | grep -qx "needs-human"; then state="needs-human"; break; fi
sleep 30
done
[ "$SECONDS" -ge "$deadline" ] && [ "$state" = "(none)" ] && state="timeout"
echo "final_state=$state" >> "$GITHUB_OUTPUT"
echo "e2e-dispatch: final state = $state"

- name: Assert terminal state
id: assert
if: ${{ steps.gate.outputs.should_run == 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
ISSUE: ${{ steps.seed.outputs.issue }}
SCENARIO: ${{ steps.gate.outputs.scenario }}
SINCE: ${{ steps.seed.outputs.since }}
run: |
set -euo pipefail
pip install --quiet pyyaml
python scripts/e2e-assert.py \
--repo "$STAGING" --issue "$ISSUE" \
--expect "tests/fixtures/e2e/$SCENARIO/expectations.yml" \
--since "$SINCE" | tee assert-output.txt

- name: Estimate cost
id: cost
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
SINCE: ${{ steps.seed.outputs.since }}
run: |
set -euo pipefail
# Coarse estimate: count staging Actions runs started since the seed
# and sum their wall-clock minutes. Token cost is a fixed per-run
# constant until a real usage source is wired (issue #89 follow-up).
runs="$(gh api "repos/$STAGING/actions/runs?created=>$SINCE&per_page=100" \
--jq '[.workflow_runs[] | {s:.run_started_at, u:.updated_at}]')"
minutes="$(python -c "
import json,sys,datetime
def p(t): return datetime.datetime.fromisoformat(t.replace('Z','+00:00'))
rs=json.loads(sys.argv[1])
m=sum((p(r['u'])-p(r['s'])).total_seconds() for r in rs if r['s'])/60
print(f'{m:.1f}')" "$runs")"
echo "actions_minutes=$minutes" >> "$GITHUB_OUTPUT"
echo "token_estimate=~\$5 (coarse per-scenario constant)" >> "$GITHUB_OUTPUT"

- name: Report summary
if: ${{ steps.gate.outputs.should_run == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
SCENARIO: ${{ steps.gate.outputs.scenario }}
ORIGIN_ISSUE: ${{ steps.gate.outputs.origin_issue }}
STAGING_ISSUE_URL: ${{ steps.seed.outputs.url }}
FINAL_STATE: ${{ steps.poll.outputs.final_state }}
ASSERT_OUTCOME: ${{ steps.assert.outcome }}
ACTIONS_MINUTES: ${{ steps.cost.outputs.actions_minutes }}
TOKEN_ESTIMATE: ${{ steps.cost.outputs.token_estimate }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
verdict="✅ pass"
[ "$ASSERT_OUTCOME" != "success" ] && verdict="❌ fail"

{
echo "### E2E \`$SCENARIO\` — $verdict"
echo
echo "- Staging tracking issue: $STAGING_ISSUE_URL"
echo "- Final lifecycle state: \`$FINAL_STATE\`"
echo "- Assertions: $ASSERT_OUTCOME"
echo "- Cost: ${ACTIONS_MINUTES} Actions minutes on staging; tokens $TOKEN_ESTIMATE"
echo "- Dispatcher run: $RUN_URL"
if [ -f assert-output.txt ]; then
echo
echo '```'
cat assert-output.txt
echo '```'
fi
} > summary.md

cat summary.md >> "$GITHUB_STEP_SUMMARY"

if [ -n "${ORIGIN_ISSUE:-}" ]; then
# Comment back on the originating spectacles issue/PR.
gh issue comment "$ORIGIN_ISSUE" --repo "$REPO" --body-file summary.md
else
# Schedule/dispatch: open a dated nightly result issue.
gh issue create --repo "$REPO" \
--title "e2e nightly: $SCENARIO $(date -u +%Y-%m-%d)" \
--body-file summary.md >/dev/null
fi

- name: Tag staging for post-mortem on failure
if: ${{ steps.gate.outputs.should_run == 'true' && steps.assert.outcome != 'success' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGING: ${{ steps.gate.outputs.staging_repo }}
ISSUE: ${{ steps.seed.outputs.issue }}
run: |
set -euo pipefail
gh label create e2e:failed --repo "$STAGING" --color b60205 \
--description "E2E assertions failed; left for post-mortem." --force >/dev/null 2>&1 || true
gh issue edit "$ISSUE" --repo "$STAGING" --add-label e2e:failed
echo "::error::E2E scenario assertions failed; staging issue left open for post-mortem."
exit 1
Loading
Loading