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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
40 changes: 40 additions & 0 deletions scripts/lib/cron-timing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/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 and moves any minute-0 cron to a
# non-zero minute, letting the check and the suggested value agree without
# hand-picking. Uniqueness across files is not guaranteed — hash collisions are
# possible; the no-duplicate-crons test catches any that occur in practice.
# 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.
}
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