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/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 diff --git a/.github/workflows/promote-staging.yml b/.github/workflows/promote-staging.yml new file mode 100644 index 0000000000..475b149a59 --- /dev/null +++ b/.github/workflows/promote-staging.yml @@ -0,0 +1,258 @@ +name: Promote to staging + +# THE CUTOFF IS A COMMIT TIME, NOT THE RUN TIME. Scheduled runs here can start +# hours late: the nightly 09:00 UTC check started between 12:59Z and 15:46Z in +# the week this workflow was written. The cron only needs to run after Friday +# 17:00 Toronto time; the first-parent dev commit at that instant is the target. +# The reusable Full check must pass before the staging pointer can move. +# PROMOTION_TOKEN makes the forward-only push and starts Release; without it, +# the check still runs and the workflow reports why staging stayed put. +# SLACK_RELEASE_WEBHOOK sends the same messages as the run summary. Without it, +# the summary still records them and the missing webhook only warns. +on: + schedule: + - cron: "30 22 * * 5" + workflow_dispatch: + inputs: + target: + description: "Empty = current dev tip; cutoff = Friday cutoff; or a dev commit" + type: string + default: "" + dry_run: + description: "Run the check and messages without pushing" + type: boolean + default: false + signal: + description: "Also send the production signal" + type: boolean + default: false + +permissions: + contents: read + actions: read + +env: + CUTOFF_ZONE: America/Toronto + CUTOFF_WEEKDAY: Friday + CUTOFF_CLOCK: "17:00" + +concurrency: + # Schedule and dispatch may overlap: the pre-push ancestry recheck and a + # forward-only push keep either run safe without evicting the other. + group: promote-staging-${{ github.event_name }} + cancel-in-progress: false + +jobs: + # The plan captures the old staging and main pointers before this run moves + # anything, so the production signal can describe last week's staging. + plan: + runs-on: ubuntu-latest + outputs: + plan: ${{ steps.choose.outputs.plan }} + candidate: ${{ steps.choose.outputs.candidate }} + outcome: ${{ steps.choose.outputs.outcome }} + staging: ${{ steps.choose.outputs.staging }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Choose the dev commit and preserve the old pointers + id: choose + env: + TARGET: ${{ inputs.target }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev +refs/heads/staging:refs/remotes/origin/staging +refs/heads/main:refs/remotes/origin/main + go run ./cmd/codeaf-release promotion-plan --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --zone "$CUTOFF_ZONE" --weekday "$CUTOFF_WEEKDAY" --clock "$CUTOFF_CLOCK" --event "$GITHUB_EVENT_NAME" --target "$TARGET" --run-url "$RUN_URL" --github-output "$GITHUB_OUTPUT" + + # A failed plan cannot use the Go tool or its missing outputs, so this small + # independent job still tells the owner why no staging pointer moved. + plan-failed: + needs: [plan] + if: always() && needs.plan.result == 'failure' + runs-on: ubuntu-latest + steps: + # Every job starts in an empty workspace, so the poster script exists here + # only if this job checks the repository out itself. + - uses: actions/checkout@v4 + - name: Report a plan that could not choose a commit + env: + DRY_RUN: ${{ inputs.dry_run }} + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + run: | + set -euo pipefail + marker="" + if [ "${DRY_RUN:-false}" = true ]; then marker="[dry run] "; fi + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + text="${marker}*staging did not move* — the promotion could not choose a dev commit; see the run." + text+=$'\n'"<${run_url}|Promote to staging run>" + jq -n --arg text "$text" '{text: $text}' > message.json + scripts/slack-post.sh message.json + + # This job reads the pre-push plan even if the Full check or push later fails. + # always() keeps a skipped promotion check from suppressing the signal. + signal: + needs: [plan] + if: always() && needs.plan.result == 'success' && (github.event_name == 'schedule' || inputs.signal) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Send the signal for the staging commit read before this run + env: + PLAN_JSON: ${{ needs.plan.outputs.plan }} + DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ github.token }} + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + run: | + set -euo pipefail + printf '%s\n' "$PLAN_JSON" > plan.json + git fetch --no-tags origin +refs/heads/staging:refs/remotes/origin/staging +refs/heads/main:refs/remotes/origin/main + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json || printf '[]\n' > releases.json + go run ./cmd/codeaf-release promotion-published --releases releases.json --sha "$(jq -r .staging plan.json)" > published.json + published_at="$(jq -r '.published_at // ""' published.json)" + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase signal --published-at "$published_at" --dry-run="${DRY_RUN:-false}" > message.json + scripts/slack-post.sh message.json + + # A callable Full check runs only for a real promotion candidate. Its page + # job requests issues:write even when skipped, so the caller grants it here. + full_check: + needs: [plan] + if: needs.plan.outputs.outcome == 'promote' + permissions: + contents: read + issues: write + uses: ./.github/workflows/ci-full.yml + with: + ref: ${{ needs.plan.outputs.candidate }} + + # always() defeats GitHub's skipped-ancestor propagation: current and refused + # plans still report even though the Full check job is skipped. + finish: + needs: [plan, full_check] + if: always() && needs.plan.result == 'success' && (needs.full_check.result == 'success' || needs.full_check.result == 'failure' || needs.full_check.result == 'cancelled' || needs.full_check.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # The stored GITHUB_TOKEN header would override the promotion token + # in the push URL, so checkout must not persist its credentials. + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Report the check, move staging, and wait for its build + env: + PLAN_JSON: ${{ needs.plan.outputs.plan }} + CANDIDATE: ${{ needs.plan.outputs.candidate }} + CHECK_RESULT: ${{ needs.full_check.result }} + DRY_RUN: ${{ inputs.dry_run }} + PROMOTION_TOKEN: ${{ secrets.PROMOTION_TOKEN }} + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + GH_TOKEN: ${{ github.token }} + run: | + set -Eeuo pipefail + push_done=false + report_unexpected_error() { + rc=$? + trap - ERR + set +e + marker="" + if [ "${DRY_RUN:-false}" = true ]; then marker="[dry run] "; fi + if [ "$push_done" = true ]; then + text="${marker}*staging moved* to \`${CANDIDATE:0:10}\` but the promotion could not report its build; see the run." + else + text="${marker}*staging did not move* — the promotion step failed before pushing; see the run." + fi + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + text+=$'\n'"<${run_url}|Promote to staging run>" + jq -n --arg text "$text" '{text: $text}' > fallback.json && scripts/slack-post.sh fallback.json + exit "$rc" + } + trap 'report_unexpected_error' ERR + printf '%s\n' "$PLAN_JSON" > plan.json + go run ./cmd/codeaf-release promotion-decision --plan plan.json --check-result "$CHECK_RESULT" --dry-run="${DRY_RUN:-false}" --token-set="$([ -n "$PROMOTION_TOKEN" ] && echo true || echo false)" > decision.json + if [ "$(jq -r .action decision.json)" = report ]; then + phase="$(jq -r .phase decision.json)" + jobs="" + if [ "$phase" = check ] && [ "$CHECK_RESULT" != success ]; then + gh run view "$GITHUB_RUN_ID" --json jobs > jobs.json || printf '{"jobs":[]}\n' > jobs.json + jobs="$(go run ./cmd/codeaf-release promotion-jobs --file jobs.json)" + fi + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase "$phase" --check-result "$CHECK_RESULT" --jobs "$jobs" --dry-run="${DRY_RUN:-false}" > message.json + scripts/slack-post.sh message.json + if [ "$(jq -r .status decision.json)" = success ]; then exit 0; fi + trap - ERR + exit 1 + fi + if ! git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev +refs/heads/staging:refs/remotes/origin/staging 2> fetch.log; then + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file fetch.log)" > message.json + scripts/slack-post.sh message.json + trap - ERR + exit 1 + fi + if ! state="$(go run ./cmd/codeaf-release promotion-recheck --candidate "$CANDIDATE" 2> recheck.log)"; then + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file recheck.log)" > message.json + scripts/slack-post.sh message.json + trap - ERR + exit 1 + fi + if [ "$state" != promote ]; then + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$state" > message.json + scripts/slack-post.sh message.json + if [ "$state" = current ]; then exit 0; fi + trap - ERR + exit 1 + fi + # GITHUB_TOKEN pushes do not start release.yml, so a staging build would not publish. + # GitHub also refuses a GITHUB_TOKEN push when a promoted commit changes .github/workflows/. + pushed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if ! git push "https://x-access-token:${PROMOTION_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$CANDIDATE:refs/heads/staging" 2> push.log; then + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase push --push-error "$(go run ./cmd/codeaf-release promotion-push-error --file push.log)" > message.json + scripts/slack-post.sh message.json + trap - ERR + exit 1 + fi + push_done=true + # A pushed staging commit is not ready until the release workflow publishes its build. + state=absent + for attempt in $(seq 1 60); do + gh run list --workflow release.yml --event push --branch staging --commit "$CANDIDATE" --limit 100 --json databaseId,headSha,status,conclusion,url,createdAt,attempt > runs.json || printf '[]\n' > runs.json + go run ./cmd/codeaf-release promotion-release --runs runs.json --sha "$CANDIDATE" --pushed-at "$pushed_at" > release-state.json + state="$(jq -r .status release-state.json)" + if [ "$state" != absent ]; then break; fi + sleep 10 + done + if [ "$state" = pending ]; then + for attempt in $(seq 1 180); do + gh run list --workflow release.yml --event push --branch staging --commit "$CANDIDATE" --limit 100 --json databaseId,headSha,status,conclusion,url,createdAt,attempt > runs.json || printf '[]\n' > runs.json + go run ./cmd/codeaf-release promotion-release --runs runs.json --sha "$CANDIDATE" --pushed-at "$pushed_at" > release-state.json + state="$(jq -r .status release-state.json)" + if [ "$state" != pending ]; then break; fi + sleep 10 + done + fi + release_url="$(jq -r '.url // ""' release-state.json)" + tag="" + if [ "$state" = success ]; then + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json || printf '[]\n' > releases.json + go run ./cmd/codeaf-release promotion-published --releases releases.json --sha "$CANDIDATE" > published.json + tag="$(jq -r '.tag_name // ""' published.json)" + fi + go run ./cmd/codeaf-release promotion-message --plan plan.json --phase release --release-status "$state" --release-url "$release_url" --release-tag "$tag" > message.json + scripts/slack-post.sh message.json + if [ "$state" = success ] && [ -n "$tag" ]; then exit 0; fi + trap - ERR + exit 1 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/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..39a6959899 --- /dev/null +++ b/cmd/codeaf-release/promotion.go @@ -0,0 +1,702 @@ +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 + } + 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 + } + } + _, 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": + // 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": + 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]") + } + // 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 != "" { + 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..974a766091 --- /dev/null +++ b/cmd/codeaf-release/promotion_test.go @@ -0,0 +1,422 @@ +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) + } + 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") { + 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) + } + }) + } +} + +// 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") + 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/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 91f927d48d..7e49e1773d 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/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. 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/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index c58f684220..613e9a2233 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 81abc87aac..f8522ffcc6 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2703,6 +2703,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"}, @@ -2902,6 +2906,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/promotion_workflow_test.go b/internal/release/promotion_workflow_test.go new file mode 100644 index 0000000000..e87e89a4b6 --- /dev/null +++ b/internal/release/promotion_workflow_test.go @@ -0,0 +1,496 @@ +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"]) + 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) + } + } + 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) + } + } +} + +// 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 := 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 + 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/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"}, 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