| name | schedule |
| description | This skill should be used when creating, listing, or deleting scheduled agent tasks via GitHub Actions cron workflows. It generates workflow YAML files that invoke Soleur skills on a recurring schedule using claude-code-action. |
Schedule Manager
Generate GitHub Actions workflow files that run Soleur skills on a schedule. Two modes:
- Recurring (
--cron): Standard cron-triggered workflow that fires on every matching tick.
- One-time (
--once --at <YYYY-MM-DD>): Single-fire workflow that self-disables after running. Useful for "remind me to do X in 2 weeks" style tasks.
Each schedule becomes a standalone .github/workflows/scheduled-<name>.yml.
When to use this skill vs harness schedule
Two skills exist with the name schedule. They serve different jobs:
Use this (soleur:schedule) when | Use harness schedule when |
|---|
| Push commits, open PRs, modify the user's repo | Analyze, summarize, report — no repo writes |
| Use repo secrets (Doppler, Vercel, Cloudflare) | No secrets needed |
Invoke a Soleur skill (/soleur:<skill>) | Generic Claude API task |
| Run Terraform / migrations / deploys | Read-only research, posting somewhere |
Examples for soleur:schedule:
- "Open a cleanup PR removing feature flag X in 2 weeks" →
--once
- "Run a weekly Terraform drift check" → recurring
Examples for harness schedule:
- "Summarize recent issues every Monday and post to Slack"
- "Check if a vendor's API changed and email the diff"
If the agent doesn't need access to your repo, prefer harness schedule.
Both schedule skills generate GitHub-Actions cron only. For a one-time, autonomous, server-side action that needs fire-time prd secrets or an installation-token repo write (no operator, no GH-Actions runner), use the Inngest path instead: a self-armed oneshot (ADR-046, bespoke logic) or the generic reminder primitive (POST /api/internal/schedule-reminder, no deploy for a comment / registered check). See inngest-oneshot-and-reminder-patterns.md.
Commands
create
Generate a new scheduled workflow.
Step 0a: Mode detection (recurring vs one-time)
Inspect $ARGUMENTS:
- If both
--cron and --once are present → error: Cannot specify both --once and --cron. Stop.
- If neither
--cron nor --once is present → error: Specify either --once <YYYY-MM-DD> or --cron <expression>. Stop. (No silent default — the operator must declare intent.)
- If
--once is present → one-time mode. Validate per Step 0c below, then skip to Step 2 → Step 3b.
- If
--cron is present → recurring mode. Continue with Step 0b below.
Step 0b: Check arguments (recurring mode)
If $ARGUMENTS contains --name, --skill, --cron, and --model flags, extract values directly and skip to Step 2. Validate each value using the same rules below. If any required flag is missing, proceed to Step 1 for the missing parameters only. Optional flags: --timeout (minutes, default 30) and --max-turns (default 30).
Step 0c: Check arguments (one-time mode)
For --once, all five flags are MANDATORY. There is no AskUserQuestion fallback (Step 1 is recurring-only). Operator-supplied values are required so the workflow file is self-documenting at delete/cleanup time. Each value MUST match its regex EXACTLY before substitution into the YAML — these regexes are the load-bearing defense against shell/YAML injection at fire time.
If ANY regex below fails, emit ERROR: --<flag> value '<value>' does not match required pattern <regex> to stderr and stop.
-
--once (mode flag, no value)
-
--at <YYYY-MM-DD> — strict ISO date pattern ^\d{4}-\d{2}-\d{2}$. Default time = 09:00 UTC. Natural-language dates ("2 weeks from now", tomorrow) and ISO datetimes (2026-05-17T03:00) are intentionally rejected. Validate with:
python3 -c "
import re, sys
from datetime import datetime, timezone, timedelta
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', sys.argv[1]):
sys.exit(f\"ERROR: --at value '{sys.argv[1]}' does not match required pattern ^\\\d{{4}}-\\\d{{2}}-\\\d{{2}}\$\")
d = datetime.fromisoformat(sys.argv[1]).replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
if d.date() < now.date():
sys.exit('ERROR: --at date is in the past')
if (d - now) > timedelta(days=50):
sys.exit('ERROR: --at date is more than 50 days out (GHA auto-disables workflows after 60d inactivity; 10d margin)')
" '<AT_VALUE>'
-
--issue <N> — GitHub issue number, pattern ^[1-9][0-9]{0,8}$ (positive integer, no leading zeros, ≤9 digits).
-
--comment <id> — GitHub comment ID, pattern ^[1-9][0-9]{0,18}$ (positive int64, no leading zeros). Look it up via gh api repos/<OWNER>/<REPO>/issues/<N>/comments --jq '.[] | "\(.id) \(.user.login) \(.created_at): \(.body | .[0:80])"' if the operator needs to find it.
-
--name <kebab-case> — schedule name, pattern ^[a-z][a-z0-9-]{0,49}$ (lowercase, leading letter, ≤50 chars). Reject if .github/workflows/scheduled-<name>.yml already exists with the exact error:
ERROR: .github/workflows/scheduled-<name>.yml already exists. Pick a different --name (no --force flag in v1).
Comment integrity capture (D5 — comment-author-pin defense):
The fire-time agent fetches the task spec from the referenced comment body. If the comment is editable between authoring and fire, an attacker with comment-edit access can rewrite the task — single-user incident vector. Pin the comment at create time:
COMMENT_META=$(gh api "repos/${REPO_OWNER_AND_NAME}/issues/comments/${COMMENT_ID}" \
--jq '"\(.user.login)\t\(.created_at)\t\(.updated_at)"' 2>/dev/null) || {
echo "ERROR: --comment <id> not found or not accessible" >&2; exit 1; }
EXPECTED_AUTHOR=$(echo "$COMMENT_META" | cut -f1)
COMMENT_CREATED_AT=$(echo "$COMMENT_META" | cut -f2)
COMMENT_UPDATED_AT=$(echo "$COMMENT_META" | cut -f3)
if [[ "$COMMENT_CREATED_AT" != "$COMMENT_UPDATED_AT" ]]; then
echo "ERROR: comment $COMMENT_ID has been edited (created_at != updated_at)." >&2
echo "Re-post the task spec as a fresh comment and pass the new --comment <id>." >&2
exit 1
fi
Both $EXPECTED_AUTHOR and $COMMENT_CREATED_AT are embedded into the workflow's env: block (Step 3b) and re-checked by the fire-time pre-flight (D5) — see Step 3b below.
Default-branch warning:
If the current branch is not the default branch (gh repo view --json defaultBranchRef --jq .defaultBranchRef.name), print a WARNING (not an error):
WARNING: GHA cron triggers fire only from workflows on the default branch.
Merge this workflow before <FIRE_DATE> or it will not fire.
If any one-time validation fails, stop. Do not write the file.
Step 1: Collect inputs (recurring mode only)
One-time mode does not pass through Step 1 — all flags are mandatory at the command line so the resulting workflow is self-documenting. For recurring mode, use the AskUserQuestion tool to gather missing inputs one at a time:
-
Schedule name — A short kebab-case name (e.g., weekly-security-audit). Validate: lowercase letters, numbers, and hyphens only. Check that .github/workflows/scheduled-<name>.yml does not already exist.
-
Skill to run — Which Soleur skill to invoke. Show available skills:
ls plugins/soleur/skills/*/SKILL.md | sed 's|plugins/soleur/skills/||;s|/SKILL.md||' | sort
Present the list and let the user choose. Prefix with soleur: in the generated prompt.
-
Cron expression — A 5-field POSIX cron expression (minute hour day-of-month month day-of-week).
- Reject named values (
MON, JAN) — GitHub Actions does not support them
- Reject schedules more frequent than every 5 minutes
- Note: GitHub Actions cron has ~15-minute variance in trigger timing
-
Model — Which Claude model to use. Default: claude-sonnet-4-6 (good balance of cost and capability). Accept any valid Anthropic model identifier.
-
Timeout (minutes) — Job-level timeout to prevent runaway billing. Default: 30. Validate: positive integer, minimum 5 minutes.
-
Max turns — Maximum number of agent turns before stopping. Default: 30. Validate: positive integer, minimum 5 turns. Budget formula: plugin overhead (~10 turns) + task tool calls + error buffer (~5). Multi-platform data collection or PR-based persist workflows typically need 40-50 turns.
Step 2: Resolve action SHAs
Pin GitHub Actions to commit SHAs for supply-chain security. For each action (actions/checkout@v4, anthropics/claude-code-action@v1), resolve the SHA:
REF_JSON=$(gh api repos/OWNER/REPO/git/ref/tags/TAG 2>/dev/null)
TYPE=$(echo "$REF_JSON" | jq -r '.object.type')
SHA=$(echo "$REF_JSON" | jq -r '.object.sha')
if [ "$TYPE" = "tag" ]; then
SHA=$(gh api "repos/OWNER/REPO/git/tags/$SHA" --jq '.object.sha')
fi
echo "$SHA"
For workflows that process PRs (e.g., ship-merge, compound-review), use gh pr checks --required for CI gating rather than reimplementing statusCheckRollup filtering in jq — GitHub CLI already respects the repo's required checks configuration.
If gh api fails, do not generate the workflow. Display: "Could not resolve action SHAs. Check network connectivity and gh auth status, then retry." The user can retry when they have network access.
Step 3a: Generate the workflow file (recurring mode)
Skip to Step 3b if running in one-time mode.
Determine repo owner and name:
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
Create .github/workflows/scheduled-<NAME>.yml with this content, replacing all <PLACEHOLDER> values:
name: "Scheduled: <DISPLAY_NAME>"
on:
schedule:
- cron: '<CRON_EXPRESSION>'
workflow_dispatch: {}
concurrency:
group: schedule-<NAME>
cancel-in-progress: false
permissions:
contents: read
issues: write
id-token: write
jobs:
run-schedule:
runs-on: ubuntu-latest
timeout-minutes: <TIMEOUT>
steps:
- name: Checkout repository
uses: actions/checkout@<CHECKOUT_SHA>
- name: Ensure label exists
env:
GH_TOKEN: ${{ github.token }}
run: |
gh label create "scheduled-<NAME>" \
--description "Scheduled: <DISPLAY_NAME>" \
--color "0E8A16" 2>/dev/null || true
- name: Run scheduled skill
uses: anthropics/claude-code-action@<ACTION_SHA>
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/<REPO_OWNER>/<REPO_NAME>.git'
plugins: 'soleur@soleur'
claude_args: >-
--model <MODEL>
--max-turns <MAX_TURNS>
--allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch
prompt: |
Run /soleur:<SKILL_NAME> on this repository.
After your analysis is complete, create a GitHub issue titled
"[Scheduled] <DISPLAY_NAME> - <today's date in YYYY-MM-DD format>"
with the label "scheduled-<NAME>" summarizing your findings.
Step 3b: Generate the workflow file (one-time mode)
For --once, generate the cron expression from --at <YYYY-MM-DD>:
python3 -c "
from datetime import datetime
d = datetime.fromisoformat('<AT_VALUE>')
print(f'0 9 {d.day} {d.month} *')
"
Result is a 5-field cron with explicit single-day + single-month + * year (e.g., 0 9 17 5 *). The * year means the cron will repeat every year — the load-bearing D3 date guard inside the agent prompt aborts non-target-year fires; D4 self-disable as the final agent step revokes the workflow on first successful fire so the cron never matches again. Both defenses must be present.
Defenses summary (D1-D5):
- D1 — runtime context fetch. Task spec is fetched from the referenced comment at fire time, never inlined into the committed YAML. Prevents secret leak via committed prompt.
- D2 — stale-context preamble. Pre-flight verifies issue OPEN, repo not archived, comment matches issue. Prevents wrong action against drifted state.
- D3 — in-prompt date guard (PRIMARY).
[[ $(date -u +%F) == $FIRE_DATE ]] aborts cross-year re-fires. Cannot fail silently.
- D4 — in-prompt self-neutralization (SECONDARY). The agent's last prompt instruction edits the generated workflow YAML to strip the
schedule: trigger and pushes (direct or via PR + auto-merge). MUST live inside the prompt — claude-code-action revokes its App token after this step, so a post-step would silently fail. Replaces the previous gh workflow disable mechanism, which fails at runtime because claude-code-action's App installation token does not honor the workflow's actions: write declaration (#3153). contents: write + pull-requests: write are the load-bearing permissions.
- D5 — comment-author + immutability pin.
EXPECTED_AUTHOR and EXPECTED_CREATED_AT env vars are captured at create time (Step 0c) and re-checked in pre-flight. Prevents "attacker edits the comment between create and fire to swap the task" — the brand-survival single-user incident vector that D1-D4 alone do not cover.
Create .github/workflows/scheduled-<NAME>.yml with this content, replacing all <PLACEHOLDER> values. The HTML markers <!-- once-template-begin --> / <!-- once-template-end --> below frame the canonical template; the test suite extracts between them, so do NOT add new fences inside the markers and do NOT remove them.
name: "Scheduled (once): <DISPLAY_NAME>"
on:
schedule:
- cron: '<ONE_TIME_CRON>'
workflow_dispatch: {}
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
concurrency:
group: schedule-once-<NAME>
cancel-in-progress: false
env:
ISSUE_NUMBER: "<N>"
COMMENT_ID: "<id>"
FIRE_DATE: "<YYYY-MM-DD>"
WORKFLOW_NAME: "scheduled-<NAME>.yml"
EXPECTED_AUTHOR: "<COMMENT_AUTHOR_LOGIN>"
EXPECTED_CREATED_AT: "<COMMENT_CREATED_AT>"
jobs:
run-once:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@<CHECKOUT_SHA>
- name: One-time fire (with self-neutralization)
uses: anthropics/claude-code-action@<ACTION_SHA>
env:
GH_TOKEN: ${{ github.token }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
show_full_output: true
plugin_marketplaces: 'https://github.com/<REPO_OWNER>/<REPO_NAME>.git'
plugins: 'soleur@soleur'
claude_args: >-
--max-turns 25
--allowedTools Bash,Read,Write,Edit,Glob,Grep
prompt: |
## Neutralization primitive (referenced by D3 abort, preflight-failure abort, and the Final step)
To **neutralize** the workflow (prevent any future cron fires), do
the following IN ORDER. The previous mechanism `gh workflow disable
"$WORKFLOW_NAME"` was removed in
installation token does not honor `actions: write`, so the disable
returns 403 regardless of the workflow's declared permissions.
1. **Idempotency precheck.** Read `.github/workflows/$WORKFLOW_NAME`.
If the `on:` block has already had `schedule:` removed (or only
contains `workflow_dispatch:`), the workflow is already
neutralized — skip to step 6 (success, no-op).
2. **Edit YAML.** Use the Read+Edit tools (NOT shell `sed`/`awk` —
shell-based YAML mutation has a long history of corrupting
workflow files in CI) to remove the `schedule:` key and its
child list under `on:`. Leave any other triggers
(`workflow_dispatch:`, etc.) intact. If `schedule:` is the ONLY
trigger, replace the entire `on:` block with `on:\n workflow_dispatch:`
so the file remains a valid GHA workflow that can be manually
invoked for forensic purposes.
3. **Stage and guard against no-op commit.**
`git add .github/workflows/$WORKFLOW_NAME` then
`git diff --cached --quiet`. If the diff is empty (exit 0), the
file was already neutralized between step 1 and step 2 — skip
to step 6. (See learning `2026-03-02-github-actions-auto-push-vs-pr-for-bot-content.md`
— `git commit` does not fail on empty diff; explicit guard is
required.)
4. **Configure git identity, then commit.**
`claude-code-action@v1` does not pre-configure `git config
user.name`/`user.email` inside the bash subprocess; without
this step `git commit` aborts with "Author identity unknown."
(This is the canonical pattern across Soleur's other scheduled
workflows that push from inside `claude-code-action`.)
Run:
```bash
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "chore(schedule): neutralize one-time workflow $WORKFLOW_NAME (post-fire cleanup, #$ISSUE_NUMBER)"
```
5. **Push — direct first, PR fallback.**
- **5a.** Try direct push:
`git push origin HEAD:${{ github.event.repository.default_branch }}`.
If exit 0, neutralization succeeded — go to step 6.
- **5b.** If direct push fails (branch protection / required
status checks): **first check whether a stale neutralization
PR already exists** for this workflow:
`EXISTING=$(gh pr list --search "head:chore/neutralize-$WORKFLOW_NAME" --state open --json url --jq '.[0].url // empty')`.
If `$EXISTING` is non-empty, treat as a successful handoff
(the prior fire already filed a cleanup PR awaiting review)
— go to step 6 without opening a duplicate. Otherwise create
an ephemeral branch
`chore/neutralize-$WORKFLOW_NAME-$(date -u +%Y%m%d%H%M%S)`,
push it, then open a PR via
`gh pr create --base "${{ github.event.repository.default_branch }}" --head "$BRANCH" --title "chore(schedule): neutralize $WORKFLOW_NAME" --body "Auto-cleanup after one-time fire of #$ISSUE_NUMBER. Removes the schedule: trigger from the generated --once workflow file. See plugins/soleur/skills/schedule/SKILL.md (D4 defense)."`.
Then attempt auto-merge under the merge-main lock so
parallel CC sessions don't queue concurrent auto-merges
(the `--` separator terminates `with_lock`'s positional
args; required):
`bash .claude/hooks/lib/session-state.sh with_lock merge-main 600 -- gh pr merge --squash --auto "$PR_URL" 2>/tmp/merge.err`.
If the wrapper returns rc=99 (`>600s` contention), the
merge was NOT queued — surface to the operator and retry
rather than treating the auto-merge as successful.
If `merge.err` contains `auto-merge is not allowed`, the user
repo has `allow_auto_merge: false` — the PR is open and
waiting on a human reviewer; that is still a successful
neutralization handoff (D3 catches any re-fire before the PR
lands).
6. **Success.** No fallback comment posted; the task-result
comment from the main work suffices.
7. **Both legs failed.** If step 5a errored AND step 5b
PR-creation errored (NOT auto-merge — auto-merge unavailability
is acceptable), post the fallback comment to issue
"Workflow ran but auto-cleanup failed (direct push: <err>; PR
create: <err>). Operator action required: edit
`.github/workflows/$WORKFLOW_NAME` to remove the `schedule:`
trigger (the same edit this run attempted), OR install the
Anthropic Claude GitHub App as a bypass-actor on your default
branch ruleset, OR install a custom GitHub App with `actions:
write` and re-run with `gh workflow disable`."
1. **Date guard (PRIMARY cross-year defense, D3):**
First, refuse to run if `$FIRE_DATE` is empty or malformed:
`[[ "$FIRE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "FIRE_DATE empty or malformed: '$FIRE_DATE'"; <invoke neutralization primitive>; exit 0; }`
Then assert the calendar match:
`[[ "$(date -u +%F)" == "$FIRE_DATE" ]]` must be true. If false,
invoke the Neutralization primitive above and exit 0. Take no
other action.
This is D3, the load-bearing defense against cron `0 9 <day> <month> *`
re-firing every year. Cannot fail silently.
2. **Idempotency:** if the workflow's `on:` block no longer
contains `schedule:` (already neutralized), exit 0 immediately.
No commit needed; the cron will not fire again from this file.
3. **Repo not archived:**
`[[ "$(gh repo view --json isArchived --jq .isArchived)" == "false" ]]`.
4. **Issue OPEN + same repo:** fetch
`gh issue view "$ISSUE_NUMBER" --json state,repository_url`. The state
must be OPEN, and `repository_url` must end in `${{ github.repository }}`.
5. **Comment exists + matches issue:**
`gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" --jq .issue_url`
must end in `/issues/$ISSUE_NUMBER`.
6. **Comment-author pin (D5, FIRST half):** the comment's author MUST equal `$EXPECTED_AUTHOR`.
`actual_author=$(gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" --jq .user.login)`
then `[[ "$actual_author" == "$EXPECTED_AUTHOR" ]]`.
7. **Comment-immutability pin (D5, SECOND half):** the comment MUST NOT have been edited after authoring.
`meta=$(gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" --jq '"\(.created_at)\t\(.updated_at)"')`
then verify `created_at == EXPECTED_CREATED_AT` AND `created_at == updated_at`.
Reject on mismatch — an edited comment between schedule and fire is the brand-survival vector D5 is designed to catch.
If ANY pre-flight check fails: post a single observation comment to issue
Neutralization primitive above and exit 0. Take no other action.
Fetch the documented task spec from the referenced comment:
`body=$(gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" --jq .body)`
If `$body` is empty (`[[ -z "$body" ]]`), treat as a pre-flight failure: post observation comment "comment body is empty", invoke the Neutralization primitive, exit 0.
Otherwise execute the documented work as instructed by `$body`. When complete, post results as a follow-up comment on issue
Invoke the Neutralization primitive above. This is D4 — the
secondary self-cleanup. D3 (the date guard above) is the primary
cross-year defense; D3 is structural (cron AND date both must
match) and cannot fail silently.
Do NOT add any agent-driven post-step (writes) — `claude-code-action`
revokes its App-installation token after this step, so any push or
PR-create from a YAML-level post-step using the action's token
would silently fail. The Post-fire verification post-step below
this action is exempt: it uses `secrets.GITHUB_TOKEN` (workflow
scope, not action scope) and only READS via the contents API.
After the Neutralization primitive completes, also post an
observable signal into the issue thread (separate from the
workflow conclusion, which is enforced by the post-step below).
Read the workflow file back from the default branch via the
contents API:
```bash
CONTENT=$(gh api "repos/${{ github.repository }}/contents/.github/workflows/$WORKFLOW_NAME" --jq .content | base64 -d)
STILL_HAS_SCHEDULE=$(printf '%s' "$CONTENT" | grep -cE '^[[:space:]]*schedule:' || true)
HAS_DISPATCH=$(printf '%s' "$CONTENT" | grep -cE '^[[:space:]]*workflow_dispatch:' || true)
```
Expected post-neutralization state: `STILL_HAS_SCHEDULE == 0` AND
`HAS_DISPATCH >= 1`. If verification FAILS (schedule: still
present OR dispatch: missing), post a follow-up comment to issue
"Workflow neutralization claimed success but post-fire
verification shows `schedule:` still present (or
`workflow_dispatch:` removed) on the default branch. Manual
intervention required: edit `.github/workflows/$WORKFLOW_NAME` to
remove the `schedule:` trigger. See the schedule SKILL.md
'Known Limitations' section for the manual neutralization recipe."
Note: an `exit 1` inside this prompt is SWALLOWED by
claude-code-action's tool-call boundary — agent tool exits
propagate to the SDK transcript, not to the workflow conclusion.
The load-bearing enforcement is the post-step below this action,
which runs in the GHA shell and propagates its exit code to the
workflow conclusion. This in-prompt verification provides the
observable signal (issue comment); the post-step provides the
non-success conclusion.
After the claude-code-action step, add this post-step (outside the action,
runs in the GHA shell — exit codes propagate to the workflow conclusion):
- name: Post-fire verification (#3403)
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
STILL_HAS_SCHEDULE=99
HAS_DISPATCH=0
for attempt in 1 2 3; do
CONTENT=$(gh api "repos/$REPO/contents/.github/workflows/$WORKFLOW_NAME" --jq .content 2>/dev/null | base64 -d || true)
STILL_HAS_SCHEDULE=$(printf '%s' "$CONTENT" | grep -cE '^[[:space:]]*schedule:' || true)
HAS_DISPATCH=$(printf '%s' "$CONTENT" | grep -cE '^[[:space:]]*workflow_dispatch:' || true)
if [[ "$STILL_HAS_SCHEDULE" == "0" && "$HAS_DISPATCH" -ge "1" ]]; then
echo "::notice::Post-fire verification passed (attempt $attempt)."
exit 0
fi
echo "Attempt $attempt: schedule=$STILL_HAS_SCHEDULE dispatch=$HAS_DISPATCH — retrying after replication lag."
sleep 10
done
echo "::error::Post-fire verification failed: schedule=$STILL_HAS_SCHEDULE dispatch=$HAS_DISPATCH"
exit 1
YAML write verification (one-time mode — same primitive as recurring, additional asserts on the one-time-specific env block):
python3 -c "
import re, sys, yaml
d = yaml.safe_load(open(sys.argv[1]))
assert d['on']['schedule'][0]['cron'] == '<ONE_TIME_CRON>', 'cron mismatch'
assert d['env']['ISSUE_NUMBER'] == '<N>', 'ISSUE_NUMBER mismatch'
assert re.fullmatch(r'\d{4}-\d{2}-\d{2}', d['env']['FIRE_DATE']), 'FIRE_DATE empty or malformed'
assert d['env']['FIRE_DATE'] == '<YYYY-MM-DD>', 'FIRE_DATE substitution mismatch'
assert d['env'].get('EXPECTED_AUTHOR'), 'EXPECTED_AUTHOR missing (D5 author-pin defense)'
assert d['env'].get('EXPECTED_CREATED_AT'), 'EXPECTED_CREATED_AT missing (D5 immutability pin)'
assert d['permissions']['contents'] == 'write', 'contents:write missing (D4 neutralization commit will fail)'
assert d['permissions']['pull-requests'] == 'write', 'pull-requests:write missing (D4 PR-fallback will fail)'
# Anti-regression (#3153): actions:write is NOT in the canonical template.
# The Anthropic GitHub App's installation manifest caps actions:* at READ;
# declaring actions:write at the workflow level cannot widen the App's
# effective scope and only creates false confidence for future maintainers.
assert 'actions' not in d['permissions'] or d['permissions']['actions'] != 'write', \
'actions:write should not be in --once template (App token does not honor it; see #3153)'
" .github/workflows/scheduled-<NAME>.yml
Step 4: Validate and confirm
After writing the file, validate the YAML syntax:
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/scheduled-<NAME>.yml'))" 2>&1 || echo "WARNING: YAML syntax error detected"
Display a summary:
Schedule created: .github/workflows/scheduled-<NAME>.yml
Name: <DISPLAY_NAME>
Skill: /soleur:<SKILL_NAME>
Cron: <CRON_EXPRESSION>
Model: <MODEL>
Timeout: <TIMEOUT> minutes
Max turns: <MAX_TURNS>
Output: GitHub Issues
Prerequisites:
- ANTHROPIC_API_KEY must be set as a repository secret
- .claude-plugin/marketplace.json must exist at the repo root
The schedule activates once this file is merged to the default branch.
Step 5: Verify workflow after merge
After the PR containing the new workflow is merged to the default branch, trigger a manual run and verify it succeeds:
gh workflow run scheduled-<NAME>.yml
sleep 5
RUN_ID=$(gh run list --workflow=scheduled-<NAME>.yml --limit=1 --json databaseId --jq '.[0].databaseId')
Poll for completion with the Monitor tool — NEVER gh run watch in the foreground and NEVER Bash run_in_background (hr-monitor-not-run-in-background-for-polling; hook-enforced by background-poll-prefer-monitor.sh). gh run watch blocks the turn opaquely; a backgrounded poll fails silently (PR #4512). Use the Monitor tool with a state-change + heartbeat loop, mirroring plugins/soleur/skills/ship/SKILL.md Phase 7 / Step 3:
prev=""; i=0
while true; do
i=$((i+1))
s=$(gh run view "$RUN_ID" --json status,conclusion --jq '"\(.status) \(.conclusion // "-")"' 2>&1) || s="fetch-error: $s"
if [[ "$s" != "$prev" ]] || (( i % 6 == 1 )); then echo "$(date +%H:%M:%S) [${i}] run $RUN_ID ${s}"; prev="$s"; fi
echo "$s" | grep -qE "^(completed|fetch-error)" && break
[ "$i" -ge 40 ] && { echo "Run poll timed out after ~20m. Last: $s"; break; }
sleep 30
done
After it reports completed, check the conclusion:
CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion')
if [ "$CONCLUSION" != "success" ]; then
echo "WORKFLOW FAILED — investigate before moving on"
gh run view "$RUN_ID" --log-failed | tail -50
fi
If the run fails, diagnose the issue, fix the workflow file, and re-run. Do not close the task until the workflow has completed successfully at least once.
list
Display all existing scheduled workflows, distinguishing recurring from one-time by cron shape.
If $ARGUMENTS contains --json, output a JSON array with name, cron, mode (string: "recurring" or "one-time"), and skill fields. Otherwise display a formatted list with a mode tag.
ls .github/workflows/scheduled-*.yml 2>/dev/null
If no files found, display: "No scheduled workflows found."
For each file, extract the cron expression and classify by shape. The shape definition below is canonical — if Step 3b ever changes the generated cron pattern, update this classifier in the same edit:
- 5-field cron with explicit single integer for minute, hour, day-of-month, AND month, with
* for year (e.g., 0 9 17 5 *) → [one-time].
- Anything else →
[recurring].
Mode detection (per file):
cron=$(python3 -c "import yaml,sys; d=yaml.safe_load(open(sys.argv[1])); print(d['on']['schedule'][0]['cron'])" "$file")
fields=($cron)
mode=recurring
if [[ "${fields[2]}" =~ ^[0-9]+$ ]] && [[ "${fields[3]}" =~ ^[0-9]+$ ]] && [[ "${fields[4]}" == "*" ]] && [[ "${fields[0]}" =~ ^[0-9]+$ ]] && [[ "${fields[1]}" =~ ^[0-9]+$ ]]; then
mode=one-time
fi
Display:
[recurring] weekly-audit (cron: 0 9 * * 1)
[one-time] verify-hook-fires (cron: 0 9 17 5 *)
V1 reports mode + cron only. Richer state (pending / disabled_inactivity / fired-failed) is deferred — operators can run gh workflow list and gh workflow view <NAME> directly for now.
delete <name>
Remove a scheduled workflow.
-
Verify .github/workflows/scheduled-<name>.yml exists. If not, display: "Schedule '' not found. Run /soleur:schedule list to see available schedules."
-
If $ARGUMENTS contains --yes or --confirm, skip to step 3. Otherwise, use AskUserQuestion tool to confirm: "Delete schedule ''? This will deactivate the cron trigger once merged to the default branch."
-
Remove the file.
-
Display: "Deleted .github/workflows/scheduled-<name>.yml. The schedule will stop once this deletion is merged to the default branch."
Known Limitations
- Skills only — Agents cannot be reliably invoked in unattended CI. Only skills (
/soleur:<skill-name>) are supported.
- Issue output only — All scheduled runs report findings via GitHub Issues. PR and Discord output modes planned for v2.
- No state across runs — Each scheduled run starts fresh. No mechanism to carry state between executions.
- No skill-specific arguments — The template prompt does not pass arguments (e.g.,
--tiers 0,3) to the invoked skill. Manual prompt edit required after generation.
- No cascading priority selection — Generated workflows that select issues by label hardcode a single priority tier. When that tier is empty, the bot sits idle while higher-priority bugs accumulate. Manually add a priority cascade loop (p3 -> p2 -> p1) after generation.
--once requires merge-before-fire. GHA cron triggers fire only from workflows on the default branch. A --once workflow on a feature branch must be merged before its --at date or it will not fire.
--at caps at 50 days. GHA auto-disables workflows after 60 days of inactivity. The 50-day cap leaves a 10-day margin so a freshly merged --once workflow is still active when its cron tick arrives.
- Cron variance ~15 min. GitHub Actions cron schedules trigger on a best-effort basis.
--at 2026-05-17 may fire any time between 09:00 and 09:15 UTC.
--once D4 cleanup costs one extra GHA run on branch-protected repos. When direct push to the default branch is blocked, D4's PR-fallback opens a cleanup PR. Required status checks fire on the ephemeral branch — that's one extra billable run per --once fire. To skip it, add chore/neutralize-* to your branch ruleset's bypass-actor list or disable required checks for that branch pattern.
--once D3 + D4-failure → annual re-fire. If D4's neutralization fails (both direct push and PR-create fail) and the operator does not act on the fallback comment, the cron 0 9 D M * re-fires next year on the same calendar date. D3 (date guard) catches it and immediately invokes neutralization again — no harmful action against drifted state — but the workflow stays active until either the operator intervenes or GHA's 60-day inactivity timer fires after a full quiet year.
--once D4 abort-path silent failure (#3403). A --once schedule whose tracked issue closes pre-fire routes through the abort path. The 2026-05-05 dogfood (#3185) showed that abort-path side-effects (observation comment, neutralization commit) silently fail inside claude-code-action — permission_denials_count: 1, run conclusion success, zero observable side-effects. Root cause TBD by the post-merge sandbox dogfood .github/workflows/scheduled-dogfood-3403.yml. Until then: --once schedules generated before the bundle PR closing #3403 + #3404 + #3407 may not self-neutralize on the abort path; treat them as not-self-cleaning and run the migration sweep gh workflow list --all | grep 'Scheduled (once):' to identify each, then manually neutralize per the PR #3402 recipe. The Post-fire verification block added to Step 3b's prompt ensures the agent never exits success without observable side-effect proof — independent of whatever the sandbox reveals as the root cause.
Sharp Edges
--once widens agent-prompt blast radius via contents: write + pull-requests: write. The fire-time agent's --allowedTools allowlist plus the comment-fetched $body (D1) means a successful prompt-injection in the comment body now has push + PR-create capability, not just gh workflow disable. D5 (comment-author + immutability pin) gates this — but D5 only verifies who authored the comment, not what they wrote. Pin --comment to a high-trust author (yourself or an org admin), and avoid scheduling --once against issues where the pinned commenter could later be compromised.
- Agent-prompt
exit 1 is swallowed by claude-code-action's tool-call boundary. The Bash tool's exit code lands in the SDK transcript as data, not as the workflow step's exit code — the run conclusion stays success. To genuinely fail a workflow conclusion based on agent-side verification, run the verification as a real GHA-shell post-step OUTSIDE the action using secrets.GITHUB_TOKEN (the workflow-scope token; not subject to the action's App-token revocation). The --once template's Post-fire verification post-step is the canonical pattern. See knowledge-base/project/learnings/2026-05-07-claude-code-action-boundaries-and-once-schedule-bundle.md Insight 1.