-
Notifications
You must be signed in to change notification settings - Fork 0
feat(e2e): /e2e staging-repo dispatcher (walking skeleton) #282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: | ||||||||||||||||||||||||||||||||||
| - name: Check out spectacles (fixtures + scripts) | ||||||||||||||||||||||||||||||||||
| uses: actions/checkout@v4 | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's see the file and the specific lines in context
cat -n .github/workflows/e2e-dispatch.yml | head -80Repository: 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 1Repository: norrietaylor/spectacles Length of output: 369 🏁 Script executed: # Verify if the SHA exists for actions/checkout
web_search
GitHub actions/checkout v4 commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5Repository: 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.ymlRepository: 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 1Repository: norrietaylor/spectacles Length of output: 465 🌐 Web query:
💡 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 Citations:
🌐 Web query:
💡 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 Citations:
Pin checkout action to SHA and disable persisted credentials. The workflow uses an unpinned third-party action ( 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
Suggested change
🧰 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 AgentsSources: 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject unsafe staging repo configuration before any write token is minted.
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, locate and examine the e2e-dispatch.yml file
find . -name "e2e-dispatch.yml" -type fRepository: 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.ymlRepository: 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.ymlRepository: norrietaylor/spectacles Length of output: 1041 🏁 Script executed: # Search for where staging_repo is emitted/generated
grep -n "staging_repo" .github/workflows/e2e-dispatch.ymlRepository: norrietaylor/spectacles Length of output: 666 🏁 Script executed: # Search for where STAGING_REPO is set
grep -n "STAGING_REPO" .github/workflows/e2e-dispatch.ymlRepository: 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.ymlRepository: 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.ymlRepository: 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.ymlRepository: 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 -20Repository: 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset persisted staging artifacts, not just stale issues. The reset step only closes prior 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, 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - 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 | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n .github/workflows/e2e-dispatch.yml | head -100Repository: norrietaylor/spectacles
Length of output: 4689
🏁 Script executed:
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 -50Repository: norrietaylor/spectacles
Length of output: 2682
🏁 Script executed:
grep -n "check-runs\|permissions:" .github/workflows/e2e-dispatch.ymlRepository: norrietaylor/spectacles
Length of output: 175
Add
checks: readpermission to the job.The Layer-0 gate at line 140 calls
repos/$REPO/commits/$SHA/check-runswithgithub.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
🤖 Prompt for AI Agents