Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/add-to-project-reconcile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ on:
type: boolean
default: false
schedule:
- cron: "0 7 * * *" # daily 07:00 UTC
- cron: "54 7 * * *" # daily 07:54 UTC

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/canary-rollout.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ name: Canary Rollout

on:
schedule:
- cron: "0 */4 * * *" # every 4 hours — autocut → promote-all → sync-issues
- cron: "33 */4 * * *" # every 4 hours at :33 — autocut → promote-all → sync-issues
workflow_dispatch:
inputs:
command:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ jobs:
# Template workflows use placeholder expressions — informational only
./actionlint -ignore 'context' standards/workflows/*.yml || true

- name: Lint scheduled workflow timing
if: always()
# Fail any workflow whose cron fires on the top of the hour (minute 0).
# Standard: standards/ci-standards.md → "Scheduled Workflow Timing".
run: bash scripts/check-cron-timing.sh

shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/compliance-audit-and-improvement.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: Org Standards Compliance Audit

on:
schedule:
- cron: '0 12 * * 5' # Every Friday at 12:00 UTC
- cron: '57 12 * * 5' # Every Friday at 12:57 UTC
workflow_dispatch:
inputs:
target_repo:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/compliance-retrigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Compliance Issue Re-trigger

on:
schedule:
- cron: '0 * * * *' # Hourly, on the hour (≤ one engagement per repo per run)
- cron: '11 * * * *' # Hourly at :11 — off the top of the hour (≤ one engagement per repo per run)
workflow_dispatch:
inputs:
stale_days:
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/cron-timing-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Quality gate for the off-peak scheduled-workflow timing check: the pure helper
# lib, the CI check that consumes it, and their validating bats tests.
#
# Triggered on any PR that touches:
# - scripts/lib/cron-timing.sh
# - scripts/check-cron-timing.sh
# - test/scripts/cron-timing/**
# - .github/workflows/cron-timing-tests.yml
#
# Gate: bats validates that cron_minute_is_zero flags minute-0 crons, that
# cron_offset_minute is deterministic, in 1..59, and de-collides the two
# historical pairs, and that check-cron-timing.sh fails a minute-0 fixture,
# points at the standard, and passes the repo's own (now offset) workflows.
# ShellCheck of the scripts is covered by the existing scripts/**/*.sh job in
# ci.yml. Standard: standards/ci-standards.md → "Scheduled Workflow Timing" (#1052).
# Modeled on .github/workflows/pr-limits-tests.yml.

name: Cron-timing Tests

on:
pull_request:
paths:
- 'scripts/lib/cron-timing.sh'
- 'scripts/check-cron-timing.sh'
- 'test/scripts/cron-timing/**'
- '.github/workflows/cron-timing-tests.yml'
push:
branches:
- main
paths:
- 'scripts/lib/cron-timing.sh'
- 'scripts/check-cron-timing.sh'
- 'test/scripts/cron-timing/**'
- '.github/workflows/cron-timing-tests.yml'

permissions:
contents: read

concurrency:
group: cron-timing-tests-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Validate cron timing
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
# Read-only test job — never pushes; don't leave the token in git config.
persist-credentials: false

- name: Install bats
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends bats

- name: Run bats suite
run: bats --print-output-on-failure test/scripts/cron-timing/
2 changes: 1 addition & 1 deletion .github/workflows/daily-org-status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Daily Org Status

on:
schedule:
- cron: '0 11 * * *' # 6:00 AM CDT (UTC-5) — shifts to 5 AM in winter CST
- cron: '55 11 * * *' # 11:55 UTC — 6:55 AM CDT (UTC-5), shifts to 5:55 AM in winter CST
workflow_dispatch: # allow manual trigger from Actions tab

permissions: {} # no default permissions; each job grants only what it needs
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependabot-rebase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ on:
branches:
- main
schedule:
- cron: '0 */4 * * *' # every 4 hours — safety net when no pushes to main trigger the chain
- cron: '50 */4 * * *' # every 4 hours at :50 — safety net when no pushes to main trigger the chain
workflow_dispatch: # allow manual trigger to flush Dependabot PR queue

concurrency:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/feature-ideation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ name: Feature Research & Ideation (BMAD Analyst)

on:
schedule:
- cron: '0 7 * * 5' # Friday 07:00 UTC (3 AM EDT / 2 AM EST)
- cron: '41 7 * * 5' # Friday 07:41 UTC (3 AM EDT / 2 AM EST)
workflow_dispatch:
inputs:
focus_area:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/org-scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name: Org Scorecard Review

on:
schedule:
- cron: '0 9 * * 1'
- cron: '6 9 * * 1' # Mondays 09:06 UTC
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pinned-version-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name: Pinned-version report

on:
schedule:
- cron: '0 8 * * 1' # Mondays 08:00 UTC
- cron: '31 8 * * 1' # Mondays 08:31 UTC
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-limits-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ name: PR-limits Report

on:
schedule:
- cron: '0 12 * * *' # once daily, 07:00 CDT (UTC-5)
- cron: '44 12 * * *' # once daily, 12:44 UTC — 07:44 CDT (UTC-5)
workflow_dispatch: # allow manual trigger from the Actions tab

permissions: {} # no default permissions; the job grants only what it needs
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/standards-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name: Standards Deploy

on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 08:00 UTC — offset from the Fri 12:00 UTC compliance audit
- cron: '16 8 * * 1' # Every Monday at 08:16 UTC — offset from the Fri compliance audit
workflow_dispatch:
inputs:
target_repo:
Expand Down
2 changes: 1 addition & 1 deletion docs/initiatives/canary-rollout-adr.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ parks at the frontier), and any in-window failure is triaged before promotion, s

### Cadence

[`.github/workflows/canary-rollout.yml`](../../.github/workflows/canary-rollout.yml) runs every **4 hours** (`cron: "0 */4 * * *"`),
[`.github/workflows/canary-rollout.yml`](../../.github/workflows/canary-rollout.yml) runs every **4 hours** (`cron: "33 */4 * * *"`, offset off the top of the hour),
ordering **autocut → promote-all → sync-issues**. `autocut` (#1069, gated by the `CANARY_AUTO_CUT` variable) cuts a new
`<agent>/vX.Y.Z` + moves `next` whenever a registered reusable's blob on its host `main` HEAD differs from the current candidate.
Timer promotion is **arm-gated**: when `CANARY_AUTO_PROMOTE == 'true'` the run advances every PROMOTE-ready agent one ring (BLOCKED
Expand Down
48 changes: 48 additions & 0 deletions scripts/check-cron-timing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
# check-cron-timing.sh — fail if any workflow in .github/workflows/ schedules a
# cron on the top of the hour (minute field 0). Enforces the off-peak
# scheduled-workflow timing standard (standards/ci-standards.md → "Scheduled
# Workflow Timing"). Wired into ci.yml's Lint job and
# .dev-lead/scripts/dev-lead-lint.sh.
#
# Usage: check-cron-timing.sh [workflow-dir]
# workflow-dir defaults to the repo's own .github/workflows/. The templates in
# standards/workflows/ are deliberately out of scope — retiming them is
# downstream fan-out (tracked in petry-projects/.github-private#726), not this
# repo obeying the convention.

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/cron-timing.sh
source "${SCRIPT_DIR}/lib/cron-timing.sh"

WORKFLOW_DIR="${1:-${SCRIPT_DIR}/../.github/workflows}"
STANDARD='standards/ci-standards.md → "Scheduled Workflow Timing"'

violations=0
shopt -s nullglob
for wf in "${WORKFLOW_DIR}"/*.yml "${WORKFLOW_DIR}"/*.yaml; do
# Pull every quoted cron expression out of the file. A cron line looks like:
# - cron: '0 7 * * *' # comment
while IFS= read -r expr; do
[ -n "$expr" ] || continue
if cron_minute_is_zero "$expr"; then
{
printf 'FAIL: %s — cron "%s" fires at minute 0 (top of the hour).\n' \
"$(basename "$wf")" "$expr"
printf ' Offset the minute field off :00 per %s.\n' "$STANDARD"
printf ' Suggested minute for this file: %s\n' "$(cron_offset_minute "$wf")"
} >&2
violations=$((violations + 1))
fi
done < <(grep -oE "cron:[[:space:]]*['\"][^'\"]+['\"]" "$wf" \
| sed -E "s/cron:[[:space:]]*['\"]//; s/['\"]$//")
done

if [ "$violations" -gt 0 ]; then
printf '\n%d scheduled workflow(s) fire on the top of the hour. See %s.\n' \
"$violations" "$STANDARD" >&2
exit 1
fi

echo "cron-timing: all scheduled workflows in ${WORKFLOW_DIR} are offset off the top of the hour"
38 changes: 38 additions & 0 deletions scripts/lib/cron-timing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# cron-timing.sh — pure, side-effect-free helpers for the off-peak
# scheduled-workflow timing standard (standards/ci-standards.md → "Scheduled
# Workflow Timing", promoted from the petry-projects/.github-private repo-local
# convention). No external calls; safe to `source` from a workflow, the CI
# check (scripts/check-cron-timing.sh), or a bats test.
#
# The same helpers both *detect* a top-of-the-hour cron and *pick* its
# deterministic replacement minute, so the CI check and the chosen values can
# never disagree, and a newly added workflow gets a defensible minute for free.

# Echo the minute field (field 1) of a 5-field cron expression. Leading and
# trailing whitespace is tolerated (default IFS word-splitting trims it).
cron_minute_field() {
local minute _rest
read -r minute _rest <<<"$1"
printf '%s' "$minute"
}

# Return 0 (true) when the cron minute field is a top-of-the-hour start (a bare
# `0`) — the behaviour the off-peak standard forbids (`0 * * * *`, `0 7 * * *`,
# `0 */4 * * *`, …). Any offset minute (17, 41, */15, …) returns non-zero.
cron_minute_is_zero() {
[[ "$(cron_minute_field "$1")" == "0" ]]
}

# Deterministic off-peak minute (1..59) for a workflow filename. Hashing the
# filename spreads workflows across the hour, de-collides files that previously
# shared a cron, and lets the check and the value agree without hand-picking.
# cksum is POSIX and reproducible on every runner; the basename keeps the result
# path-independent so callers may pass a bare name or a full path.
cron_offset_minute() {
local name checksum
name="$(basename -- "$1")"
checksum="$(printf '%s' "$name" | cksum)"
checksum="${checksum%% *}" # keep the leading numeric checksum only
printf '%s' "$(( checksum % 59 + 1 ))"
Comment thread
don-petry marked this conversation as resolved.
Resolved
}
70 changes: 69 additions & 1 deletion standards/ci-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,74 @@ stale-base revert class) surfaces on the first approval rather than going unnoti

---

## Scheduled Workflow Timing

**Origin.** This is the org-wide promotion of the repo-local "Scheduled
workflows" convention first established in
[`petry-projects/.github-private`](https://github.com/petry-projects/.github-private)
(epic `#722`, story `#726`), where it took that repo from a fleet-worst offender
to **0 minute-0 crons out of 20**. This section is the canonical, fleet-wide
statement of the rule; the `.github-private` repo-local section defers to it, and
so does every downstream repo. (Precedent: the same repo-local → org promotion
path the Cost-reporting section followed.)

### The standard

Every `schedule:` cron in a workflow MUST be offset **off the top of the hour**:

1. **No `0 * * * *`.** An hourly job pinned to minute 0 stacks the whole fleet's
hourly load onto `:00`. If a job must run hourly, pick any other minute
(`11 * * * *`, `37 * * * *`, …).
2. **No minute-0 starts at all.** The minute field of a `schedule:` cron MUST NOT
be `0` — this applies to daily, weekly, and every-N-hours crons alike
(`0 7 * * *`, `0 8 * * 1`, `0 */4 * * *` are all non-compliant). The top of
the hour is GitHub's most contended slot; scheduled workflows there are the
most likely to be **delayed or dropped** under load.
3. **Spread minutes across the hour.** Two workflows in the same repo MUST NOT
share an identical cron expression. Choosing a distinct minute per workflow
both de-collides them and smooths the repo's own load.
4. **Higher-frequency idempotent sweeps.** A safety-net sweep that only needs to
run *eventually* (an every-N-hours reconciler, a chain-stall backstop) SHOULD
use a coarse interval (`*/15`, `*/4h`) with a non-zero minute — never
`0 * * * *`. Because these sweeps are idempotent, the exact minute is
immaterial to correctness; picking a non-zero one keeps them off the
contended slot at zero behavioural cost. Do **not** change a sweep's
*frequency* to satisfy this rule — only its minute.

> **Change the minute field only.** When bringing an existing workflow into
> compliance, edit **only** the cron minute; never the hour, day-of-week, or
> frequency. Retiming *when* or *how often* a workflow runs changes its
> behaviour and is out of scope for a timing-compliance change.

### Picking the minute deterministically

Choose the offset by **hashing the workflow filename**, not by hand, so the CI
check and the chosen value always agree and a newly added workflow gets a
defensible, collision-resistant minute automatically. The canonical recipe lives
in [`scripts/lib/cron-timing.sh`](../scripts/lib/cron-timing.sh)
(`cron_offset_minute`): `cksum` of the basename, `% 59 + 1`, yielding a minute in
**1–59**. Distinct filenames hash to distinct minutes, which is what
de-collides workflows that previously shared a cron.

### CI enforcement

[`scripts/check-cron-timing.sh`](../scripts/check-cron-timing.sh) scans a repo's
`.github/workflows/*.yml`, fails on any cron whose minute field is `0`, and
prints the suggested replacement minute alongside a pointer to this section. It
is wired into this repo's `ci.yml` **Lint** job and its
`.dev-lead/scripts/dev-lead-lint.sh`, and is unit-tested by
[`test/scripts/cron-timing/`](../test/scripts/cron-timing). Downstream repos
adopting the check point it at their own `.github/workflows/` — the rule is the
same everywhere and defers to this section for its rationale.

> **Templates in `standards/workflows/` are deliberately not swept by this
> repo's check.** Retiming a template's cron changes what every consumer repo
> receives, i.e. downstream fan-out — tracked separately in
> [`petry-projects/.github-private#726`](https://github.com/petry-projects/.github-private/issues/726),
> not folded into a source-of-truth timing fix.

---

## Workflow Patterns by Tech Stack

### TypeScript / Node.js (npm)
Expand Down Expand Up @@ -1757,7 +1825,7 @@ org-level workflows that run across all repositories:

### OpenSSF Scorecard (`org-scorecard.yml`)

- **Schedule:** Weekly (Monday 9:00 UTC)
- **Schedule:** Weekly (Monday 09:06 UTC — offset off the top of the hour per [Scheduled Workflow Timing](#scheduled-workflow-timing))
- **Purpose:** Security posture scoring for all public repos
- **Token Requirements (`ORG_SCORECARD_TOKEN`):** Must be a Fine-Grained Personal Access
Token with **Repository access** set to "All repositories" (or specific audit targets).
Expand Down
Loading
Loading