Skip to content

feat(e2e): /e2e staging-repo dispatcher (walking skeleton)#282

Open
norrietaylor wants to merge 3 commits into
mainfrom
feat/89-e2e-staging-dispatcher
Open

feat(e2e): /e2e staging-repo dispatcher (walking skeleton)#282
norrietaylor wants to merge 3 commits into
mainfrom
feat/89-e2e-staging-dispatcher

Conversation

@norrietaylor

Copy link
Copy Markdown
Owner

Closes #89 (walking-skeleton layer; remaining scenarios are follow-up fixture-only PRs).

What

A non-agentic dispatcher that drives one synthetic feature through the full SDD lifecycle on a dedicated staging repo, asserts terminal state, and reports duration + a coarse cost estimate. Runs on /e2e [scenario] from write-access authors, nightly, and workflow_dispatch.

Files

File Role
.github/workflows/e2e-dispatch.yml Plain Actions orchestrator (no lock). Gate → provision → seed → poll → assert → cost → report.
scripts/e2e-assert.py Walks staging state via gh api, checks expectations.yml.
scripts/e2e-setup-staging.sh One-time staging provisioning; documented, not CI-run.
tests/fixtures/e2e/happy-path-feature/{issue.md,expectations.yml} One scenario fixture + assertion contract.
docs/sdd/e2e.md (+ mkdocs.yml nav) Operator doc.

Divergences from the issue text (intentional)

  • Plain .yml, not .md + lock. Non-agentic orchestrators in this repo are hand-written Actions YAML (lint.yml, sdd-unspecced-scan.yml). A gh-aw .md source always injects an agent step, so it cannot be "no gh-aw agent step."
  • Provision by ref, not lock push. quick-setup.sh --ref <pr-head-sha> --direct re-pins the staging wrappers; cross-repo uses: …@<sha> then runs the PR's hosted lock. No gh aw deploy, no literal lock copy.
  • Cost = Actions minutes + coarse constant. Lock metadata carries no token cost; the token figure is a fixed per-scenario constant until a real usage source is wired.

Verification

actionlint e2e-dispatch.yml           exit 0  (incl. embedded run: shellcheck)
shellcheck e2e-setup-staging.sh       exit 0
python ast.parse e2e-assert.py        OK
markdownlint docs + fixtures          0 errors
mkdocs build --strict                 exit 0
test-command-table / lifecycle /
  safe-outputs / requirement-ids      all exit 0
glob + label-check unit test          PASS

/e2e is deliberately invisible to test-command-table.py — it is a plain-workflow command, not an SDD slash command.

Not in this PR

  • Live E2E acceptance run — needs the spectacles-staging repo, App install, and secrets. Run scripts/e2e-setup-staging.sh once, set SPECTACLES_E2E_STAGING_REPO, then /e2e happy-path-feature on a throwaway PR.
  • Other scenarioshappy-path-bug, revise-loop-spec, needs-human-handoff add a fixture directory each, no dispatcher change.

🤖 Generated with Claude Code

Walking skeleton for #89: drive a synthetic feature through the full SDD
lifecycle on a dedicated staging repo, assert terminal state, report cost.

- e2e-dispatch.yml: plain Actions orchestrator (no lock). /e2e [scenario]
  from write-access authors, nightly schedule, workflow_dispatch. Gate ->
  provision -> seed -> poll -> assert -> cost -> report.
- Provision by ref: quick-setup.sh --ref <pr-head-sha> re-pins staging
  wrappers; cross-repo uses:@<sha> runs the PR's hosted lock. No lock push.
- e2e-assert.py: walks staging state via gh api, checks expectations.yml
  (terminal label, artifact globs, PRs opened/merged).
- e2e-setup-staging.sh: one-time staging provisioning, documented, not CI-run.
- happy-path-feature fixture; docs/sdd/e2e.md + mkdocs nav entry.

Cost is staging Actions minutes + a coarse per-scenario token constant; lock
metadata carries no token cost. happy-path-bug, revise-loop-spec, and
needs-human-handoff are follow-up fixture-only PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 583411b6-e9df-4e34-9bb7-98ce45b50f9e

📥 Commits

Reviewing files that changed from the base of the PR and between 21c3983 and fe0ed6a.

📒 Files selected for processing (2)
  • docs/sdd/e2e.md
  • scripts/e2e-setup-staging.sh
✅ Files skipped from review due to trivial changes (1)
  • docs/sdd/e2e.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/e2e-setup-staging.sh

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added an end-to-end testing pipeline that can be triggered by issue comments, scheduled nightly runs, or manual dispatch.
    • Introduced automatic staging setup and scenario-driven validation with pass/fail reporting.
  • Documentation
    • Added an end-to-end testing guide covering how scenarios run, expected outcomes, and how results are reported.
  • Tests / Fixtures
    • Added a new example E2E “happy path” scenario with its assertion expectations and seeded tracking issue content.

Walkthrough

Adds a complete E2E testing pipeline: a new e2e-dispatch.yml GitHub Actions workflow that seeds synthetic tracking issues on a staging repo and polls label-based lifecycle states, a scripts/e2e-assert.py Python script validating terminal state against expectations.yml, a scripts/e2e-setup-staging.sh one-time provisioner, the first happy-path-feature scenario fixture, and supporting documentation.

Changes

E2E Staging Dispatcher and Assertion Pipeline

Layer / File(s) Summary
Happy-path scenario fixture
tests/fixtures/e2e/happy-path-feature/issue.md, tests/fixtures/e2e/happy-path-feature/expectations.yml
Adds the first E2E scenario: a --version CLI issue body and a YAML contract specifying seed labels, required terminal label (sdd:done), artifact glob (docs/specs/**/*.md), PR merge requirements, and forbidden labels.
e2e-assert.py assertion script
scripts/e2e-assert.py
Adds the Python assertion script with module docstring, gh_api (paginated GitHub CLI wrapper), glob_to_regex, check_labels, check_artifacts, check_prs, and a main entry point that accumulates all failures and exits non-zero on any mismatch.
e2e-setup-staging.sh provisioner
scripts/e2e-setup-staging.sh
Adds the one-time Bash setup script: header and strict mode, parses --staging, --ref, --dry-run; run() helper for conditional execution; verifies repo, clears branch protection, installs SDD suite via quick-setup.sh --direct, sets the SPECTACLES_E2E_STAGING_REPO variable, and conditionally configures secrets from environment; emits a manual-steps checklist.
Workflow triggers, permissions, and concurrency
.github/workflows/e2e-dispatch.yml (lines 1–53)
Defines triggers (issue comment, cron, workflow_dispatch), baseline permissions, and a per-scenario concurrency key that serializes runs without canceling in-flight executions.
Input resolution, gating, and Layer-0 scan
.github/workflows/e2e-dispatch.yml (lines 62–165)
Enforces disable switch, validates staging repo, resolves scenario/ref by event type, checks collaborator write access, verifies fixture files, outputs resolved parameters, performs Layer-0 check-run scan for non-main refs, and mints app installation token.
Staging provisioning, reset, and issue seeding
.github/workflows/e2e-dispatch.yml (lines 166–221)
Runs quick-setup.sh against staging with ref pinning, closes stale seeded issues, installs YAML tooling, extracts seed labels from fixture expectations, creates the e2e:seed label, seeds synthetic tracking issue from fixture Markdown, and outputs issue number, URL, and seed timestamp.
Label polling, assertion, and cost estimation
.github/workflows/e2e-dispatch.yml (lines 222–285)
Polls tracking issue labels until terminal state (sdd:done, needs-human, or timeout), runs e2e-assert.py with continue-on-error, and queries staging workflow runs to sum wall-clock minutes and generate token-cost estimate.
Summary reporting and failure post-mortem
.github/workflows/e2e-dispatch.yml (lines 286–345)
Assembles Markdown verdict report (state, assertion result, cost, run link), appends to job summary, posts back to originating spectacles issue/PR or creates nightly result issue, labels staging issue e2e:failed on failure, and exits with appropriate code.
E2E documentation and nav registration
docs/sdd/e2e.md, mkdocs.yml
Adds comprehensive docs page describing dispatch pipeline, staging setup, configuration variables, fixture schema, and cost reporting; registered in MkDocs navigation.

Sequence Diagram(s)

sequenceDiagram
  participant Trigger as Trigger (comment/cron/dispatch)
  participant Workflow as e2e-dispatch.yml
  participant StagingRepo as Staging Repo (GitHub)
  participant AssertScript as e2e-assert.py
  participant SpectaclesIssue as Spectacles Issue/PR

  Trigger->>Workflow: fire event (scenario, ref)
  Workflow->>Workflow: resolve inputs, gate (disable switch, fixture check, Layer-0 scan)
  Workflow->>Workflow: mint app installation token
  Workflow->>StagingRepo: quick-setup.sh (pin wrappers to ref)
  Workflow->>StagingRepo: close stale seeded issues
  Workflow->>StagingRepo: create tracking issue with seed labels
  loop poll every ~60s until terminal or timeout
    Workflow->>StagingRepo: read tracking issue labels
    StagingRepo-->>Workflow: current labels
  end
  Workflow->>AssertScript: run with --repo, --issue, --expect, --since
  AssertScript->>StagingRepo: gh api (issue labels, tree, PRs)
  StagingRepo-->>AssertScript: JSON responses
  AssertScript-->>Workflow: exit 0 (pass) or exit 1 + failure list
  Workflow->>StagingRepo: query workflow runs → sum durations (cost)
  Workflow->>SpectaclesIssue: post Markdown verdict comment (or create nightly issue)
  alt assertion failed
    Workflow->>StagingRepo: label tracking issue e2e:failed
    Workflow-->>Trigger: exit failure
  else assertion passed
    Workflow-->>Trigger: exit success
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #89: This PR directly implements the /e2e staging-repo dispatcher workflow, e2e-assert.py, e2e-setup-staging.sh, the happy-path-feature scenario fixture, and all acceptance criteria described in that issue.
  • #90: The PR delivers the Layer-1 dispatcher infrastructure that issue #90 explicitly lists as a prerequisite for its Layer-2 release-tag E2E testing system.

Poem

🐇 Hop, hop — the staging repo waits,
A synthetic issue walks through all the gates.
Labels tick from seed to sdd:done,
Assertions checked, the verdict: 0, we won!
Token costs tallied, a nightly report appears,
The rabbit ships E2E — no manual tears! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(e2e): /e2e staging-repo dispatcher (walking skeleton)' accurately summarizes the main change—a new e2e dispatcher workflow for running end-to-end tests on a staging repository.
Description check ✅ Passed The description comprehensively explains the feature, files involved, design divergences from the issue, verification steps, and deferred work. It is directly related to the changeset.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #89: dispatcher triggered by /e2e and nightly schedule, provisions staging repo, seeds synthetic issues, polls for lifecycle completion, validates terminal state, and reports results with cost estimates.
Out of Scope Changes check ✅ Passed All changes are in scope: workflow orchestrator, assertion script, setup script, fixture files, and documentation. No extraneous modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/89-e2e-staging-dispatcher

Comment @coderabbitai help to get the list of available commands and usage tips.

The compiled locks use engine: claude and read CLAUDE_CODE_OAUTH_TOKEN, not
ANTHROPIC_API_KEY. Update the staging setup script and docs to match, and add
GH_AW_OTEL_ENDPOINT to the optional telemetry secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with 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.

Inline comments:
In @.github/workflows/e2e-dispatch.yml:
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.

In `@docs/sdd/e2e.md`:
- Line 17: The token budget limit for the issue.md file in the documentation is
stated as "≤ 50 tokens" but conflicts with the PR objectives which specify "~30
tokens per fixture". Update the documentation in the e2e.md file at the location
describing `tests/fixtures/e2e/<scenario>/issue.md` to align the token limit
with the actual constraint (either change it to "~30 tokens" or clarify the
correct intended boundary) to ensure consistency with the stated PR objectives.

In `@scripts/e2e-setup-staging.sh`:
- Around line 94-95: The current error handling on lines 94-95 suppresses all
failures from the gh api command indiscriminately, including auth, permission,
and API errors, which can hide real issues and leave branch protection in place.
Instead of using the broad || operator that ignores any gh api failure, capture
the actual error output from the gh api DELETE command and only suppress the
specific case where branch protection doesn't exist, while allowing auth
failures, permission errors, and other API errors to propagate and fail the
script appropriately. This will ensure that later operations like the --direct
install fail in the right place with clear error messages rather than silently
continuing with protected branches.
- Around line 110-120: The set_secret function currently silently skips missing
secrets with just a warning, but required secrets like APP_PRIVATE_KEY and
ANTHROPIC_API_KEY should cause the script to fail with an error instead.
Additionally, when the run command executes in dry-run mode, it echoes the full
gh secret set command including the secret value in the --body parameter,
exposing sensitive information. Modify the set_secret function to differentiate
between required and optional secrets, exiting with an error for required ones
that are missing, and refactor the gh secret set command execution to avoid
echoing the secret value in dry-run mode, either by passing the secret via stdin
instead of --body parameter, or by using a different approach that doesn't
expose the sensitive value in the command output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a883194-9ee0-4eeb-8229-14e57e3af769

📥 Commits

Reviewing files that changed from the base of the PR and between 9c060c5 and 21c3983.

📒 Files selected for processing (7)
  • .github/workflows/e2e-dispatch.yml
  • docs/sdd/e2e.md
  • mkdocs.yml
  • scripts/e2e-assert.py
  • scripts/e2e-setup-staging.sh
  • tests/fixtures/e2e/happy-path-feature/expectations.yml
  • tests/fixtures/e2e/happy-path-feature/issue.md

Comment on lines +54 to +58
permissions:
contents: read
issues: read
pull-requests: read
steps:

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.

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

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

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

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.

Comment on lines +140 to +145
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

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.

Comment on lines +157 to +159
repositories: |
${{ github.event.repository.name }}
${{ steps.gate.outputs.staging_repo }}

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.

Comment on lines +176 to +188
- 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

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.

Comment thread docs/sdd/e2e.md
spectacles ref under test. The staging wrappers call the hosted locks via
`uses: …@<ref>`, so the staging run exercises the PR's code — no lock push.
3. It seeds a synthetic tracking issue from
`tests/fixtures/e2e/<scenario>/issue.md` (≤ 50 tokens to bound cost), with

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 | 🟡 Minor | ⚡ Quick win

Token bound inconsistency between documentation and PR objectives.

The documentation states issue.md should be "≤ 50 tokens", but the PR objectives describe fixtures as "bounded to ~30 tokens per fixture to control LLM costs." While this discrepancy is minor, clarifying the intended token budget would prevent future confusion when creating additional scenario fixtures.

🤖 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 `@docs/sdd/e2e.md` at line 17, The token budget limit for the issue.md file in
the documentation is stated as "≤ 50 tokens" but conflicts with the PR
objectives which specify "~30 tokens per fixture". Update the documentation in
the e2e.md file at the location describing
`tests/fixtures/e2e/<scenario>/issue.md` to align the token limit with the
actual constraint (either change it to "~30 tokens" or clarify the correct
intended boundary) to ensure consistency with the stated PR objectives.

Comment on lines +94 to +95
gh api -X DELETE "repos/$staging/branches/$default_branch/protection" >/dev/null 2>&1 \
|| echo "e2e-setup-staging: no branch protection to clear (ok)"

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

Only ignore the “no branch protection” case.

Line 94 currently swallows every gh api failure, including auth/permission/API errors, and continues as if protection was absent. That can leave staging protected and make the later --direct install fail in a less obvious place.

Proposed fix
-  gh api -X DELETE "repos/$staging/branches/$default_branch/protection" >/dev/null 2>&1 \
-    || echo "e2e-setup-staging: no branch protection to clear (ok)"
+  if ! out="$(gh api -X DELETE "repos/$staging/branches/$default_branch/protection" 2>&1)"; then
+    if printf '%s\n' "$out" | grep -q 'HTTP 404'; then
+      echo "e2e-setup-staging: no branch protection to clear (ok)"
+    else
+      printf '%s\n' "$out" >&2
+      echo "error: failed to clear branch protection on $staging@$default_branch" >&2
+      exit 1
+    fi
+  fi
 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
gh api -X DELETE "repos/$staging/branches/$default_branch/protection" >/dev/null 2>&1 \
|| echo "e2e-setup-staging: no branch protection to clear (ok)"
if ! out="$(gh api -X DELETE "repos/$staging/branches/$default_branch/protection" 2>&1)"; then
if printf '%s\n' "$out" | grep -q 'HTTP 404'; then
echo "e2e-setup-staging: no branch protection to clear (ok)"
else
printf '%s\n' "$out" >&2
echo "error: failed to clear branch protection on $staging@$default_branch" >&2
exit 1
fi
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 `@scripts/e2e-setup-staging.sh` around lines 94 - 95, The current error
handling on lines 94-95 suppresses all failures from the gh api command
indiscriminately, including auth, permission, and API errors, which can hide
real issues and leave branch protection in place. Instead of using the broad ||
operator that ignores any gh api failure, capture the actual error output from
the gh api DELETE command and only suppress the specific case where branch
protection doesn't exist, while allowing auth failures, permission errors, and
other API errors to propagate and fail the script appropriately. This will
ensure that later operations like the --direct install fail in the right place
with clear error messages rather than silently continuing with protected
branches.

Comment thread scripts/e2e-setup-staging.sh Outdated
Comment on lines +110 to +120
set_secret() {
local name="$1" value="$2"
if [ -z "$value" ]; then
echo "e2e-setup-staging: WARNING $name not in environment; set it manually"
return
fi
run gh secret set "$name" --repo "$staging" --body "$value"
}
echo "e2e-setup-staging: setting secrets from the environment"
set_secret APP_PRIVATE_KEY "${STAGING_APP_PRIVATE_KEY:-}"
set_secret ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}"

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

Don’t print or silently skip required secrets.

In dry-run mode, run gh secret set ... --body "$value" echoes the private key/API key. Also, the non-dry-run path reports success even when the required APP_PRIVATE_KEY or ANTHROPIC_API_KEY is missing.

Proposed fix
 set_secret() {
   local name="$1" value="$2"
   if [ -z "$value" ]; then
-    echo "e2e-setup-staging: WARNING $name not in environment; set it manually"
-    return
+    echo "error: required secret source for $name is missing from the environment" >&2
+    return 1
+  fi
+  if [ "$dry_run" -eq 1 ]; then
+    echo "DRY-RUN: gh secret set $name --repo $staging --body <redacted>"
+    return 0
   fi
-  run gh secret set "$name" --repo "$staging" --body "$value"
+  gh secret set "$name" --repo "$staging" --body "$value"
 }
 echo "e2e-setup-staging: setting secrets from the environment"
 set_secret APP_PRIVATE_KEY "${STAGING_APP_PRIVATE_KEY:-}"
 set_secret ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}"
🤖 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 `@scripts/e2e-setup-staging.sh` around lines 110 - 120, The set_secret function
currently silently skips missing secrets with just a warning, but required
secrets like APP_PRIVATE_KEY and ANTHROPIC_API_KEY should cause the script to
fail with an error instead. Additionally, when the run command executes in
dry-run mode, it echoes the full gh secret set command including the secret
value in the --body parameter, exposing sensitive information. Modify the
set_secret function to differentiate between required and optional secrets,
exiting with an error for required ones that are missing, and refactor the gh
secret set command execution to avoid echoing the secret value in dry-run mode,
either by passing the secret via stdin instead of --body parameter, or by using
a different approach that doesn't expose the sensitive value in the command
output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test (layer 1): /e2e staging-repo dispatcher for nightly + on-demand end-to-end runs

1 participant