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
20 changes: 11 additions & 9 deletions .github/rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ when it changed, and who signed for it.

They are **not applied automatically.** Apply them by hand:

This repository is public. The live `protection` ruleset covers `main`, `dev`,
and `staging`. The checked-in `promotion-pointers.json` has no bypass actors.
Whoever applies it must add the owner of `PROMOTION_TOKEN` (or that account's
role) to its bypass list; otherwise Friday's push is refused. The token also
needs Contents and Workflows read and write to publish the staging release.

```sh
gh api -X POST repos/Agent-Field/codeaf/rulesets --input .github/rulesets/dev.json
gh api -X POST repos/Agent-Field/codeaf/rulesets --input .github/rulesets/promotion-pointers.json
Expand All @@ -19,19 +25,15 @@ gh api repos/Agent-Field/codeaf/rulesets --jq '.[] | "\(.id)\t\(.name)"'
gh api -X PUT repos/Agent-Field/codeaf/rulesets/<id> --input .github/rulesets/dev.json
```

## When they can be applied

While `Agent-Field` is on the **free** plan and this repository is **private**,
that combination has no branch rules at all — both the rulesets API and the older
protection API answer `403 Upgrade to GitHub Pro`. Until the org moves to **GitHub
Team** or the repository is **public**, everything in `docs/rules/` is convention
that a careless `git push --force` can undo without being asked a question.
## Inspect the live rules

Apply both rulesets with the commands above the day the repository goes public.
Until then, check whether the API has started working:
The repository is public and its `protection` ruleset is live. Inspect the
rulesets and confirm the promotion account can bypass the pointer rules before
enabling the Friday workflow:

```sh
gh api repos/Agent-Field/codeaf/rulesets
gh api repos/Agent-Field/codeaf/rulesets/<id> --jq .current_user_can_bypass
```

## `required_status_checks` names are job names
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/ci-full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,23 @@ on:
# 09:00 UTC, which is the small hours in California and the morning in India
# — a result waiting on both desks rather than a run nobody sees finish.
- cron: "0 9 * * *"
workflow_call:
inputs:
ref:
required: true
type: string
workflow_dispatch:
inputs:
ref:
description: "commit to check; empty = the branch you dispatch on"
required: false
type: string

permissions:
contents: read

concurrency:
group: full-check-${{ github.ref }}
group: full-check-${{ inputs.ref || github.ref }}
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -68,6 +78,8 @@ jobs:
shard: [0, 1, 2]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
Expand Down Expand Up @@ -139,6 +151,8 @@ jobs:
- { goos: windows, goarch: arm64 }
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
Expand Down Expand Up @@ -177,6 +191,8 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
Expand All @@ -195,7 +211,7 @@ jobs:
needs: [full-tests, cross-build, remote]
# A cancelled job — the runner shutdown this file describes above — is
# not a failure to GitHub, and it is exactly the night to be told about.
if: always() && github.event_name == 'schedule' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
if: always() && !inputs.ref && github.event_name == 'schedule' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-latest
permissions:
issues: write
Expand Down Expand Up @@ -230,6 +246,8 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
Expand Down
258 changes: 258 additions & 0 deletions .github/workflows/promote-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
name: Promote to staging

# THE CUTOFF IS A COMMIT TIME, NOT THE RUN TIME. Scheduled runs here can start
# hours late: the nightly 09:00 UTC check started between 12:59Z and 15:46Z in
# the week this workflow was written. The cron only needs to run after Friday
# 17:00 Toronto time; the first-parent dev commit at that instant is the target.
# The reusable Full check must pass before the staging pointer can move.
# PROMOTION_TOKEN makes the forward-only push and starts Release; without it,
# the check still runs and the workflow reports why staging stayed put.
# SLACK_RELEASE_WEBHOOK sends the same messages as the run summary. Without it,
# the summary still records them and the missing webhook only warns.
on:
schedule:
- cron: "30 22 * * 5"
workflow_dispatch:
inputs:
target:
description: "Empty = current dev tip; cutoff = Friday cutoff; or a dev commit"
type: string
default: ""
dry_run:
description: "Run the check and messages without pushing"
type: boolean
default: false
signal:
description: "Also send the production signal"
type: boolean
default: false

permissions:
contents: read
actions: read

env:
CUTOFF_ZONE: America/Toronto
CUTOFF_WEEKDAY: Friday
CUTOFF_CLOCK: "17:00"

concurrency:
# Schedule and dispatch may overlap: the pre-push ancestry recheck and a
# forward-only push keep either run safe without evicting the other.
group: promote-staging-${{ github.event_name }}
cancel-in-progress: false

jobs:
# The plan captures the old staging and main pointers before this run moves
# anything, so the production signal can describe last week's staging.
plan:
runs-on: ubuntu-latest
outputs:
plan: ${{ steps.choose.outputs.plan }}
candidate: ${{ steps.choose.outputs.candidate }}
outcome: ${{ steps.choose.outputs.outcome }}
staging: ${{ steps.choose.outputs.staging }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Choose the dev commit and preserve the old pointers
id: choose
env:
TARGET: ${{ inputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev +refs/heads/staging:refs/remotes/origin/staging +refs/heads/main:refs/remotes/origin/main
go run ./cmd/codeaf-release promotion-plan --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --zone "$CUTOFF_ZONE" --weekday "$CUTOFF_WEEKDAY" --clock "$CUTOFF_CLOCK" --event "$GITHUB_EVENT_NAME" --target "$TARGET" --run-url "$RUN_URL" --github-output "$GITHUB_OUTPUT"

# A failed plan cannot use the Go tool or its missing outputs, so this small
# independent job still tells the owner why no staging pointer moved.
plan-failed:
needs: [plan]
if: always() && needs.plan.result == 'failure'
runs-on: ubuntu-latest
steps:
# Every job starts in an empty workspace, so the poster script exists here
# only if this job checks the repository out itself.
- uses: actions/checkout@v4
- name: Report a plan that could not choose a commit
env:
DRY_RUN: ${{ inputs.dry_run }}
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}
run: |
set -euo pipefail
marker=""
if [ "${DRY_RUN:-false}" = true ]; then marker="[dry run] "; fi
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
text="${marker}*staging did not move* — the promotion could not choose a dev commit; see the run."
text+=$'\n'"<${run_url}|Promote to staging run>"
jq -n --arg text "$text" '{text: $text}' > message.json
scripts/slack-post.sh message.json

# This job reads the pre-push plan even if the Full check or push later fails.
# always() keeps a skipped promotion check from suppressing the signal.
signal:
needs: [plan]
if: always() && needs.plan.result == 'success' && (github.event_name == 'schedule' || inputs.signal)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Send the signal for the staging commit read before this run
env:
PLAN_JSON: ${{ needs.plan.outputs.plan }}
DRY_RUN: ${{ inputs.dry_run }}
GH_TOKEN: ${{ github.token }}
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}
run: |
set -euo pipefail
printf '%s\n' "$PLAN_JSON" > plan.json
git fetch --no-tags origin +refs/heads/staging:refs/remotes/origin/staging +refs/heads/main:refs/remotes/origin/main
gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json || printf '[]\n' > releases.json
go run ./cmd/codeaf-release promotion-published --releases releases.json --sha "$(jq -r .staging plan.json)" > published.json
published_at="$(jq -r '.published_at // ""' published.json)"
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase signal --published-at "$published_at" --dry-run="${DRY_RUN:-false}" > message.json
scripts/slack-post.sh message.json

# A callable Full check runs only for a real promotion candidate. Its page
# job requests issues:write even when skipped, so the caller grants it here.
full_check:
needs: [plan]
if: needs.plan.outputs.outcome == 'promote'
permissions:
contents: read
issues: write
uses: ./.github/workflows/ci-full.yml
with:
ref: ${{ needs.plan.outputs.candidate }}

# always() defeats GitHub's skipped-ancestor propagation: current and refused
# plans still report even though the Full check job is skipped.
finish:
needs: [plan, full_check]
if: always() && needs.plan.result == 'success' && (needs.full_check.result == 'success' || needs.full_check.result == 'failure' || needs.full_check.result == 'cancelled' || needs.full_check.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The stored GITHUB_TOKEN header would override the promotion token
# in the push URL, so checkout must not persist its credentials.
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Report the check, move staging, and wait for its build
env:
PLAN_JSON: ${{ needs.plan.outputs.plan }}
CANDIDATE: ${{ needs.plan.outputs.candidate }}
CHECK_RESULT: ${{ needs.full_check.result }}
DRY_RUN: ${{ inputs.dry_run }}
PROMOTION_TOKEN: ${{ secrets.PROMOTION_TOKEN }}
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
run: |
set -Eeuo pipefail
push_done=false
report_unexpected_error() {
rc=$?
trap - ERR
set +e
marker=""
if [ "${DRY_RUN:-false}" = true ]; then marker="[dry run] "; fi
if [ "$push_done" = true ]; then
text="${marker}*staging moved* to \`${CANDIDATE:0:10}\` but the promotion could not report its build; see the run."
else
text="${marker}*staging did not move* — the promotion step failed before pushing; see the run."
fi
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
text+=$'\n'"<${run_url}|Promote to staging run>"
jq -n --arg text "$text" '{text: $text}' > fallback.json && scripts/slack-post.sh fallback.json
exit "$rc"
}
trap 'report_unexpected_error' ERR
printf '%s\n' "$PLAN_JSON" > plan.json
go run ./cmd/codeaf-release promotion-decision --plan plan.json --check-result "$CHECK_RESULT" --dry-run="${DRY_RUN:-false}" --token-set="$([ -n "$PROMOTION_TOKEN" ] && echo true || echo false)" > decision.json
if [ "$(jq -r .action decision.json)" = report ]; then
phase="$(jq -r .phase decision.json)"
jobs=""
if [ "$phase" = check ] && [ "$CHECK_RESULT" != success ]; then
gh run view "$GITHUB_RUN_ID" --json jobs > jobs.json || printf '{"jobs":[]}\n' > jobs.json
jobs="$(go run ./cmd/codeaf-release promotion-jobs --file jobs.json)"
fi
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase "$phase" --check-result "$CHECK_RESULT" --jobs "$jobs" --dry-run="${DRY_RUN:-false}" > message.json
scripts/slack-post.sh message.json
if [ "$(jq -r .status decision.json)" = success ]; then exit 0; fi
trap - ERR
exit 1
fi
if ! git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev +refs/heads/staging:refs/remotes/origin/staging 2> fetch.log; then
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file fetch.log)" > message.json
scripts/slack-post.sh message.json
trap - ERR
exit 1
fi
if ! state="$(go run ./cmd/codeaf-release promotion-recheck --candidate "$CANDIDATE" 2> recheck.log)"; then
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file recheck.log)" > message.json
scripts/slack-post.sh message.json
trap - ERR
exit 1
fi
if [ "$state" != promote ]; then
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$state" > message.json
scripts/slack-post.sh message.json
if [ "$state" = current ]; then exit 0; fi
trap - ERR
exit 1
fi
# GITHUB_TOKEN pushes do not start release.yml, so a staging build would not publish.
# GitHub also refuses a GITHUB_TOKEN push when a promoted commit changes .github/workflows/.
pushed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if ! git push "https://x-access-token:${PROMOTION_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$CANDIDATE:refs/heads/staging" 2> push.log; then
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file push.log)" > message.json
scripts/slack-post.sh message.json
trap - ERR
exit 1
fi
push_done=true
# A pushed staging commit is not ready until the release workflow publishes its build.
state=absent
for attempt in $(seq 1 60); do
gh run list --workflow release.yml --event push --branch staging --commit "$CANDIDATE" --limit 100 --json databaseId,headSha,status,conclusion,url,createdAt,attempt > runs.json || printf '[]\n' > runs.json
go run ./cmd/codeaf-release promotion-release --runs runs.json --sha "$CANDIDATE" --pushed-at "$pushed_at" > release-state.json
state="$(jq -r .status release-state.json)"
if [ "$state" != absent ]; then break; fi
sleep 10
done
if [ "$state" = pending ]; then
for attempt in $(seq 1 180); do
gh run list --workflow release.yml --event push --branch staging --commit "$CANDIDATE" --limit 100 --json databaseId,headSha,status,conclusion,url,createdAt,attempt > runs.json || printf '[]\n' > runs.json
go run ./cmd/codeaf-release promotion-release --runs runs.json --sha "$CANDIDATE" --pushed-at "$pushed_at" > release-state.json
state="$(jq -r .status release-state.json)"
if [ "$state" != pending ]; then break; fi
sleep 10
done
fi
release_url="$(jq -r '.url // ""' release-state.json)"
tag=""
if [ "$state" = success ]; then
gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json || printf '[]\n' > releases.json
go run ./cmd/codeaf-release promotion-published --releases releases.json --sha "$CANDIDATE" > published.json
tag="$(jq -r '.tag_name // ""' published.json)"
fi
go run ./cmd/codeaf-release promotion-message --plan plan.json --phase release --release-status "$state" --release-url "$release_url" --release-tag "$tag" > message.json
scripts/slack-post.sh message.json
if [ "$state" = success ] && [ -n "$tag" ]; then exit 0; fi
trap - ERR
exit 1
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,16 @@ jobs:
curl -fsSL https://agentfield.ai/get/devaf | bash
```

EOF
fi
if [ "$CHANNEL" = "staging" ]; then
cat >> install.md <<'EOF'
This installs this staging channel as `stageaf` beside codeaf:

```sh
curl -fsSL https://agentfield.ai/get/stageaf | bash
```

EOF
fi
if [ "$CHANNEL" = "stable" ]; then
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ than only in `docs/rules/` because they are the ones that must never be looked u
of the three.** Promotion is the deliberate fast-forward below.
- **`staging` and `main` move by fast-forward onto tested `dev` history** —
`git push origin <sha>:staging`, then `git push origin <sha>:main`, never a merge.
`staging` moves by itself every Friday at the Toronto 17:00 cutoff through
Promote to staging; a person still moves `main`.
- **Pushes publish channel builds.** `dev` and `staging` publish their named
channels; `main` publishes an rc. A person cuts stable by dispatching `Release`
on `main`. The workflow refuses rc or stable commits not already on `staging`,
Expand Down
Loading
Loading