From 4351c397e5b843517d6f6191b8d36ad430b8fda0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 24 Sep 2026 13:43:01 -0400 Subject: [PATCH 1/6] ci: let Full check test a requested commit Accept a required ref from reusable callers and an optional ref from a manual dispatch. Check out that ref in every job and give it a separate concurrency group. Keep the nightly issue job off a called check so a promotion failure cannot page with the caller's SHA. The runbook's by-hand step, which passed a sha to --ref and was refused, becomes --ref dev -f ref=$SHA. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci-full.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index f5bd48406a..b5d18f54c0 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -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: @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 From 376361c0862ecdc35cbb5c067e58b15a3720cd7e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 24 Sep 2026 13:43:04 -0400 Subject: [PATCH 2/6] build: promote the Friday dev cutoff to staging after Full check Choose the latest first-parent dev commit at the Toronto Friday 17:00 committer-time cutoff, run the reusable Full check on it, and fast-forward staging only after a successful result. Recheck both refs immediately before the token-backed push, wait for the release run created by this push, and send every outcome to the run summary and the configured Slack webhook. Report a failed plan and unexpected finish errors even when the release tool fails. Send a separate production signal for the staging pointer captured before the promotion. Keep main manual, and document the retry, dry-run, fine-grained token, bypass, and fallback roads. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/rulesets/README.md | 20 +- .github/workflows/promote-staging.yml | 255 ++++++++ CLAUDE.md | 2 + cmd/codeaf-release/main.go | 18 + cmd/codeaf-release/promotion.go | 687 ++++++++++++++++++++ cmd/codeaf-release/promotion_test.go | 385 +++++++++++ docs/rules/ci.md | 3 + docs/rules/promotion.md | 82 ++- internal/release/promotion_workflow_test.go | 462 +++++++++++++ scripts/slack-post.sh | 15 + 10 files changed, 1909 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/promote-staging.yml create mode 100644 cmd/codeaf-release/promotion.go create mode 100644 cmd/codeaf-release/promotion_test.go create mode 100644 internal/release/promotion_workflow_test.go create mode 100755 scripts/slack-post.sh diff --git a/.github/rulesets/README.md b/.github/rulesets/README.md index 3c2057dcd3..495077f5af 100644 --- a/.github/rulesets/README.md +++ b/.github/rulesets/README.md @@ -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 @@ -19,19 +25,15 @@ gh api repos/Agent-Field/codeaf/rulesets --jq '.[] | "\(.id)\t\(.name)"' gh api -X PUT repos/Agent-Field/codeaf/rulesets/ --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/ --jq .current_user_can_bypass ``` ## `required_status_checks` names are job names diff --git a/.github/workflows/promote-staging.yml b/.github/workflows/promote-staging.yml new file mode 100644 index 0000000000..065c5aac0c --- /dev/null +++ b/.github/workflows/promote-staging.yml @@ -0,0 +1,255 @@ +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: + - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 974f3982da..7e20d64430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 :staging`, then `git push origin :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`, diff --git a/cmd/codeaf-release/main.go b/cmd/codeaf-release/main.go index e04af80cf7..91ca7415ab 100644 --- a/cmd/codeaf-release/main.go +++ b/cmd/codeaf-release/main.go @@ -46,6 +46,22 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 case "prune": err = runPrune(args[1:], stdin, stdout) + case "promotion-plan": + err = runPromotionPlan(args[1:], stdout) + case "promotion-message": + err = runPromotionMessage(args[1:], stdout) + case "promotion-recheck": + err = runPromotionRecheck(args[1:], stdout) + case "promotion-release": + err = runPromotionRelease(args[1:], stdout) + case "promotion-published": + err = runPromotionPublished(args[1:], stdout) + case "promotion-jobs": + err = runPromotionJobs(args[1:], stdout) + case "promotion-push-error": + err = runPromotionPushError(args[1:], stdout) + case "promotion-decision": + err = runPromotionDecision(args[1:], stdout) default: usage(stderr) return 2 @@ -74,6 +90,8 @@ func usage(w io.Writer) { Print stable, rc, dev, staging, or other. prune --channel dev|staging Read lines and print expired tags. + promotion-plan|promotion-decision|promotion-recheck|promotion-release|promotion-published|promotion-jobs|promotion-push-error|promotion-message + Plan, check, and report the staging promotion. See docs/rules/promotion.md. `) } diff --git a/cmd/codeaf-release/promotion.go b/cmd/codeaf-release/promotion.go new file mode 100644 index 0000000000..d6f48fb2d8 --- /dev/null +++ b/cmd/codeaf-release/promotion.go @@ -0,0 +1,687 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// PromotionPlan keeps the refs read before this run moves staging so the main signal names last week's build. +type PromotionPlan struct { + Outcome string `json:"outcome"` + Candidate string `json:"candidate"` + Staging string `json:"staging"` + Main string `json:"main"` + Coverage string `json:"coverage"` + RunURL string `json:"run_url"` + Reason string `json:"reason,omitempty"` + Changes []PromotionChange `json:"changes,omitempty"` +} + +// PromotionChange retains commit subjects with the plan so the report describes the checked candidate. +type PromotionChange struct { + SHA string `json:"sha"` + Subject string `json:"subject"` +} + +// promotionDecision keeps the push gate separate from message rendering so a red check cannot push. +type promotionDecision struct { + Action string `json:"action"` + Phase string `json:"phase,omitempty"` + Status string `json:"status"` +} + +// decidePromotion admits a push only after a successful check and a configured token. +func decidePromotion(plan PromotionPlan, check string, dry, tokenSet bool) promotionDecision { + if plan.Outcome != "promote" { + status := "failure" + if plan.Outcome == "current" { + status = "success" + } + return promotionDecision{"report", "plan", status} + } + if check != "success" { + return promotionDecision{"report", "check", "failure"} + } + if dry { + return promotionDecision{"report", "check", "success"} + } + if !tokenSet { + return promotionDecision{"report", "check", "failure"} + } + return promotionDecision{"push", "", "success"} +} + +// runPromotionDecision reads the saved plan so the workflow has no policy branches of its own. +func runPromotionDecision(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-decision", flag.ContinueOnError) + path := fs.String("plan", "", "") + check := fs.String("check-result", "", "") + dry := fs.Bool("dry-run", false, "") + tokenSet := fs.Bool("token-set", false, "") + if err := fs.Parse(args); err != nil { + return err + } + if *path == "" { + return usageErr("--plan is required") + } + raw, err := os.ReadFile(*path) + if err != nil { + return err + } + var plan PromotionPlan + if err := json.Unmarshal(raw, &plan); err != nil { + return err + } + return json.NewEncoder(out).Encode(decidePromotion(plan, *check, *dry, *tokenSet)) +} + +// gitAt runs read-only git queries in the selected repository fixture or checkout. +func gitAt(repo string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)), nil +} + +// ancestor uses git's ancestry relation because a timestamp cannot prove a safe fast-forward. +func ancestor(repo, older, newer string) bool { + cmd := exec.Command("git", "-C", repo, "merge-base", "--is-ancestor", older, newer) + return cmd.Run() == nil +} + +// cutoffAt constructs each local Friday anew so daylight changes cannot shift the intended hour. +func cutoffAt(now time.Time, zone, weekday, clock string) (time.Time, error) { + loc, err := time.LoadLocation(zone) + if err != nil { + return time.Time{}, err + } + var day time.Weekday + found := false + for i := time.Sunday; i <= time.Saturday; i++ { + if i.String() == weekday { + day = i + found = true + break + } + } + if !found { + return time.Time{}, fmt.Errorf("unknown weekday %q", weekday) + } + parts := strings.Split(clock, ":") + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid cutoff clock %q", clock) + } + hour, e1 := strconv.Atoi(parts[0]) + minute, e2 := strconv.Atoi(parts[1]) + if e1 != nil || e2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return time.Time{}, fmt.Errorf("invalid cutoff clock %q", clock) + } + local := now.In(loc) + days := (int(local.Weekday()) - int(day) + 7) % 7 + date := local.AddDate(0, 0, -days) + cut := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc) + if cut.After(now) { + date = date.AddDate(0, 0, -7) + cut = time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc) + } + return cut, nil +} + +// firstParentCandidate ignores merged side branches because only the dev line marks when work landed. +func firstParentCandidate(repo, ref string, cut time.Time) (string, error) { + // THE CUTOFF IS A COMMIT TIME, NOT THE RUN TIME. Read the complete first-parent + // log in one process; consuming it all also avoids a SIGPIPE from early exit. + line, err := gitAt(repo, "log", "--first-parent", "--format=%H %ct", ref) + if err != nil { + return "", err + } + for _, entry := range strings.Split(line, "\n") { + fields := strings.Fields(entry) + if len(fields) != 2 { + return "", fmt.Errorf("invalid first-parent log entry %q", entry) + } + seconds, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return "", err + } + if !time.Unix(seconds, 0).After(cut) { + return fields[0], nil + } + } + return "", errors.New("no dev commit existed at the cutoff") +} + +// changeList follows first parents so the reported count matches promoted dev merges. +func changeList(repo, from, to string) ([]PromotionChange, error) { + raw, err := gitAt(repo, "log", "--first-parent", "--format=%H%x09%s", from+".."+to) + if err != nil { + return nil, err + } + if raw == "" { + return nil, nil + } + var changes []PromotionChange + for _, line := range strings.Split(raw, "\n") { + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 2 { + changes = append(changes, PromotionChange{parts[0], parts[1]}) + } + } + return changes, nil +} + +// planPromotion freezes pre-push pointers and refuses targets outside dev before choosing an outcome. +func planPromotion(repo string, now time.Time, zone, weekday, clock, target, runURL string) (PromotionPlan, error) { + // THE SIGNAL READS THE POINTERS FROM BEFORE THIS RUN MOVED ANYTHING. + plan := PromotionPlan{RunURL: runURL} + var err error + if plan.Staging, err = gitAt(repo, "rev-parse", "origin/staging"); err != nil { + return plan, err + } + if plan.Main, err = gitAt(repo, "rev-parse", "origin/main"); err != nil { + return plan, err + } + dev, err := gitAt(repo, "rev-parse", "origin/dev") + if err != nil { + return plan, err + } + if target == "cutoff" { + cut, err := cutoffAt(now, zone, weekday, clock) + if err != nil { + return plan, err + } + plan.Coverage = "dev as of " + cut.In(cut.Location()).Format("Mon Jan 2 15:04 MST") + plan.Candidate, err = firstParentCandidate(repo, dev, cut) + if err != nil { + plan.Outcome = "off-dev" + plan.Reason = err.Error() + return plan, nil + } + } else { + plan.Coverage = "dev at " + now.UTC().Format("Mon Jan 2 15:04 MST") + " (chosen by hand)" + if target == "" { + plan.Candidate = dev + } else { + plan.Candidate, err = gitAt(repo, "rev-parse", "--verify", target+"^{commit}") + if err != nil { + plan.Outcome = "off-dev" + plan.Reason = "target does not name a commit on dev" + return plan, nil + } + } + } + if !ancestor(repo, plan.Candidate, dev) { + plan.Outcome = "off-dev" + plan.Reason = "target is not on origin/dev" + return plan, nil + } + switch { + case ancestor(repo, plan.Staging, plan.Candidate): + if plan.Staging == plan.Candidate { + plan.Outcome = "current" + } else { + plan.Outcome = "promote" + plan.Changes, err = changeList(repo, plan.Staging, plan.Candidate) + } + case ancestor(repo, plan.Candidate, plan.Staging): + plan.Outcome = "current" + default: + plan.Outcome = "diverged" + } + return plan, err +} + +// runPromotionPlan writes one JSON plan to both stdout and the caller's job outputs. +func runPromotionPlan(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-plan", flag.ContinueOnError) + nowText := fs.String("now", "", "planning time in RFC3339") + zone := fs.String("zone", "", "") + weekday := fs.String("weekday", "", "") + clock := fs.String("clock", "", "") + target := fs.String("target", "", "") + event := fs.String("event", "workflow_dispatch", "") + runURL := fs.String("run-url", "", "") + repo := fs.String("repo", ".", "") + output := fs.String("github-output", "", "") + if err := fs.Parse(args); err != nil { + return err + } + if *nowText == "" || *zone == "" || *weekday == "" || *clock == "" { + return usageErr("--now, --zone, --weekday, and --clock are required") + } + now, err := time.Parse(time.RFC3339, *nowText) + if err != nil { + return err + } + if *event == "schedule" { + *target = "cutoff" + } + plan, err := planPromotion(*repo, now, *zone, *weekday, *clock, *target, *runURL) + if err != nil { + return err + } + raw, err := json.Marshal(plan) + if err != nil { + return err + } + fmt.Fprintln(out, string(raw)) + if *output != "" { + file, err := os.OpenFile(*output, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + return err + } + defer file.Close() + _, err = fmt.Fprintf(file, "plan=%s\noutcome=%s\ncandidate=%s\nstaging=%s\n", raw, plan.Outcome, plan.Candidate, plan.Staging) + return err + } + return nil +} + +// runPromotionRecheck enforces the same ancestry rule after the long Full check completes. +func runPromotionRecheck(args []string, out io.Writer) error { + // NOTHING IS EVER FORCED. The workflow fetches both refs immediately before this call. + fs := flag.NewFlagSet("promotion-recheck", flag.ContinueOnError) + candidate := fs.String("candidate", "", "") + repo := fs.String("repo", ".", "") + if err := fs.Parse(args); err != nil { + return err + } + if *candidate == "" { + return usageErr("--candidate is required") + } + dev, err := gitAt(*repo, "rev-parse", "origin/dev") + if err != nil { + return err + } + stage, err := gitAt(*repo, "rev-parse", "origin/staging") + if err != nil { + return err + } + result := "diverged" + if !ancestor(*repo, *candidate, dev) { + result = "off-dev" + } else if ancestor(*repo, stage, *candidate) { + if stage == *candidate { + result = "current" + } else { + result = "promote" + } + } else if ancestor(*repo, *candidate, stage) { + result = "current" + } + fmt.Fprintln(out, result) + return nil +} + +// releaseRun holds the fields needed to distinguish this push from an earlier release of the same SHA. +type releaseRun struct { + HeadSHA string `json:"headSha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + DatabaseID int64 `json:"databaseId"` + CreatedAt string `json:"createdAt"` + Attempt int `json:"attempt"` +} + +// publishedRelease supplies the tag and optional publication date for a finished build. +type publishedRelease struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` +} + +// runPromotionJobs names the failed jobs of the called Full check in a short notification. +func runPromotionJobs(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-jobs", flag.ContinueOnError) + path := fs.String("file", "", "") + if err := fs.Parse(args); err != nil { + return err + } + if *path == "" { + return usageErr("--file is required") + } + raw, err := os.ReadFile(*path) + if err != nil { + return err + } + var view struct { + Jobs []struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` + } `json:"jobs"` + } + if err := json.Unmarshal(raw, &view); err != nil { + return err + } + var names []string + for _, job := range view.Jobs { + if job.Conclusion != "success" && job.Conclusion != "skipped" && job.Conclusion != "" { + names = append(names, job.Name) + } + } + _, err = fmt.Fprintln(out, strings.Join(names, ", ")) + return err +} + +// runPromotionPushError keeps the first useful git error so a refused push is actionable. +func runPromotionPushError(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-push-error", flag.ContinueOnError) + path := fs.String("file", "", "") + if err := fs.Parse(args); err != nil { + return err + } + if *path == "" { + return usageErr("--file is required") + } + raw, err := os.ReadFile(*path) + if err != nil { + return err + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + for _, line := range lines { + if strings.Contains(line, "error:") || strings.Contains(line, "fatal:") || strings.Contains(line, "remote:") || strings.Contains(line, "[remote rejected]") { + _, err = fmt.Fprintln(out, line) + return err + } + } + if len(lines) == 0 { + return errors.New("git push failed without an error line") + } + _, err = fmt.Fprintln(out, lines[0]) + return err +} + +// runPromotionPublished requires a staging tag for the candidate before claiming publication. +func runPromotionPublished(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-published", flag.ContinueOnError) + releases := fs.String("releases", "", "") + sha := fs.String("sha", "", "") + if err := fs.Parse(args); err != nil { + return err + } + if *releases == "" || len(*sha) < 12 { + return usageErr("--releases and --sha are required") + } + raw, err := os.ReadFile(*releases) + if err != nil { + return err + } + var rows []publishedRelease + if err := json.Unmarshal(raw, &rows); err != nil { + var pages [][]publishedRelease + if nestedErr := json.Unmarshal(raw, &pages); nestedErr != nil { + return err + } + for _, page := range pages { + rows = append(rows, page...) + } + } + tagPattern := regexp.MustCompile(`^staging-[0-9]{8}-` + regexp.QuoteMeta((*sha)[:12]) + `$`) + for _, row := range rows { + if tagPattern.MatchString(row.TagName) { + return json.NewEncoder(out).Encode(row) + } + } + return json.NewEncoder(out).Encode(publishedRelease{}) +} + +// releaseState is the small result the workflow polls while waiting for publication. +type releaseState struct { + Status string `json:"status"` + URL string `json:"url,omitempty"` + ID int64 `json:"id,omitempty"` +} + +// classifyRelease discards old runs so a repeat push cannot inherit an earlier success. +func classifyRelease(raw []byte, sha string, pushedAt time.Time) (releaseState, error) { + var runs []releaseRun + if err := json.Unmarshal(raw, &runs); err != nil { + return releaseState{}, err + } + var newest *releaseRun + var newestAt time.Time + for _, run := range runs { + if run.HeadSHA != sha { + continue + } + createdAt, err := time.Parse(time.RFC3339, run.CreatedAt) + if err != nil { + return releaseState{}, fmt.Errorf("release run %d createdAt: %w", run.DatabaseID, err) + } + if createdAt.Before(pushedAt.Add(-2 * time.Minute)) { + continue + } + if newest == nil || createdAt.After(newestAt) || (createdAt.Equal(newestAt) && run.Attempt > newest.Attempt) { + copy := run + newest = © + newestAt = createdAt + } + } + if newest == nil { + return releaseState{Status: "absent"}, nil + } + state := releaseState{Status: "pending", URL: newest.URL, ID: newest.DatabaseID} + if newest.Status == "completed" { + state.Status = newest.Conclusion + } + return state, nil +} + +// runPromotionRelease uses the injected push time rather than a tool-local wall clock. +func runPromotionRelease(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-release", flag.ContinueOnError) + runs := fs.String("runs", "", "") + sha := fs.String("sha", "", "") + pushedAtText := fs.String("pushed-at", "", "") + if err := fs.Parse(args); err != nil { + return err + } + if *runs == "" || *sha == "" || *pushedAtText == "" { + return usageErr("--runs, --sha, and --pushed-at are required") + } + pushedAt, err := time.Parse(time.RFC3339, *pushedAtText) + if err != nil { + return err + } + raw, err := os.ReadFile(*runs) + if err != nil { + return err + } + state, err := classifyRelease(raw, *sha, pushedAt) + if err != nil { + return err + } + return json.NewEncoder(out).Encode(state) +} + +// pullSuffix recognizes squash-merge subjects for links without changing their displayed words. +var pullSuffix = regexp.MustCompile(`\s*\(#([0-9]+)\)$`) + +// escapeSlack prevents a commit subject from becoming Slack markup. +func escapeSlack(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) +} + +// short keeps routine status messages scannable while commands retain full SHAs. +func short(sha string) string { + if len(sha) > 10 { + return sha[:10] + } + return sha +} + +// changeLines bounds subject lists so a busy week still fits Slack's message limit. +func changeLines(changes []PromotionChange) string { + var b strings.Builder + for i, change := range changes { + if i >= 20 { + fmt.Fprintf(&b, "\nand %d more", len(changes)-20) + break + } + subject := change.Subject + pr := "" + if match := pullSuffix.FindStringSubmatch(subject); match != nil { + subject = strings.TrimSuffix(subject, match[0]) + pr = " ()" + } + runes := []rune(subject) + if len(runes) > 90 { + subject = string(runes[:89]) + "…" + } + fmt.Fprintf(&b, "\n• %s%s", escapeSlack(subject), pr) + } + return b.String() +} + +// runLink makes every ordinary outcome traceable to the promotion run. +func runLink(plan PromotionPlan) string { + if plan.RunURL == "" { + return "" + } + return "\n<" + plan.RunURL + "|Promote to staging run>" +} + +// prefix marks dry-run notifications before their headline so nobody mistakes them for a push. +func prefix(dry bool) string { + if dry { + return "[dry run] " + } + return "" +} + +// message composes every normal outcome in one place so workflow branches do not drift in wording. +func message(plan PromotionPlan, phase, checkResult, jobs, pushError, releaseStatus, releaseURL, releaseTag, publishedAt string, dry bool, repo string) (string, error) { + id := short(plan.Candidate) + if id == "" { + id = short(plan.Staging) + } + context := fmt.Sprintf("%s (%s)", "`"+id+"`", plan.Coverage) + var body string + switch phase { + case "plan": + switch plan.Outcome { + case "current": + body = fmt.Sprintf("*staging is already current* — Nothing new on dev; staging stays on `%s`. %s", short(plan.Staging), context) + case "diverged": + body = fmt.Sprintf("*staging did not move* — `%s` and `%s` diverged; nothing was forced. A person has to look. %s", short(plan.Staging), id, context) + case "off-dev": + body = fmt.Sprintf("*staging did not move* — %s. %s", escapeSlack(plan.Reason), context) + default: + return "", nil + } + case "check": + if checkResult != "success" { + verb := map[string]string{"failure": "failed", "cancelled": "was cancelled", "skipped": "was skipped"}[checkResult] + if verb == "" { + verb = "did not pass" + } + body = fmt.Sprintf("*staging did not move* — The full check %s on %s.", verb, context) + if jobs != "" { + body += " Failing: " + escapeSlack(jobs) + "." + } + body += fmt.Sprintf(" staging stays on `%s`. Fix it on dev, then run Promote to staging from Actions (leave target empty) to try again.", short(plan.Staging)) + } else if dry { + body = fmt.Sprintf("*staging did not move* — The full check passed. staging would move to %s with %d changes:%s", context, len(plan.Changes), changeLines(plan.Changes)) + } else { + body = fmt.Sprintf("*staging did not move* — The full check passed on %s, but PROMOTION_TOKEN is not configured. Run `git push origin %s:staging` by hand.", context, plan.Candidate) + } + case "push": + if pushError == "current" { + body = fmt.Sprintf("*staging is already current* — staging reached %s during the check; nothing moved.", context) + } else if pushError == "diverged" || pushError == "off-dev" { + body = fmt.Sprintf("*staging did not move* — The refs changed during the check (%s); nothing was forced. A person has to look. %s", pushError, context) + } else { + first := strings.Split(strings.TrimSpace(pushError), "\n")[0] + if token := os.Getenv("PROMOTION_TOKEN"); token != "" { + first = strings.ReplaceAll(first, token, "[token]") + } + body = fmt.Sprintf("*staging did not move* — Push of %s was refused: %s. Run `git push origin %s:staging` by hand after checking the refs.", context, escapeSlack(first), plan.Candidate) + } + case "release": + if releaseStatus == "success" && releaseTag != "" { + body = fmt.Sprintf("*staging moved* to %s · %d changes%s\nBuild `%s` is published. Try it beside codeaf: `curl -fsSL https://agentfield.ai/get/stageaf | bash` · already have it: `stageaf update`.", context, len(plan.Changes), changeLines(plan.Changes), releaseTag) + } else { + body = fmt.Sprintf("*staging moved* to %s, but the build did not publish", context) + if releaseStatus == "absent" { + body += "; no release run appeared within the wait." + } else { + body += fmt.Sprintf(" (%s).", releaseStatus) + if releaseURL != "" { + body += " <" + releaseURL + "|the release run>" + } + } + } + case "signal": + if ancestor(repo, plan.Staging, plan.Main) { + body = fmt.Sprintf("*main already has everything staging had* (`%s`); nothing to release this week.", short(plan.Staging)) + } else { + changes, err := changeList(repo, plan.Main, plan.Staging) + if err != nil { + return "", err + } + since := "" + if publishedAt != "" { + if date, err := time.Parse(time.RFC3339, publishedAt); err == nil { + since = " since " + date.Format("Mon Jan 2") + } + } + body = fmt.Sprintf("*Ready for main* — staging has been on `%s`%s. main is %d changes behind:%s\nWhen you want it released: `git fetch origin && git push origin %s:main` publishes the next rc. Stable is a Release dispatch on main; see docs/rules/promotion.md. Nothing was pushed to main.", short(plan.Staging), since, len(changes), changeLines(changes), plan.Staging) + } + default: + return "", usageErr("unknown message phase %q", phase) + } + footer := runLink(plan) + start := prefix(dry) + maxBody := 2999 - len([]rune(start)) - len([]rune(footer)) + runes := []rune(body) + if len(runes) > maxBody { + runes = append(runes[:maxBody-1], '…') + } + return start + string(runes) + footer, nil +} + +// runPromotionMessage JSON-encodes the text so quotes and newlines remain safe for the webhook. +func runPromotionMessage(args []string, out io.Writer) error { + fs := flag.NewFlagSet("promotion-message", flag.ContinueOnError) + path := fs.String("plan", "", "") + phase := fs.String("phase", "", "") + check := fs.String("check-result", "", "") + jobs := fs.String("jobs", "", "") + pushErr := fs.String("push-error", "", "") + releaseStatus := fs.String("release-status", "", "") + releaseURL := fs.String("release-url", "", "") + releaseTag := fs.String("release-tag", "", "") + publishedAt := fs.String("published-at", "", "") + dry := fs.Bool("dry-run", false, "") + repo := fs.String("repo", ".", "") + if err := fs.Parse(args); err != nil { + return err + } + if *path == "" { + return usageErr("--plan is required") + } + raw, err := os.ReadFile(*path) + if err != nil { + return err + } + var plan PromotionPlan + if err = json.Unmarshal(raw, &plan); err != nil { + return err + } + msg, err := message(plan, *phase, *check, *jobs, *pushErr, *releaseStatus, *releaseURL, *releaseTag, *publishedAt, *dry, *repo) + if err != nil { + return err + } + return json.NewEncoder(out).Encode(map[string]string{"text": msg}) +} diff --git a/cmd/codeaf-release/promotion_test.go b/cmd/codeaf-release/promotion_test.go new file mode 100644 index 0000000000..bc36ff72b1 --- /dev/null +++ b/cmd/codeaf-release/promotion_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// F7: Every promotion function explains its purpose where a future editor changes the policy. +func TestPromotionFunctionsKeepDocComments(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "promotion.go", nil, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Doc == nil { + t.Errorf("%s has no doc comment", fn.Name.Name) + } + } +} + +func instant(t *testing.T, s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v +} +func fixtureGit(t *testing.T, repo string, date string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com"}, args...)...) + cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } + return strings.TrimSpace(string(out)) +} +func fixtureCommit(t *testing.T, repo, date, subject string) string { + t.Helper() + path := filepath.Join(repo, "item") + if err := os.WriteFile(path, []byte(date+subject), 0600); err != nil { + t.Fatal(err) + } + fixtureGit(t, repo, date, "add", "item") + fixtureGit(t, repo, date, "commit", "-m", subject) + return fixtureGit(t, repo, date, "rev-parse", "HEAD") +} +func promotionFixture(t *testing.T) (string, string, string, string) { + t.Helper() + repo := t.TempDir() + date := "2026-09-18T12:00:00Z" + fixtureGit(t, repo, date, "init", "-b", "dev") + base := fixtureCommit(t, repo, date, "base") + old := fixtureCommit(t, repo, "2026-09-25T20:59:00Z", "before cutoff (#1234)") + at := fixtureCommit(t, repo, "2026-09-25T21:00:00Z", "at cutoff") + late := fixtureCommit(t, repo, "2026-09-25T21:00:01Z", "after cutoff") + fixtureGit(t, repo, date, "update-ref", "refs/remotes/origin/dev", late) + fixtureGit(t, repo, date, "update-ref", "refs/remotes/origin/staging", base) + fixtureGit(t, repo, date, "update-ref", "refs/remotes/origin/main", base) + return repo, base, old, at +} + +// C1: The Toronto cutoff stays on the preceding Friday until its exact local instant. +func TestC1TorontoCutoff(t *testing.T) { + for _, row := range []struct{ now, want string }{ + {"2026-09-25T21:00:00Z", "2026-09-25T21:00:00Z"}, + {"2026-09-25T20:59:59Z", "2026-09-18T21:00:00Z"}, + {"2026-09-26T01:30:00Z", "2026-09-25T21:00:00Z"}, + {"2026-10-01T12:00:00Z", "2026-09-25T21:00:00Z"}, + } { + got, err := cutoffAt(instant(t, row.now), "America/Toronto", "Friday", "17:00") + if err != nil || !got.Equal(instant(t, row.want)) { + t.Errorf("%s: %s, %v; want %s", row.now, got, err, row.want) + } + } +} + +// C2: DST changes alter the UTC cutoff without changing Friday at 17:00 locally. +func TestC2TorontoStandardAndDaylightTime(t *testing.T) { + for _, row := range []struct{ now, want string }{ + {"2026-11-06T22:00:00Z", "2026-11-06T22:00:00Z"}, + {"2026-11-06T21:30:00Z", "2026-10-30T21:00:00Z"}, + {"2026-10-30T21:00:00Z", "2026-10-30T21:00:00Z"}, + {"2027-03-12T22:00:00Z", "2027-03-12T22:00:00Z"}, + {"2027-03-19T21:00:00Z", "2027-03-19T21:00:00Z"}, + } { + got, err := cutoffAt(instant(t, row.now), "America/Toronto", "Friday", "17:00") + if err != nil || !got.Equal(instant(t, row.want)) { + t.Errorf("%s: %s, %v; want %s", row.now, got, err, row.want) + } + } +} + +// C3: The latest first-parent commit at or before the cutoff is chosen even after a delayed start. +func TestC3CandidateAtCommitTime(t *testing.T) { + repo, _, _, at := promotionFixture(t) + for _, now := range []string{"2026-09-25T21:00:00Z", "2026-09-26T12:00:00Z"} { + plan, err := planPromotion(repo, instant(t, now), "America/Toronto", "Friday", "17:00", "cutoff", "https://example.test/run") + if err != nil || plan.Candidate != at { + t.Fatalf("%s: %+v %v", now, plan, err) + } + } + // A side commit with a newer time is reachable from dev but never on its first-parent line. + fixtureGit(t, repo, "2026-09-25T20:00:00Z", "checkout", "-b", "side", at) + if err := os.WriteFile(filepath.Join(repo, "side-file"), []byte("side"), 0600); err != nil { + t.Fatal(err) + } + fixtureGit(t, repo, "2026-09-25T20:59:59Z", "add", "side-file") + fixtureGit(t, repo, "2026-09-25T20:59:59Z", "commit", "-m", "side") + side := fixtureGit(t, repo, "2026-09-25T20:59:59Z", "rev-parse", "HEAD") + fixtureGit(t, repo, "2026-09-25T21:01:00Z", "checkout", "dev") + fixtureGit(t, repo, "2026-09-25T21:01:00Z", "merge", "--no-ff", "side", "-m", "merge side") + merge := fixtureGit(t, repo, "2026-09-25T21:01:00Z", "rev-parse", "HEAD") + fixtureGit(t, repo, "2026-09-25T21:01:00Z", "update-ref", "refs/remotes/origin/dev", merge) + got, err := firstParentCandidate(repo, "origin/dev", instant(t, "2026-09-25T21:00:00Z")) + if err != nil || got != at || got == side { + t.Fatalf("first parent = %q, %v", got, err) + } +} + +// C4: Promotion, current, divergence, off-dev, and an empty cutoff line are distinct outcomes. +func TestC4PlanOutcomesAndChanges(t *testing.T) { + repo, base, old, at := promotionFixture(t) + now := instant(t, "2026-09-26T12:00:00Z") + plan, err := planPromotion(repo, now, "America/Toronto", "Friday", "17:00", "cutoff", "run") + if err != nil || plan.Outcome != "promote" || plan.Candidate != at || len(plan.Changes) != 2 || plan.Changes[0].Subject != "at cutoff" || plan.Changes[1].SHA != old { + t.Fatalf("promote: %+v %v", plan, err) + } + fixtureGit(t, repo, "2026-09-18T12:00:00Z", "update-ref", "refs/remotes/origin/staging", at) + plan, _ = planPromotion(repo, now, "America/Toronto", "Friday", "17:00", "cutoff", "run") + if plan.Outcome != "current" { + t.Fatalf("equal: %+v", plan) + } + fixtureGit(t, repo, "2026-09-18T12:00:00Z", "update-ref", "refs/remotes/origin/staging", fixtureGit(t, repo, "2026-09-18T12:00:00Z", "rev-parse", "origin/dev")) + plan, _ = planPromotion(repo, now, "America/Toronto", "Friday", "17:00", "cutoff", "run") + if plan.Outcome != "current" { + t.Fatalf("ahead: %+v", plan) + } + fixtureGit(t, repo, "2026-09-18T12:00:00Z", "checkout", "-b", "extra", base) + extra := fixtureCommit(t, repo, "2026-09-25T20:00:00Z", "extra") + fixtureGit(t, repo, "2026-09-18T12:00:00Z", "update-ref", "refs/remotes/origin/staging", extra) + plan, _ = planPromotion(repo, now, "America/Toronto", "Friday", "17:00", "cutoff", "run") + if plan.Outcome != "diverged" { + t.Fatalf("diverged: %+v", plan) + } + plan, _ = planPromotion(repo, now, "America/Toronto", "Friday", "17:00", extra, "run") + if plan.Outcome != "off-dev" { + t.Fatalf("off dev: %+v", plan) + } + plan, _ = planPromotion(repo, instant(t, "2026-09-11T12:00:00Z"), "America/Toronto", "Friday", "17:00", "cutoff", "run") + if plan.Outcome != "off-dev" || plan.Reason == "" { + t.Fatalf("empty cutoff: %+v", plan) + } +} + +// C5: Empty, cutoff, and explicit dispatch targets resolve to different dev commits. +func TestC5DispatchTargets(t *testing.T) { + repo, _, _, at := promotionFixture(t) + now := instant(t, "2026-09-26T12:00:00Z") + tip, _ := gitAt(repo, "rev-parse", "origin/dev") + for _, row := range []struct{ target, want string }{{"", tip}, {"cutoff", at}, {at, at}} { + plan, err := planPromotion(repo, now, "America/Toronto", "Friday", "17:00", row.target, "run") + if err != nil || plan.Candidate != row.want { + t.Fatalf("target %q: %+v %v", row.target, plan, err) + } + } +} + +// C7: A staging pointer moved to another line is refused before any push can run. +func TestC7RecheckRefusesNonFastForward(t *testing.T) { + repo, base, _, at := promotionFixture(t) + var out strings.Builder + if err := runPromotionRecheck([]string{"--repo", repo, "--candidate", at}, &out); err != nil || strings.TrimSpace(out.String()) != "promote" { + t.Fatalf("initial: %q %v", out.String(), err) + } + fixtureGit(t, repo, "2026-09-25T20:00:00Z", "checkout", "-b", "other", base) + extra := fixtureCommit(t, repo, "2026-09-25T20:00:00Z", "different branch") + fixtureGit(t, repo, "2026-09-25T20:00:00Z", "update-ref", "refs/remotes/origin/staging", extra) + out.Reset() + if err := runPromotionRecheck([]string{"--repo", repo, "--candidate", at}, &out); err != nil || strings.TrimSpace(out.String()) != "diverged" { + t.Fatalf("moved: %q %v", out.String(), err) + } +} + +// C6-C11: Each check, token, push, publication, and dry-run outcome keeps its message and result distinct. +func TestC6ToC11PromotionMessages(t *testing.T) { + repo, base, _, at := promotionFixture(t) + plan, _ := planPromotion(repo, instant(t, "2026-09-26T12:00:00Z"), "America/Toronto", "Friday", "17:00", "cutoff", "https://example.test/run") + for _, result := range []string{"failure", "cancelled", "skipped"} { + msg, _ := message(plan, "check", result, "full tests, remote", "", "", "", "", "", false, repo) + verb := map[string]string{"failure": "failed", "cancelled": "was cancelled", "skipped": "was skipped"}[result] + if !strings.HasPrefix(msg, "*staging did not move*") || !strings.Contains(msg, "The full check "+verb+" on") || !strings.Contains(msg, "full tests, remote") || !strings.Contains(msg, "https://example.test/run") || !strings.Contains(msg, "leave target empty") { + t.Fatalf("%s: %s", result, msg) + } + } + withoutJobs, _ := message(plan, "check", "failure", "", "", "", "", "", "", false, repo) + if strings.Contains(withoutJobs, "Failing:") { + t.Fatal(withoutJobs) + } + noToken, _ := message(plan, "check", "success", "", "", "", "", "", "", false, repo) + if !strings.Contains(noToken, "PROMOTION_TOKEN") || !strings.Contains(noToken, "git push origin "+at+":staging") { + t.Fatal(noToken) + } + dry, _ := message(plan, "check", "success", "", "", "", "", "", "", true, repo) + if !strings.HasPrefix(dry, "[dry run] *staging did not move*") || !strings.Contains(dry, "would move") { + t.Fatal(dry) + } + rejected, _ := message(plan, "push", "", "", "remote: denied\nsecond line", "", "", "", "", false, repo) + if !strings.Contains(rejected, "remote: denied") || strings.Contains(rejected, "second line") || !strings.Contains(rejected, "git push origin "+at+":staging") { + t.Fatal(rejected) + } + good, _ := message(plan, "release", "", "", "", "success", "https://example.test/release", "staging-20260925-"+at[:12], "", false, repo) + if !strings.HasPrefix(good, "*staging moved*") || !strings.Contains(good, "/get/stageaf") || !strings.Contains(good, "stageaf update") { + t.Fatal(good) + } + bad, _ := message(plan, "release", "", "", "", "failure", "https://example.test/release", "", "", false, repo) + if !strings.Contains(bad, "did not publish") || !strings.Contains(bad, "") { + t.Fatal(bad) + } + absent, _ := message(plan, "release", "", "", "", "absent", "", "", "", false, repo) + if !strings.Contains(absent, "no release run appeared") || strings.Contains(absent, "(absent)") { + t.Fatal(absent) + } + plan.Staging = base + plan.Outcome = "current" + current, _ := message(plan, "plan", "", "", "", "", "", "", "", false, repo) + if !strings.HasPrefix(current, "*staging is already current*") || !strings.Contains(current, "Nothing new on dev") || strings.Contains(current, "at the cutoff") { + t.Fatal(current) + } + plan.Outcome = "diverged" + diverged, _ := message(plan, "plan", "", "", "", "", "", "", "", false, repo) + if !strings.HasPrefix(diverged, "*staging did not move*") || !strings.Contains(diverged, "nothing was forced") { + t.Fatal(diverged) + } +} + +// C6-C11: Only a successful Full check with a configured token can enter the push road. +func TestC6ToC11PushDecision(t *testing.T) { + plan := PromotionPlan{Outcome: "promote"} + for _, check := range []string{"failure", "cancelled", "skipped"} { + decision := decidePromotion(plan, check, false, true) + if decision.Action != "report" || decision.Status != "failure" { + t.Fatalf("%s: %+v", check, decision) + } + } + if decision := decidePromotion(plan, "success", false, false); decision.Action != "report" || decision.Status != "failure" { + t.Fatalf("missing token: %+v", decision) + } + if decision := decidePromotion(plan, "success", true, true); decision.Action != "report" || decision.Status != "success" { + t.Fatalf("dry run: %+v", decision) + } + if decision := decidePromotion(plan, "success", false, true); decision.Action != "push" { + t.Fatalf("passed: %+v", decision) + } + for _, row := range []struct{ outcome, status string }{{"current", "success"}, {"diverged", "failure"}, {"off-dev", "failure"}} { + plan.Outcome = row.outcome + decision := decidePromotion(plan, "skipped", false, true) + if decision.Action != "report" || decision.Phase != "plan" || decision.Status != row.status { + t.Fatalf("%s: %+v", row.outcome, decision) + } + } +} + +// C13-C15: Commit subjects are escaped and bounded, and the main signal names the preserved staging SHA. +func TestC13ToC15SlackPayloadAndSignal(t *testing.T) { + repo, base, _, at := promotionFixture(t) + plan, _ := planPromotion(repo, instant(t, "2026-09-26T12:00:00Z"), "America/Toronto", "Friday", "17:00", "cutoff", "https://example.test/run") + plan.Changes = []PromotionChange{{at, "a & \\\"quote\\\" 😃 (#1234)"}} + for i := 0; i < 30; i++ { + plan.Changes = append(plan.Changes, PromotionChange{at, strings.Repeat("a", 100)}) + } + plan.Changes[1].Subject = "line one\nline two" + msg, _ := message(plan, "release", "", "", "", "success", "", "staging-20260925-"+at[:12], "", false, repo) + if !strings.Contains(msg, "& <tag>") || !strings.Contains(msg, "|#1234>") || !strings.Contains(msg, "line one\nline two") || !strings.Contains(msg, "and 11 more") || len([]rune(msg)) >= 3000 { + t.Fatal(msg) + } + raw, err := json.Marshal(map[string]string{"text": msg}) + if err != nil || !json.Valid(raw) { + t.Fatal(err) + } + plan.Staging = at + plan.Main = base + signal, _ := message(plan, "signal", "", "", "", "", "", "", "2026-09-22T12:00:00Z", false, repo) + if !strings.HasPrefix(signal, "*Ready for main*") || strings.Contains(signal, plan.Coverage) || !strings.Contains(signal, "git fetch origin && git push origin "+at+":main") || !strings.Contains(signal, "Nothing was pushed to main") || !strings.Contains(signal, "since Tue Sep 22") { + t.Fatal(signal) + } + plan.Main = at + signal, _ = message(plan, "signal", "", "", "", "", "", "", "", false, repo) + if !strings.HasPrefix(signal, "*main already has everything staging had*") || strings.Contains(signal, plan.Coverage) { + t.Fatal(signal) + } +} + +// C9: Only the matching staging release run can report publication. +func TestC9ReleaseRunClassification(t *testing.T) { + push := instant(t, "2026-09-25T22:00:00Z") + for _, row := range []struct { + name, json, want string + id int64 + }{ + {"none", `[]`, "absent", 0}, + {"old success", `[{"headSha":"a","status":"completed","conclusion":"success","createdAt":"2026-09-25T21:57:59Z","databaseId":1}]`, "absent", 0}, + {"old success and new pending", `[{"headSha":"a","status":"completed","conclusion":"success","createdAt":"2026-09-25T21:57:59Z","databaseId":1},{"headSha":"a","status":"in_progress","createdAt":"2026-09-25T22:01:00Z","databaseId":2}]`, "pending", 2}, + {"newest of two", `[{"headSha":"a","status":"completed","conclusion":"success","createdAt":"2026-09-25T22:01:00Z","databaseId":2},{"headSha":"a","status":"completed","conclusion":"failure","createdAt":"2026-09-25T22:02:00Z","databaseId":3}]`, "failure", 3}, + {"latest attempt", `[{"headSha":"a","status":"completed","conclusion":"failure","createdAt":"2026-09-25T22:01:00Z","databaseId":2,"attempt":1},{"headSha":"a","status":"in_progress","createdAt":"2026-09-25T22:01:00Z","databaseId":2,"attempt":2}]`, "pending", 2}, + } { + t.Run(row.name, func(t *testing.T) { + got, err := classifyRelease([]byte(row.json), "a", push) + if err != nil || got.Status != row.want || got.ID != row.id { + t.Fatalf("%s: %+v %v", row.json, got, err) + } + }) + } +} + +// C7: A rejected push quotes git's first error line rather than its destination preamble. +func TestC7PushErrorSelectsFirstError(t *testing.T) { + path := filepath.Join(t.TempDir(), "push.log") + if err := os.WriteFile(path, []byte("To example.test\nremote: permission denied\nerror: failed to push\n"), 0600); err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := runPromotionPushError([]string{"--file", path}, &out); err != nil || strings.TrimSpace(out.String()) != "remote: permission denied" { + t.Fatalf("error line = %q, %v", out.String(), err) + } +} + +// F1: A check without failed job records omits the empty failure list from its message. +func TestCheckWithoutFailedJobsHasNoFailureList(t *testing.T) { + path := filepath.Join(t.TempDir(), "jobs.json") + if err := os.WriteFile(path, []byte(`{"jobs":[]}`), 0600); err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := runPromotionJobs([]string{"--file", path}, &out); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out.String()) != "" { + t.Fatalf("jobs = %q", out.String()) + } +} + +// F9: Selecting the cutoff candidate reads one first-parent log even with many newer commits. +func TestCandidateReadsOneGitLog(t *testing.T) { + dir := t.TempDir() + count := filepath.Join(dir, "calls") + script := filepath.Join(dir, "git") + content := "#!/bin/sh\nprintf 'call\\n' >> '" + count + "'\nprintf 'new 1790380000\\nold 1000000000\\n'\n" + if err := os.WriteFile(script, []byte(content), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + got, err := firstParentCandidate(dir, "origin/dev", instant(t, "2026-09-25T21:00:00Z")) + if err != nil || got != "old" { + t.Fatalf("candidate = %q, %v", got, err) + } + raw, err := os.ReadFile(count) + if err != nil || strings.Count(string(raw), "call") != 1 { + t.Fatalf("calls = %q, %v", raw, err) + } +} + +// C9: A published staging tag must carry the candidate's first twelve hex characters. +func TestC9PublishedTagMatchesCandidate(t *testing.T) { + const sha = "abcdef0123456789abcdef0123456789abcdef01" + path := filepath.Join(t.TempDir(), "releases.json") + raw := `[[{"tag_name":"staging-20260925-deadbeef0000","published_at":"2026-09-25T22:00:00Z"}],[{"tag_name":"staging-20260925-abcdef012345","published_at":"2026-09-25T23:00:00Z"}]]` + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := runPromotionPublished([]string{"--releases", path, "--sha", sha}, &out); err != nil || !strings.Contains(out.String(), "staging-20260925-abcdef012345") { + t.Fatalf("published = %s, %v", out.String(), err) + } +} diff --git a/docs/rules/ci.md b/docs/rules/ci.md index d97aae0771..c5861ddcd0 100644 --- a/docs/rules/ci.md +++ b/docs/rules/ci.md @@ -6,6 +6,7 @@ | --- | --- | --- | --- | | pull request into `dev`, and every push to `dev` | `.github/workflows/ci.yml` | `light gate`: build, gofmt, vet, the packed corpora, the change entry, the manual law, the laws. `touched packages`: the full suite of every package the change touched. `check`: green only when both are | `light gate` a few minutes; `touched packages` as long as the slowest touched package; `check` when both are in | | pull request into `staging` or `main`, every push to either, and nightly at 09:00 UTC | `.github/workflows/ci-full.yml` | the whole suite, six-platform cross build, the two-machine remote test | tens of minutes | +| Friday after the 17:00 Toronto cutoff, or a manual dispatch | `.github/workflows/promote-staging.yml` | plan the cutoff commit, call Full check on that commit, fast-forward staging if it passes, and report the release and production signal | Full check plus the release build | | every push to `dev`, `staging` or `main`; a manual stable or channel dispatch | `.github/workflows/release.yml` | resolve and guard the tag, test the release surface except on dev, build six binaries with furrow, publish | — | **The light gate is deliberately light.** Work reaches `dev` many times a day, @@ -18,6 +19,8 @@ somebody broke something. not a flaw in the arrangement, it is the arrangement: `dev` is where things are allowed to be briefly wrong, `staging` is where they are not. The full suite is paid for once, on the way into `staging`, instead of on every pull request. +`Full check` is also called by the weekly promotion with the chosen commit as +its `ref`; that run is the check the promotion uses before moving staging. **But "light" never meant "runs a filter nobody remembers".** Until 2026-09-02 the gate's one test step over the engine was `go test -run 'Manual'`, and the diff --git a/docs/rules/promotion.md b/docs/rules/promotion.md index d5e8c438e8..d63fd74516 100644 --- a/docs/rules/promotion.md +++ b/docs/rules/promotion.md @@ -1,18 +1,75 @@ # Promoting, and releasing -## Choosing what to promote +## Weekly staging promotion + +`Promote to staging` runs every Friday after the 17:00 America/Toronto cutoff. +It chooses the newest commit on `dev`'s first-parent line whose **committer time** +is at or before that cutoff. The scheduled run may start hours late, so its +start time cannot choose the commit. `staging` moves only after a fresh reusable +`Full check` run against that exact commit succeeds. A successful push starts +`Release`; the promotion waits for its staging build and reports its tag. + +The workflow sends each message to the run summary and, when configured, to +the channel attached to `SLACK_RELEASE_WEBHOOK`: + +- **Moved and published:** install the named staging build with + `curl -fsSL https://agentfield.ai/get/stageaf | bash`, or run `stageaf update`. +- **Nothing new:** staging already contains the chosen dev commit; no action. +- **Full check did not pass:** the message names the failed jobs and links the + run. Fix `dev`, then retry from Actions. +- **Target off dev or branches diverged:** nothing moves. Inspect the branch + pointers; never force a promotion. +- **Token missing:** the check passed, but nothing moved. Configure the token + or use the exact by-hand push in the message. +- **Push refused:** inspect the first git error and the branch pointers before + using the by-hand command in the message. +- **Moved but did not publish:** inspect the linked `Release` run and repair + the release. The staging pointer has already moved. +- **Plan could not choose a commit:** inspect the linked run's fetch and plan + step, then retry from Actions after the cause is fixed. Nothing moved. +- **Promotion step failed unexpectedly:** inspect the linked run. Its message + says whether the push completed; if it did, inspect the staging release. + +Retry with Actions → `Promote to staging`, on `dev`, leaving `target` empty for +the current dev tip; `target=cutoff` repeats the weekly cutoff choice, and a +dev commit SHA selects that commit. `dry_run=true` runs the full check and sends +the marked messages without pushing. `signal=true` also sends the production +signal. A dry run still needs the Slack webhook if the notification road is to +be exercised. + +The scheduled run separately signals what the **old** staging pointer held +before this week's move. That commit has had its week on staging. The message +lists what main is behind and gives the exact fast-forward command for a person +to run; it does not move `main`. In this cadence, main runs one week behind +staging. If main already contains that staging commit, the message says so. +The date in the message is the release's `published_at` when one exists. + +One-time setup: set `SLACK_RELEASE_WEBHOOK` to an incoming Slack webhook (its +configuration chooses the channel). Set `PROMOTION_TOKEN` to a fine-grained +personal access token scoped to this repository with **Contents: read and +write** and **Workflows: read and write**, owned by an account the live +`protection` branch ruleset lets bypass. List ruleset ids with `gh api +repos/Agent-Field/codeaf/rulesets`, then check from that account with `gh api +repos/Agent-Field/codeaf/rulesets/ --jq .current_user_can_bypass`; it must +answer `always`. A GitHub App would need a token-minting step because its +installation tokens expire after one hour; this workflow has no such step. +`GITHUB_TOKEN` cannot start the downstream release workflow on push and cannot +push commits that change workflow files. -**Promote the newest commit on `dev` that is at least two days old, green on the -full check, and has nothing open against it.** +## Choosing what to promote -Age is the criterion that matters here and it is the one people skip. The -failures this repository actually suffers are not the ones review catches — they -are the ones that surface when somebody uses the thing for an afternoon, which is -precisely what agent-written code produces and precisely what no gate can see. -A commit that nobody has run is a commit nobody has tested, however green it is. +The weekly promotion chooses the Friday cutoff commit after a fresh full check. +It does not inspect a commit's age or open issues. The older two-day soak rule +guides the by-hand fallback: choose a dev commit people have used for at least +two days and against which nothing is open. A dispatch can name that SHA while +it is still ahead of staging; a forward-only pointer cannot +move back to an older commit after a promotion. -So: people build `dev` with `make build` and use it. If a day passes and nobody -has said "something is off", that commit is a candidate. +People should build `dev` with `make build` and use it during the week. The +failures that surface after an afternoon of use are not all caught by a gate. +That human use informs whether to repair dev before Friday or select a specific +dev commit by hand. The scheduled job still follows its cutoff and Full check +rule, so it never silently substitutes a human judgment for either one. ## dev → staging @@ -29,12 +86,15 @@ git merge-base --is-ancestor $SHA origin/dev && echo "on dev" # 3. Run the full check against it first, so a red staging is never how you # find out. This is the same workflow CI runs. -gh workflow run ci-full.yml --ref $SHA +gh workflow run ci-full.yml --ref dev -f ref=$SHA # 4. Move the pointer. Not a merge — a fast-forward. git push origin $SHA:staging ``` +Wait for the dispatched Full check to conclude `success` on `$SHA` before +running step 4. A queued dispatch is not a passed check. + If step 4 is rejected as a non-fast-forward, **do not force it.** It means `staging` is somewhere `dev` has not been, which should be impossible and is worth understanding before anything else happens. diff --git a/internal/release/promotion_workflow_test.go b/internal/release/promotion_workflow_test.go new file mode 100644 index 0000000000..aa6fc38096 --- /dev/null +++ b/internal/release/promotion_workflow_test.go @@ -0,0 +1,462 @@ +package release + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func workflowDocument(t *testing.T, name string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", name)) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + return doc +} +func obj(t *testing.T, value any) map[string]any { + t.Helper() + result, ok := value.(map[string]any) + if !ok { + t.Fatalf("want mapping, got %T: %v", value, value) + } + return result +} +func list(t *testing.T, value any) []any { + t.Helper() + result, ok := value.([]any) + if !ok { + t.Fatalf("want list, got %T: %v", value, value) + } + return result +} +func stringField(t *testing.T, value any) string { + t.Helper() + result, ok := value.(string) + if !ok { + t.Fatalf("want string, got %T: %v", value, value) + } + return result +} + +// C16: The schedule and the dispatch are the only triggers, and the parsed cron follows the parsed cutoff in both Toronto offsets. +func TestC16PromotionTriggerAndCutoff(t *testing.T) { + doc := workflowDocument(t, "promote-staging.yml") + on := obj(t, doc["on"]) + if len(on) != 2 { + t.Fatalf("triggers = %v", on) + } + schedule := list(t, on["schedule"]) + if len(schedule) != 1 { + t.Fatalf("schedule = %v", schedule) + } + cron := strings.Fields(stringField(t, obj(t, schedule[0])["cron"])) + if len(cron) != 5 { + t.Fatalf("cron = %v", cron) + } + minute, e1 := strconv.Atoi(cron[0]) + hour, e2 := strconv.Atoi(cron[1]) + if e1 != nil || e2 != nil || cron[2] != "*" || cron[3] != "*" { + t.Fatalf("cron = %v", cron) + } + dispatch := obj(t, on["workflow_dispatch"]) + inputs := obj(t, dispatch["inputs"]) + if len(inputs) != 3 { + t.Fatalf("inputs = %v", inputs) + } + for _, name := range []string{"target", "dry_run", "signal"} { + if _, ok := inputs[name]; !ok { + t.Errorf("missing %s", name) + } + } + env := obj(t, doc["env"]) + zone := stringField(t, env["CUTOFF_ZONE"]) + weekday := stringField(t, env["CUTOFF_WEEKDAY"]) + clock := stringField(t, env["CUTOFF_CLOCK"]) + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + cutParts := strings.Split(clock, ":") + cutHour, _ := strconv.Atoi(cutParts[0]) + cutMinute, _ := strconv.Atoi(cutParts[1]) + for _, day := range []time.Time{time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC), time.Date(2026, 11, 6, 0, 0, 0, 0, time.UTC)} { + run := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, time.UTC) + local := run.In(loc) + cut := time.Date(local.Year(), local.Month(), local.Day(), cutHour, cutMinute, 0, 0, loc) + if local.Weekday().String() != weekday || cron[4] != strconv.Itoa(int(local.Weekday())) || !run.After(cut) { + t.Errorf("cron %v run %s before cutoff %s", cron, run, cut) + } + } + concurrency := obj(t, doc["concurrency"]) + if stringField(t, concurrency["group"]) != "promote-staging-${{ github.event_name }}" || concurrency["cancel-in-progress"] != false { + t.Fatalf("concurrency = %v", concurrency) + } +} + +// F8: The workflow explains why a late cron is safe and why each job exists. +func TestPromotionWorkflowExplainsItsSafetyLaws(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "promote-staging.yml")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, want := range []string{"# THE CUTOFF IS A COMMIT TIME, NOT THE RUN TIME.", "12:59Z and 15:46Z", "PROMOTION_TOKEN", "SLACK_RELEASE_WEBHOOK", "forward-only", "persist-credentials"} { + if !strings.Contains(text, want) { + t.Errorf("workflow lacks explanation %q", want) + } + } + for _, name := range []string{"plan", "plan-failed", "signal", "full_check", "finish"} { + pattern := regexp.MustCompile(`(?m)(?:^ #[^\n]*\n)+ ` + regexp.QuoteMeta(name) + `:`) + if !pattern.MatchString(text) { + t.Errorf("%s has no job comment", name) + } + } +} + +// R5, R6, and R8: Setup and manual copy must describe the token and weekly outcome truthfully. +func TestPromotionDocsDescribeLiveSetupAndConditionalCadence(t *testing.T) { + root := repositoryRoot(t) + read := func(path string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + return string(raw) + } + runbook := read("docs/rules/promotion.md") + for _, want := range []string{"fine-grained\npersonal access token", "Contents: read and", "Workflows: read and write", ".current_user_can_bypass", "always", "expire after one hour", "token-minting step"} { + if !strings.Contains(runbook, want) { + t.Errorf("runbook lacks %q", want) + } + } + if strings.Contains(runbook, "installation token with those permissions and bypass\nrights also works") { + t.Error("runbook presents an expiring App token as a static secret") + } + rules := read(".github/rulesets/README.md") + for _, want := range []string{"repository is public", "live `protection` ruleset", "`main`, `dev`,", "`staging`", "`PROMOTION_TOKEN`", "bypass list"} { + if !strings.Contains(rules, want) { + t.Errorf("ruleset README lacks %q", want) + } + } + manual := read("internal/manual/chat/running-from-the-terminal.md") + section := strings.SplitN(strings.SplitN(manual, "## What is stageaf", 2)[1], "\n## ", 2)[0] + if len(section) >= 2000 || strings.Contains(section, "Staging moves once a week") || !strings.Contains(section, "The promotion runs once a week") || !strings.Contains(section, "When the check\nfails or there is nothing new, staging stays where it was") { + t.Fatalf("stageaf manual overpromises or exceeds section limit: %s", section) + } +} + +// C17: The reusable Full check receives the candidate and all permissions its own jobs request. +func TestC17PromotionCallsFullCheckWithPermissions(t *testing.T) { + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + full := obj(t, jobs["full_check"]) + if full["uses"] != "./.github/workflows/ci-full.yml" || obj(t, full["with"])["ref"] != "${{ needs.plan.outputs.candidate }}" { + t.Fatalf("full check = %v", full) + } + if obj(t, full["permissions"])["issues"] != "write" { + t.Fatalf("permissions = %v", full["permissions"]) + } +} + +// C18 and R1: A skipped reusable check and a failed plan each keep their own report job runnable. +func TestC18SkippedAncestorsCannotSkipReports(t *testing.T) { + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + for _, name := range []string{"finish", "signal", "plan-failed"} { + job := obj(t, jobs[name]) + needs := list(t, job["needs"]) + if !containsNeed(needs, "plan") { + t.Errorf("%s does not need plan: %v", name, needs) + } + condition := stringField(t, job["if"]) + want := "needs.plan.result == 'success'" + if name == "plan-failed" { + want = "needs.plan.result == 'failure'" + } + if !strings.HasPrefix(condition, "always() &&") || !strings.Contains(condition, want) { + t.Errorf("%s: %s", name, condition) + } + } + if !containsNeed(list(t, obj(t, jobs["finish"])["needs"]), "full_check") { + t.Fatal("finish does not need full_check") + } + condition := stringField(t, obj(t, jobs["finish"])["if"]) + for _, status := range []string{"success", "failure", "cancelled", "skipped"} { + if !strings.Contains(condition, "needs.full_check.result == '"+status+"'") { + t.Errorf("finish lacks %s", status) + } + } +} + +func containsNeed(needs []any, name string) bool { + for _, need := range needs { + if need == name { + return true + } + } + return false +} + +// R1 and R3: A failed plan and an unexpected finish error still post a summary without calling the Go tool. +func TestPromotionFallbackMessagesSurviveToolFailure(t *testing.T) { + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + failed := obj(t, jobs["plan-failed"]) + step := obj(t, list(t, failed["steps"])[0]) + run := stringField(t, step["run"]) + for _, want := range []string{"scripts/slack-post.sh", "jq -n", "promotion could not choose a dev commit", "DRY_RUN"} { + if !strings.Contains(run, want) { + t.Errorf("plan-failed lacks %q", want) + } + } + if strings.Contains(run, "go run") { + t.Fatal("failed-plan fallback depends on the Go tool") + } + finish := obj(t, jobs["finish"]) + finishRun := stringField(t, obj(t, list(t, finish["steps"])[2])["run"]) + for _, want := range []string{"set -Eeuo pipefail", "trap 'report_unexpected_error' ERR", "push_done=true", "promotion step failed before pushing", "promotion could not report its build", "scripts/slack-post.sh fallback.json"} { + if !strings.Contains(finishRun, want) { + t.Errorf("finish lacks %q", want) + } + } + for _, want := range []string{"pushed_at=", "--commit \"$CANDIDATE\"", "createdAt,attempt", "--pushed-at \"$pushed_at\""} { + if !strings.Contains(finishRun, want) { + t.Errorf("release lookup lacks %q", want) + } + } +} + +// R1 and R3: The real shell blocks send a message when planning fails or the finish tool exits early. +func TestPromotionFallbackShellBlocks(t *testing.T) { + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq is not on PATH") + } + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + planFailed := stringField(t, obj(t, list(t, obj(t, jobs["plan-failed"])["steps"])[0])["run"]) + finish := stringField(t, obj(t, list(t, obj(t, jobs["finish"])["steps"])[2])["run"]) + for _, row := range []struct { + name, script, want string + exit int + afterPush bool + }{ + {"plan failed", planFailed, "promotion could not choose a dev commit", 0, false}, + {"finish tool failed", finish, "promotion step failed before pushing", 27, false}, + {"report failed after push", finish, "promotion could not report its build", 27, true}, + } { + t.Run(row.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "scripts"), 0755); err != nil { + t.Fatal(err) + } + post, err := os.ReadFile(filepath.Join(repositoryRoot(t), "scripts", "slack-post.sh")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "scripts", "slack-post.sh"), post, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "bin"), 0755); err != nil { + t.Fatal(err) + } + goStub := "#!/bin/sh\nexit 27\n" + if row.afterPush { + goStub = "#!/bin/sh\ncase \"$*\" in\n *promotion-decision*) echo '{\"action\":\"push\",\"status\":\"success\"}' ;;\n *promotion-recheck*) echo promote ;;\n *) exit 27 ;;\nesac\n" + for _, name := range []string{"git", "gh"} { + stub := "#!/bin/sh\nexit 0\n" + if name == "gh" { + stub = "#!/bin/sh\necho '[]'\n" + } + if err := os.WriteFile(filepath.Join(dir, "bin", name), []byte(stub), 0755); err != nil { + t.Fatal(err) + } + } + } + if err := os.WriteFile(filepath.Join(dir, "bin", "go"), []byte(goStub), 0755); err != nil { + t.Fatal(err) + } + summary := filepath.Join(dir, "summary") + cmd := exec.Command("bash", "-c", row.script) + cmd.Dir = dir + promotionToken := "" + if row.afterPush { + promotionToken = "test-token" + } + cmd.Env = append(os.Environ(), + "PATH="+filepath.Join(dir, "bin")+string(os.PathListSeparator)+os.Getenv("PATH"), + "GITHUB_STEP_SUMMARY="+summary, "SLACK_RELEASE_WEBHOOK=", "DRY_RUN=true", + "GITHUB_SERVER_URL=https://example.test", "GITHUB_REPOSITORY=Agent-Field/codeaf", "GITHUB_RUN_ID=123", + "PLAN_JSON={}", "CHECK_RESULT=success", "CANDIDATE=abcdef0123456789", "PROMOTION_TOKEN="+promotionToken, + ) + out, err := cmd.CombinedOutput() + code := 0 + if err != nil { + if failed, ok := err.(*exec.ExitError); ok { + code = failed.ExitCode() + } else { + t.Fatal(err) + } + } + if code != row.exit { + t.Fatalf("exit = %d, want %d: %s", code, row.exit, out) + } + raw, err := os.ReadFile(summary) + if err != nil || !strings.HasPrefix(string(raw), "[dry run] *") || !strings.Contains(string(raw), row.want) || !strings.Contains(string(raw), "https://example.test/Agent-Field/codeaf/actions/runs/123") { + t.Fatalf("summary = %q, output = %s, err = %v", raw, out, err) + } + }) + } +} + +// C19: The push reads its secret through env, discards checkout credentials, and never forces a ref. +func TestC19PromotionPushIsForwardOnly(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "promote-staging.yml")) + if err != nil { + t.Fatal(err) + } + workflow := string(raw) + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + finish := obj(t, jobs["finish"]) + steps := list(t, finish["steps"]) + checkout := obj(t, steps[0]) + if obj(t, checkout["with"])["persist-credentials"] != false { + t.Fatalf("checkout = %v", checkout) + } + pushStep := obj(t, steps[len(steps)-1]) + env := obj(t, pushStep["env"]) + if env["PROMOTION_TOKEN"] != "${{ secrets.PROMOTION_TOKEN }}" { + t.Fatalf("token env = %v", env) + } + for _, line := range strings.Split(workflow, "\n") { + if strings.Contains(line, "git push") && (strings.Contains(line, "--force") || regexp.MustCompile(`\s-f(?:\s|$)`).MatchString(line) || regexp.MustCompile(`\s\+[^ ]*:`).MatchString(line)) { + t.Errorf("forced push: %s", line) + } + } + if !strings.Contains(workflow, "promotion-recheck") || !strings.Contains(workflow, "git fetch --no-tags origin +refs/heads/dev:") { + t.Fatal("push has no fresh ancestry check") + } +} + +// C20: Full check keeps its existing triggers and checks out the requested commit in every job. +func TestC20FullCheckRunsOnRequestedRef(t *testing.T) { + doc := workflowDocument(t, "ci-full.yml") + on := obj(t, doc["on"]) + for _, name := range []string{"push", "pull_request", "schedule", "workflow_dispatch", "workflow_call"} { + if _, ok := on[name]; !ok { + t.Errorf("missing %s", name) + } + } + if len(on) != 5 { + t.Fatalf("triggers = %v", on) + } + schedule := list(t, on["schedule"]) + if len(schedule) != 1 || obj(t, schedule[0])["cron"] != "0 9 * * *" { + t.Fatalf("full check schedule changed: %v", schedule) + } + for _, name := range []string{"push", "pull_request"} { + branches := list(t, obj(t, on[name])["branches"]) + if fmt.Sprint(branches) != "[staging main]" { + t.Errorf("%s branches = %v", name, branches) + } + } + call := obj(t, obj(t, on["workflow_call"])["inputs"]) + ref := obj(t, call["ref"]) + if ref["required"] != true || ref["type"] != "string" { + t.Fatalf("call ref = %v", ref) + } + jobs := obj(t, doc["jobs"]) + noCheckout := map[string]bool{"full-tests": true, "cross-build": true, "page": true} + for name, value := range jobs { + job := obj(t, value) + steps, ok := job["steps"].([]any) + if !ok { + continue + } + count := 0 + for _, stepValue := range steps { + step := obj(t, stepValue) + if step["uses"] == "actions/checkout@v4" { + count++ + if obj(t, step["with"])["ref"] != "${{ inputs.ref }}" { + t.Errorf("%s checkout lacks ref", name) + } + } + } + want := 1 + if noCheckout[name] { + want = 0 + } + if count != want { + t.Errorf("%s has %d checkouts, want %d", name, count, want) + } + } + if !strings.Contains(stringField(t, obj(t, jobs["page"])["if"]), "!inputs.ref") { + t.Fatal("page condition does not exclude requested refs") + } + if obj(t, doc["concurrency"])["group"] != "full-check-${{ inputs.ref || github.ref }}" { + t.Fatal("concurrency did not follow the requested ref") + } +} + +// C12: Every payload reaches the run summary and a local webhook, while a missing webhook only warns. +func TestC12SlackPostSummaryAndLocalWebhook(t *testing.T) { + var received string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("payload: %v", err) + } + received = payload.Text + })) + defer server.Close() + payload := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(payload, []byte(`{"text":"codeaf staging moved"}`), 0600); err != nil { + t.Fatal(err) + } + summary := filepath.Join(t.TempDir(), "summary.md") + script := filepath.Join(repositoryRoot(t), "scripts", "slack-post.sh") + scriptBody, err := os.ReadFile(script) + if err != nil || !strings.Contains(string(scriptBody), "--max-time 20") { + t.Fatalf("webhook POST has no 20-second bound: %v", err) + } + cmd := exec.Command("bash", script, payload) + cmd.Env = append(os.Environ(), "GITHUB_STEP_SUMMARY="+summary, "SLACK_RELEASE_WEBHOOK="+server.URL) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("post: %s: %v", out, err) + } + raw, err := os.ReadFile(summary) + if err != nil || !strings.Contains(string(raw), "codeaf staging moved") || received != "codeaf staging moved" { + t.Fatalf("summary %q, received %q: %v", raw, received, err) + } + cmd = exec.Command("bash", script, payload) + cmd.Env = append(os.Environ(), "GITHUB_STEP_SUMMARY="+summary, "SLACK_RELEASE_WEBHOOK=") + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "SLACK_RELEASE_WEBHOOK") { + t.Fatalf("missing webhook: %s: %v", out, err) + } + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer failing.Close() + cmd = exec.Command("bash", script, payload) + cmd.Env = append(os.Environ(), "GITHUB_STEP_SUMMARY="+summary, "SLACK_RELEASE_WEBHOOK="+failing.URL) + out, err = cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "Slack webhook POST failed") { + t.Fatalf("failed webhook: %s: %v", out, err) + } +} diff --git a/scripts/slack-post.sh b/scripts/slack-post.sh new file mode 100755 index 0000000000..84866387a1 --- /dev/null +++ b/scripts/slack-post.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +payload="$1" +message="$(jq -r .text "$payload")" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '%s\n\n' "$message" >> "$GITHUB_STEP_SUMMARY" +fi +if [ -z "${SLACK_RELEASE_WEBHOOK:-}" ]; then + echo '::warning::SLACK_RELEASE_WEBHOOK is not configured; the message is in the run summary.' + exit 0 +fi +if ! curl -fsS --max-time 20 --retry 2 -H 'Content-Type: application/json' --data-binary "@$payload" "$SLACK_RELEASE_WEBHOOK" > /dev/null; then + echo '::warning::Slack webhook POST failed; the message is in the run summary.' +fi From 78aafc0386a67e215bcd5d4b8c2825caebe6f575 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 24 Sep 2026 13:43:06 -0400 Subject: [PATCH 3/6] build: give the staging channel its stageaf installer Route update and failed-install guidance for a stageaf executable through the staging proxy (agentfield.ai/get/stageaf). Name the stageaf install line in staging release notes, the terminal manual, and the guide. Cover the named installer, update failure, release notes, and manual retrieval with focused tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 10 +++++++ cmd/codeaf/update_test.go | 24 +++++++++++++++++ docs/GUIDE.md | 5 +++- .../manual/chat/running-from-the-terminal.md | 26 +++++++++++++++++-- internal/manual/chat_test.go | 20 ++++++++++++++ internal/release/workflow_test.go | 17 ++++++++++++ internal/update/client.go | 3 +++ internal/update/update_test.go | 4 +++ 8 files changed, 106 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eaa6f8499..e0cb29d524 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go index 5f5ef3aa53..f70eb4c25f 100644 --- a/cmd/codeaf/update_test.go +++ b/cmd/codeaf/update_test.go @@ -671,6 +671,30 @@ func TestV8TerminalFailureUsesTheRunningFilesCurlLine(t *testing.T) { } } +// C22: A staging build installed as stageaf offers the same installer on a failed update. +func TestC22StageafFailureUsesTheStageafCurlLine(t *testing.T) { + const running = "staging-20260918-aaaaaaaaaaaa" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "/releases") { + fmt.Fprint(w, `[{"tag_name":"staging-20260921-bbbbbbbbbbbb","published_at":"2026-09-21T12:00:00Z"}]`) + return + } + http.NotFound(w, request) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "stageaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + _, _ = withUpdateDoor(t, running, client, target) + err := runUpdate(nil) + const want = "install a release with: curl -fsSL https://agentfield.ai/get/stageaf | bash" + if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), "/get/codeaf/staging") { + t.Fatalf("failure = %v", err) + } +} + // TestC14RestartArgumentsPassTheRealChatFlagParser proves C14. func TestC14RestartArgumentsPassTheRealChatFlagParser(t *testing.T) { for _, original := range [][]string{nil, {"chat", "--model", "x"}, {"resume", "--session", "/tmp/this.jsonl"}} { diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 4fb0946047..010af060dd 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -102,6 +102,7 @@ Recognizing a channel does not mean a matching release exists. ```bash curl -fsSL https://agentfield.ai/get/codeaf/dev | bash curl -fsSL https://agentfield.ai/get/devaf | bash +curl -fsSL https://agentfield.ai/get/stageaf | bash curl -fsSL https://agentfield.ai/get/codeaf/staging | bash curl -fsSL https://agentfield.ai/get/codeaf/rc | bash curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash @@ -128,7 +129,9 @@ when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste i current shell, bold green on a terminal, last, with a blank line above and below. `--verbose` also reports the channel, the tag and the install path on stderr. The `/get/devaf` line selects the dev channel and names the file `devaf`, installing it -beside codeaf. Release builds cover darwin, linux, and windows on amd64 and arm64. +beside codeaf. The `/get/stageaf` line selects staging and names the file +`stageaf`, also beside codeaf. Release builds cover darwin, linux, and windows +on amd64 and arm64. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 27c60a2479..f306227097 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -23,7 +23,8 @@ and `update-check.staging.json`; stable and rc use `update-check.json` for 24 hours. A cached newer release is still shown. The curl line reinstalls this file from the channel the build follows — dev and staging follow themselves, and a stable, rc or source build follows stable. So `devaf` gets `curl -fsSL -https://agentfield.ai/get/devaf | bash`, a dev build named `codeaf` gets +https://agentfield.ai/get/devaf | bash`, `stageaf` gets `curl -fsSL +https://agentfield.ai/get/stageaf | bash`, a dev build named `codeaf` gets `/get/codeaf/dev`, a release candidate gets the plain `/get/codeaf`, the same stable release its launch line just named, and a file under any other name gets `| CODEAF_INSTALL_NAME= bash` on the end of its channel's line. @@ -75,7 +76,8 @@ A push to `dev` publishes a `dev-*` build, a push to `staging` publishes a `staging-*` build, and a push to `main` publishes an rc. Each is marked as a prerelease. Stable is published only when a person dispatches `Release` on `main`. `--stable` is the default and reads GitHub's `releases/latest`, which excludes -prereleases. To take another channel, put it on the path — +prereleases. The `/get/devaf` and `/get/stageaf` lines install the dev and staging +channels beside codeaf under those file names. To take another channel, put it on the path — `https://agentfield.ai/get/codeaf/dev`, `/staging` or `/rc` — or pass `--dev`, `--staging` or `--rc` after `bash -s --`. A channel with nothing published stops with `no build has been published yet`. To pin one complete tag, replace the @@ -119,6 +121,26 @@ conversations, and one engine per workspace; opening a workspace with the other build retires an idle host or joins a busy compatible one. A devaf launch checks the dev channel hourly, and bare `/update` keeps following dev. +## What is stageaf — staging build beside codeaf — try the next release early — install staging — how often does staging update + +`stageaf` is the file name for a codeaf staging-channel build, not another product. +Install it beside codeaf with: + +```sh +curl -fsSL https://agentfield.ai/get/stageaf | bash +``` + +That proxy serves the installer from the `staging` branch and rewrites only the +channel and name lines. It installs `~/.codeaf/bin/stageaf` (`stageaf.exe` on +Windows) beside an untouched codeaf. `stageaf version` still starts with `codeaf`. +It shares `~/.codeaf` with codeaf and devaf, including keys and conversations. +A stageaf launch checks the staging channel hourly; bare `/update` follows staging. + +The promotion runs once a week and moves staging to the newest dev commit at +the Friday 17:00 Toronto-time cutoff when the full check passes. When the check +fails or there is nothing new, staging stays where it was. A person still +decides when main moves. + ## Why codeaf do may download rtk — compressed shell output and how to turn it off When `codeaf do` starts local workers, it starts one background attempt to find or diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 50eb72dc37..e33c5d4b96 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2642,6 +2642,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I install the latest dev build beside my codeaf", "running-from-the-terminal"}, {"what is devaf", "running-from-the-terminal"}, {"devaf", "running-from-the-terminal"}, + {"what is stageaf", "running-from-the-terminal"}, + {"stageaf", "running-from-the-terminal"}, + {"how do I install the staging build", "running-from-the-terminal"}, + {"how often does staging update", "running-from-the-terminal"}, {"can I run two versions of codeaf side by side", "running-from-the-terminal"}, {"how do I keep my dev build up to date", "running-from-the-terminal"}, {"install codeaf with a different file name", "running-from-the-terminal"}, @@ -2817,6 +2821,22 @@ func TestV9DevafQuestionsReachTheTerminalManual(t *testing.T) { } } +// C24: Staging installer and cadence questions reach the terminal manual section. +func TestC24StageafQuestionsReachTheTerminalManual(t *testing.T) { + for _, asked := range []string{"what is stageaf", "stageaf", "how do I install the staging build", "how often does staging update"} { + var reached bool + for _, section := range Chat().Search(asked, DefaultResults) { + if section.Page == "running-from-the-terminal" { + reached = true + break + } + } + if !reached { + t.Errorf("%q does not reach running-from-the-terminal", asked) + } + } +} + func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t *testing.T) { page, ok := Chat().Page("services") if !ok { diff --git a/internal/release/workflow_test.go b/internal/release/workflow_test.go index 7e37fe1162..3e55523b1d 100644 --- a/internal/release/workflow_test.go +++ b/internal/release/workflow_test.go @@ -135,6 +135,23 @@ func TestV10DevReleaseNotesNameTheDevafInstaller(t *testing.T) { } } +// C23: Only staging notes name the installer that keeps the staging build beside codeaf. +func TestC23StagingReleaseNotesNameTheStageafInstaller(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + workflow := string(raw) + const line = "curl -fsSL https://agentfield.ai/get/stageaf | bash" + if strings.Count(workflow, line) != 1 { + t.Fatalf("stageaf install line count = %d, want 1", strings.Count(workflow, line)) + } + block := regexp.MustCompile(`(?ms)if \[ "\$CHANNEL" = "staging" \]; then\n(.*?)\n\s+fi`).FindStringSubmatch(workflow) + if block == nil || !strings.Contains(block[1], "installs this staging channel as `stageaf` beside codeaf") || !strings.Contains(block[1], line) { + t.Fatalf("staging notes block does not name the stageaf road:\n%v", block) + } +} + // releaseSurfacePrologue is the shell of the "Test release surface" step up to // its go test line, with the go test replaced by a line that prints the // arguments it would have been given. diff --git a/internal/update/client.go b/internal/update/client.go index 7dd86e669f..f6c6d654a1 100644 --- a/internal/update/client.go +++ b/internal/update/client.go @@ -53,6 +53,9 @@ func CurlLine(executable, channel string) string { if name == "devaf" { return "curl -fsSL https://agentfield.ai/get/devaf | bash" } + if name == "stageaf" { + return "curl -fsSL https://agentfield.ai/get/stageaf | bash" + } address := "https://agentfield.ai/get/codeaf" if channel != "stable" { address += "/" + channel diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 53c01e7b30..bfd022eb34 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -370,6 +370,10 @@ func TestV8CurlLineTable(t *testing.T) { {"devaf", "dev", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, {"devaf", "stable", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, {"devaf.exe", "dev", "curl -fsSL https://agentfield.ai/get/devaf | bash"}, + {"stageaf", "staging", "curl -fsSL https://agentfield.ai/get/stageaf | bash"}, + {"stageaf", "stable", "curl -fsSL https://agentfield.ai/get/stageaf | bash"}, + {"stageaf", "rc", "curl -fsSL https://agentfield.ai/get/stageaf | bash"}, + {"stageaf.exe", "dev", "curl -fsSL https://agentfield.ai/get/stageaf | bash"}, {"mine", "dev", "curl -fsSL https://agentfield.ai/get/codeaf/dev | CODEAF_INSTALL_NAME=mine bash"}, {"mine", "stable", "curl -fsSL https://agentfield.ai/get/codeaf | CODEAF_INSTALL_NAME=mine bash"}, {"", "dev", "curl -fsSL https://agentfield.ai/get/codeaf/dev | bash"}, From 223295b6ce6d59c3b50917223cc10c828e81ee23 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 24 Sep 2026 13:51:24 -0400 Subject: [PATCH 4/6] changelog: staging moves itself every Friday, and stageaf installs it (#1438) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1438-staging-moves-every-friday.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/1438-staging-moves-every-friday.md diff --git a/docs/changes/unreleased/1438-staging-moves-every-friday.md b/docs/changes/unreleased/1438-staging-moves-every-friday.md new file mode 100644 index 0000000000..4cd67e6e96 --- /dev/null +++ b/docs/changes/unreleased/1438-staging-moves-every-friday.md @@ -0,0 +1,16 @@ +--- +kind: added +title: staging moves itself every Friday after the full check, and stageaf installs it beside codeaf +pr: 1438 +surface: [chat, build, docs] +invalidates: + - "staging moved only when a person ran `git push origin :staging`. `Promote to staging` (.github/workflows/promote-staging.yml) now moves it every Friday to the newest first-parent dev commit whose committer time is at or before 17:00 America/Toronto, and only after a fresh Full check on that exact commit passes. It pushes with the PROMOTION_TOKEN secret and posts every outcome to SLACK_RELEASE_WEBHOOK; without the token it reports and moves nothing." + - "docs/rules/promotion.md step 3 ran `gh workflow run ci-full.yml --ref $SHA`, which GitHub refuses for a sha. The Full check now takes a commit: `gh workflow run ci-full.yml --ref dev -f ref=$SHA`, and another workflow can call it with `ref`." + - "devaf was the only side-by-side install. `curl -fsSL https://agentfield.ai/get/stageaf | bash` installs the staging build as `stageaf` beside codeaf, and a stageaf's own reinstall line names that address." + - "Nothing said when staging was ready for main. The Friday run posts the staging commit from before its own move as the production candidate, with the exact fast-forward to run; main stays a person's push and runs one week behind staging." +--- + +The cutoff is a commit time rather than the run time because this repository's +scheduled runs start hours late. `workflow_dispatch` retries a failed Friday +(`target` empty for the dev tip, `cutoff` for the Friday rule, or a dev sha) and +`dry_run=true` exercises the check and the messages without pushing. From f61ec6f895e93dfb220b189a32898d5c11803dfb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 10:16:06 -0400 Subject: [PATCH 5/6] build: let the failed-plan report reach its poster script The plan-failed job ran scripts/slack-post.sh without checking the repository out, and every job starts in an empty workspace, so a failed plan reported nothing to the run summary or to Slack. Its shell-block test copied the script into its own directory and could not see the gap. A new law test walks every job in promote-staging.yml and fails when a step runs a repository script or command before any checkout. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/promote-staging.yml | 3 ++ internal/release/promotion_workflow_test.go | 42 +++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/workflows/promote-staging.yml b/.github/workflows/promote-staging.yml index 065c5aac0c..475b149a59 100644 --- a/.github/workflows/promote-staging.yml +++ b/.github/workflows/promote-staging.yml @@ -77,6 +77,9 @@ jobs: 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 }} diff --git a/internal/release/promotion_workflow_test.go b/internal/release/promotion_workflow_test.go index aa6fc38096..e87e89a4b6 100644 --- a/internal/release/promotion_workflow_test.go +++ b/internal/release/promotion_workflow_test.go @@ -215,9 +215,7 @@ func containsNeed(needs []any, name string) bool { // R1 and R3: A failed plan and an unexpected finish error still post a summary without calling the Go tool. func TestPromotionFallbackMessagesSurviveToolFailure(t *testing.T) { jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) - failed := obj(t, jobs["plan-failed"]) - step := obj(t, list(t, failed["steps"])[0]) - run := stringField(t, step["run"]) + run := runStep(t, jobs, "plan-failed") for _, want := range []string{"scripts/slack-post.sh", "jq -n", "promotion could not choose a dev commit", "DRY_RUN"} { if !strings.Contains(run, want) { t.Errorf("plan-failed lacks %q", want) @@ -240,13 +238,49 @@ func TestPromotionFallbackMessagesSurviveToolFailure(t *testing.T) { } } +// runStep returns the one shell block a job runs, wherever it sits among the job's steps. +func runStep(t *testing.T, jobs map[string]any, name string) string { + t.Helper() + for _, raw := range list(t, obj(t, jobs[name])["steps"]) { + if run, ok := obj(t, raw)["run"]; ok { + return stringField(t, run) + } + } + t.Fatalf("%s runs no shell block", name) + return "" +} + +// R4: A job starts in an empty workspace, so every job that runs a repository +// script checks the repository out first. The shell-block test below copies the +// poster into its own directory and so cannot see a job that never had it. +func TestPromotionJobsCheckOutTheScriptsTheyRun(t *testing.T) { + jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) + for name, raw := range jobs { + job := obj(t, raw) + if _, called := job["uses"]; called { + continue + } + checkedOut := false + for i, rawStep := range list(t, job["steps"]) { + step := obj(t, rawStep) + if uses, ok := step["uses"].(string); ok && strings.HasPrefix(uses, "actions/checkout@") { + checkedOut = true + } + run, ok := step["run"].(string) + if ok && (strings.Contains(run, "scripts/") || strings.Contains(run, "./cmd/")) && !checkedOut { + t.Errorf("%s step %d runs a repository file before any checkout", name, i) + } + } + } +} + // R1 and R3: The real shell blocks send a message when planning fails or the finish tool exits early. func TestPromotionFallbackShellBlocks(t *testing.T) { if _, err := exec.LookPath("jq"); err != nil { t.Skip("jq is not on PATH") } jobs := obj(t, workflowDocument(t, "promote-staging.yml")["jobs"]) - planFailed := stringField(t, obj(t, list(t, obj(t, jobs["plan-failed"])["steps"])[0])["run"]) + planFailed := runStep(t, jobs, "plan-failed") finish := stringField(t, obj(t, list(t, obj(t, jobs["finish"])["steps"])[2])["run"]) for _, row := range []struct { name, script, want string From 187906dcfb12ae04162e15cb31aa99347d6bf9fd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 10:16:06 -0400 Subject: [PATCH 6/6] build: say what a current or refused promotion actually saw A plan is current when staging already holds the cutoff commit, which includes staging having moved past it; the message said "Nothing new on dev" in both cases, though dev can carry commits after the cutoff. It now says staging is already past the commit when the two differ. An empty push, fetch or recheck log made the error reader print an empty line, which the message quoted as "was refused: ." The reader now refuses an empty log, and the message names a missing reason instead of drawing a bare colon. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf-release/promotion.go | 27 ++++++++++++++----- cmd/codeaf-release/promotion_test.go | 39 +++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/cmd/codeaf-release/promotion.go b/cmd/codeaf-release/promotion.go index d6f48fb2d8..39a6959899 100644 --- a/cmd/codeaf-release/promotion.go +++ b/cmd/codeaf-release/promotion.go @@ -388,16 +388,17 @@ func runPromotionPushError(args []string, out io.Writer) error { if err != nil { return err } - lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + text := strings.TrimSpace(string(raw)) + if text == "" { + return errors.New("git push failed without an error line") + } + lines := strings.Split(text, "\n") for _, line := range lines { if strings.Contains(line, "error:") || strings.Contains(line, "fatal:") || strings.Contains(line, "remote:") || strings.Contains(line, "[remote rejected]") { _, err = fmt.Fprintln(out, line) return err } } - if len(lines) == 0 { - return errors.New("git push failed without an error line") - } _, err = fmt.Fprintln(out, lines[0]) return err } @@ -572,7 +573,14 @@ func message(plan PromotionPlan, phase, checkResult, jobs, pushError, releaseSta case "plan": switch plan.Outcome { case "current": - body = fmt.Sprintf("*staging is already current* — Nothing new on dev; staging stays on `%s`. %s", short(plan.Staging), context) + // A plan is current when staging already holds the candidate, which + // includes staging having moved past it; only the first means dev has + // nothing new, because dev may carry commits after the cutoff. + if plan.Candidate == "" || plan.Staging == plan.Candidate { + body = fmt.Sprintf("*staging is already current* — Nothing new on dev; staging stays on `%s`. %s", short(plan.Staging), context) + } else { + body = fmt.Sprintf("*staging is already current* — staging is already past %s; staging stays on `%s`.", context, short(plan.Staging)) + } case "diverged": body = fmt.Sprintf("*staging did not move* — `%s` and `%s` diverged; nothing was forced. A person has to look. %s", short(plan.Staging), id, context) case "off-dev": @@ -606,7 +614,14 @@ func message(plan PromotionPlan, phase, checkResult, jobs, pushError, releaseSta if token := os.Getenv("PROMOTION_TOKEN"); token != "" { first = strings.ReplaceAll(first, token, "[token]") } - body = fmt.Sprintf("*staging did not move* — Push of %s was refused: %s. Run `git push origin %s:staging` by hand after checking the refs.", context, escapeSlack(first), plan.Candidate) + // The workflow passes whatever the error reader printed, and it prints + // nothing when git left an empty log, so an empty reason is named rather + // than drawn as a bare colon. + reason := ": " + escapeSlack(first) + if first == "" { + reason = " without a reason from git" + } + body = fmt.Sprintf("*staging did not move* — Push of %s was refused%s. Run `git push origin %s:staging` by hand after checking the refs.", context, reason, plan.Candidate) } case "release": if releaseStatus == "success" && releaseTag != "" { diff --git a/cmd/codeaf-release/promotion_test.go b/cmd/codeaf-release/promotion_test.go index bc36ff72b1..974a766091 100644 --- a/cmd/codeaf-release/promotion_test.go +++ b/cmd/codeaf-release/promotion_test.go @@ -231,12 +231,19 @@ func TestC6ToC11PromotionMessages(t *testing.T) { if !strings.Contains(absent, "no release run appeared") || strings.Contains(absent, "(absent)") { t.Fatal(absent) } - plan.Staging = base + blank, _ := message(plan, "push", "", "", " \n", "", "", "", "", false, repo) + if strings.Contains(blank, "refused:") || strings.Contains(blank, ": .") || !strings.Contains(blank, "without a reason from git") { + t.Fatal(blank) + } + candidate := plan.Candidate + plan.Staging = candidate plan.Outcome = "current" current, _ := message(plan, "plan", "", "", "", "", "", "", "", false, repo) if !strings.HasPrefix(current, "*staging is already current*") || !strings.Contains(current, "Nothing new on dev") || strings.Contains(current, "at the cutoff") { t.Fatal(current) } + plan.Staging = base + plan.Candidate = candidate plan.Outcome = "diverged" diverged, _ := message(plan, "plan", "", "", "", "", "", "", "", false, repo) if !strings.HasPrefix(diverged, "*staging did not move*") || !strings.Contains(diverged, "nothing was forced") { @@ -323,6 +330,36 @@ func TestC9ReleaseRunClassification(t *testing.T) { } } +// R2: When staging has already moved past the cutoff commit, the plan leaves it +// alone and says so, without claiming dev has nothing new — dev can carry commits +// after the cutoff that staging has not seen. +func TestStagingPastTheCutoffIsNotNothingNewOnDev(t *testing.T) { + repo, _, _, at := promotionFixture(t) + late := strings.TrimSpace(fixtureGit(t, repo, "2026-09-18T12:00:00Z", "rev-parse", "refs/remotes/origin/dev")) + fixtureGit(t, repo, "2026-09-18T12:00:00Z", "update-ref", "refs/remotes/origin/staging", late) + plan, err := planPromotion(repo, instant(t, "2026-09-26T12:00:00Z"), "America/Toronto", "Friday", "17:00", "cutoff", "https://example.test/run") + if err != nil || plan.Outcome != "current" || plan.Candidate != at || plan.Staging != late { + t.Fatalf("plan = %+v, %v", plan, err) + } + msg, _ := message(plan, "plan", "", "", "", "", "", "", "", false, repo) + if !strings.HasPrefix(msg, "*staging is already current*") || strings.Contains(msg, "Nothing new on dev") || !strings.Contains(msg, "already past `"+short(at)+"`") || !strings.Contains(msg, "stays on `"+short(late)+"`") { + t.Fatal(msg) + } +} + +// R3: An empty push log is an error from the reader rather than an empty line +// that would be quoted as the reason. +func TestEmptyPushLogIsAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "push.log") + if err := os.WriteFile(path, []byte("\n \n"), 0600); err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := runPromotionPushError([]string{"--file", path}, &out); err == nil || out.String() != "" { + t.Fatalf("empty log printed %q, err %v", out.String(), err) + } +} + // C7: A rejected push quotes git's first error line rather than its destination preamble. func TestC7PushErrorSelectsFirstError(t *testing.T) { path := filepath.Join(t.TempDir(), "push.log")