Repository navigation
changelog: roll the unreleased entries up as v0.7.0 (#1754) #333
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # PUSHES PUBLISH CHANNELS. `dev` and `staging` name disposable builds, while a | |
| # push to `main` opens or continues an rc line. A stable release remains a | |
| # person's decision through workflow_dispatch. Tags pushed by hand do nothing; | |
| # this workflow creates tags without writing commits onto the release pointers. | |
| on: | |
| push: | |
| branches: | |
| - dev | |
| - staging | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| channel: | |
| description: Release channel | |
| type: choice | |
| options: | |
| - stable | |
| - rc | |
| - dev | |
| - staging | |
| default: stable | |
| component: | |
| description: Semver component for a new stable or rc line | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| default: patch | |
| existing_version: | |
| description: Optional release tag to build and publish again | |
| type: string | |
| required: false | |
| permissions: | |
| contents: read | |
| env: | |
| # The repository publishing this release, which is right before and after the | |
| # GitHub rename this repository is about to take, and right in a fork. | |
| RELEASE_REPOSITORY: ${{ github.repository }} | |
| concurrency: | |
| group: release-${{ github.event_name == 'workflow_dispatch' && inputs.channel || (github.ref_name == 'main' && 'rc' || github.ref_name) }} | |
| # A burst on dev only needs its newest build. Every other channel is a | |
| # deliberate promotion or release decision and is allowed to finish. | |
| cancel-in-progress: ${{ (github.event_name == 'push' && github.ref_name == 'dev') || (github.event_name == 'workflow_dispatch' && inputs.channel == 'dev') }} | |
| jobs: | |
| # The channel, source commit and tag are decided together. THE BRANCH ORDER | |
| # IS CHECKED HERE, BEFORE ANY BUILD: main releases are already on staging, | |
| # and a staging build is already on dev. A re-publish takes the same guard as | |
| # a first publish, because the mere existence of a tag proves no promotion. | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| channel: ${{ steps.resolve.outputs.channel }} | |
| tag: ${{ steps.resolve.outputs.tag }} | |
| sha: ${{ steps.resolve.outputs.sha }} | |
| existing: ${{ steps.resolve.outputs.existing }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Check the release order and resolve the tag | |
| id: resolve | |
| env: | |
| INPUT_CHANNEL: ${{ inputs.channel }} | |
| INPUT_COMPONENT: ${{ inputs.component }} | |
| INPUT_EXISTING_VERSION: ${{ inputs.existing_version }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$GITHUB_EVENT_NAME" = "push" ]; then | |
| channel="$GITHUB_REF_NAME" | |
| [ "$channel" != "main" ] || channel="rc" | |
| component="patch" | |
| existing="" | |
| else | |
| channel="${INPUT_CHANNEL:-stable}" | |
| component="${INPUT_COMPONENT:-patch}" | |
| existing="${INPUT_EXISTING_VERSION:-}" | |
| fi | |
| git fetch --tags --force | |
| tag="" | |
| sha="$GITHUB_SHA" | |
| republish=false | |
| if [ -n "$existing" ]; then | |
| tag="$existing" | |
| if ! sha=$(git rev-parse --verify "refs/tags/${tag}^{commit}"); then | |
| echo "Release tag ${tag} does not exist; choose an existing_version from the Releases page." | |
| exit 1 | |
| fi | |
| if ! existing_kind=$(go run ./cmd/codeaf-release kind "$tag"); then | |
| existing_kind="other" | |
| fi | |
| if [ "$existing_kind" != "$channel" ]; then | |
| echo "Release tag ${tag} is ${existing_kind}, but the chosen channel is ${channel}; choose a tag whose kind matches ${channel}." | |
| exit 1 | |
| fi | |
| republish=true | |
| fi | |
| case "$channel" in | |
| stable|rc) required_ref="refs/heads/main" ;; | |
| dev) required_ref="refs/heads/dev" ;; | |
| staging) required_ref="refs/heads/staging" ;; | |
| esac | |
| if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$GITHUB_REF" != "$required_ref" ]; then | |
| echo "Release channel ${channel} must be dispatched on ${required_ref#refs/heads/}; choose that branch and run Release again." | |
| exit 1 | |
| fi | |
| case "$channel" in | |
| stable|rc) | |
| git fetch --no-tags origin +refs/heads/staging:refs/remotes/origin/staging | |
| if ! git merge-base --is-ancestor "$sha" origin/staging; then | |
| echo "$sha is not on origin/staging; fix it with: git push origin $sha:staging" | |
| exit 1 | |
| fi | |
| ;; | |
| staging) | |
| git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev | |
| if ! git merge-base --is-ancestor "$sha" origin/dev; then | |
| echo "$sha is not on origin/dev; the commit must reach dev through a pull request before it can be fast-forwarded onto staging." | |
| exit 1 | |
| fi | |
| ;; | |
| esac | |
| if [ -z "$existing" ]; then | |
| if [ "$channel" = "dev" ] || [ "$channel" = "staging" ]; then | |
| # The channel resolver deliberately does not read stdin. Keeping | |
| # git tag out of a pipe prevents its writer dying with SIGPIPE | |
| # under pipefail once the repository has enough tags. | |
| tag=$(go run ./cmd/codeaf-release next \ | |
| --channel "$channel" --sha "$sha" --date "$(date -u +%Y%m%d)") | |
| else | |
| tag=$(git tag --list | go run ./cmd/codeaf-release next \ | |
| --channel "$channel" --component "$component") | |
| fi | |
| fi | |
| { | |
| echo "channel=$channel" | |
| echo "tag=$tag" | |
| echo "sha=$sha" | |
| echo "existing=$republish" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "$channel release $tag from $sha" | |
| test: | |
| needs: prepare | |
| if: needs.prepare.outputs.channel != 'dev' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| # This is a last look at the narrow surface a released binary needs to | |
| # start. The full suite ran before staging; dev just passed its pull | |
| # request gate and deliberately does not pay for this second look. | |
| - name: Test release surface | |
| run: | | |
| set -euo pipefail | |
| # THE LEDGER ONLY SHRINKS, AND IT IS ALLOWED TO BE GONE. An empty or | |
| # absent .github/known-red.txt is the success state and must leave | |
| # no -skip expression in the way. It is read only when it is there, | |
| # the way scripts/laws.sh reads it: under pipefail an awk over a file | |
| # that does not exist is an exit inside this assignment, and that | |
| # ended this step before go test ran on the first staging build | |
| # after the ledger was deleted — no staging, rc or stable build | |
| # could publish, and dev never noticed because it skips this job. | |
| skip="" | |
| if [ -s .github/known-red.txt ]; then | |
| skip="$(awk '!/^#/ && NF {print}' .github/known-red.txt | paste -sd'|' -)" | |
| fi | |
| test_args=() | |
| [ -z "$skip" ] || test_args=(-skip "^(${skip})$") | |
| go test "${test_args[@]}" \ | |
| ./cmd/codeaf \ | |
| ./internal/config \ | |
| ./internal/exec/... \ | |
| ./internal/filelock \ | |
| ./internal/lease | |
| # ONE BUILD JOB HOLDS ALL SIX TARGETS. Besides keeping one artifact, the loop | |
| # matters because furrow's staging folder contains exactly one platform at a | |
| # time; every target prepares its own bytes immediately before compilation. | |
| build: | |
| needs: [prepare, test] | |
| if: always() && needs.prepare.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Build all targets with their furrow | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| CGO_ENABLED: "0" | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist | |
| go generate ./internal/manual | |
| built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| buildinfo_package="$(go list -m)/internal/buildinfo" | |
| targets=( | |
| "darwin amd64" | |
| "darwin arm64" | |
| "linux amd64" | |
| "linux arm64" | |
| "windows amd64" | |
| "windows arm64" | |
| ) | |
| for target in "${targets[@]}"; do | |
| read -r goos goarch <<< "$target" | |
| extension="" | |
| if [ "$goos" = "windows" ]; then | |
| rm -f internal/furrowbin/cache/furrow-*.gz | |
| extension=".exe" | |
| else | |
| env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ | |
| -goos "$goos" -goarch "$goarch" | |
| fi | |
| GOOS="$goos" GOARCH="$goarch" go build \ | |
| -tags=codeaf_packed_manual \ | |
| -trimpath \ | |
| -ldflags="-s -w -X ${buildinfo_package}.rev=${TAG} -X ${buildinfo_package}.dirty=false -X ${buildinfo_package}.builtAt=${built_at}" \ | |
| -o "dist/codeaf-${goos}-${goarch}${extension}" \ | |
| ./cmd/codeaf | |
| done | |
| # THE NOTICE TRAVELS WITH THE BINARIES for BSD-3-Clause clause 2, and | |
| # its checksum accounts for the copied document. | |
| cp THIRD-PARTY-NOTICES.md dist/ | |
| (cd dist && sha256sum codeaf-* THIRD-PARTY-NOTICES.md > checksums.txt) | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: codeaf-release | |
| path: dist/* | |
| if-no-files-found: error | |
| # PUBLISH SAYS always() FOR THE SAME REASON build DOES. GitHub skips a job | |
| # whose dependency chain contains a skipped job unless the job's own condition | |
| # says otherwise, and the chain here always contains one: `test` is skipped on | |
| # every dev build by design. The first live run on 2026-09-11 (dev, ef36c3770) | |
| # proved it — prepare and build green, eight minutes of binaries uploaded, and | |
| # publish skipped without a word, so no dev release existed. The two explicit | |
| # results below are the whole gate: a red build still publishes nothing. | |
| publish: | |
| needs: [prepare, build] | |
| if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' | |
| permissions: | |
| contents: write | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: codeaf-release | |
| path: dist | |
| - name: Write release notes | |
| id: notes | |
| env: | |
| CHANNEL: ${{ needs.prepare.outputs.channel }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_SHA: ${{ needs.prepare.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| cat > install.md <<EOF | |
| Install this ${CHANNEL} build: | |
| \`\`\`sh | |
| curl -fsSL https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/${RELEASE_SHA}/scripts/install.sh | bash -s -- --${CHANNEL} | |
| curl -fsSL https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/${RELEASE_SHA}/scripts/install.sh | VERSION=${TAG} bash | |
| \`\`\` | |
| EOF | |
| if [ "$CHANNEL" = "dev" ]; then | |
| cat >> install.md <<'EOF' | |
| This installs this dev channel as `devaf` beside codeaf: | |
| ```sh | |
| 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 | |
| # THE SECTION IS RENDERED FOR THE PAGE, NOT COPIED ONTO IT. GitHub | |
| # refuses a release body over its character ceiling, and a rolled-up | |
| # section carries every fold of every entry — the first roll-up here | |
| # weighed 1.7 MB. codeaf-changes prints the section whole when it | |
| # fits and the headline layer with a pointer to the record when it | |
| # does not; it prints nothing for a tag the changelog has no | |
| # section for, which is the fall-back below. | |
| if go run ./cmd/codeaf-changes notes "$TAG" > changelog-notes.md && [ -s changelog-notes.md ]; then | |
| { cat changelog-notes.md; echo; cat install.md; } > notes.md | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| echo "Release notes taken from the ${TAG} section of CHANGELOG.md." | |
| else | |
| echo "generated=true" >> "$GITHUB_OUTPUT" | |
| echo "::warning::CHANGELOG.md has no '## ${TAG}' section. Falling back to generated notes." | |
| fi | |
| else | |
| cp install.md notes.md | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Publish | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| CHANNEL: ${{ needs.prepare.outputs.channel }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_SHA: ${{ needs.prepare.outputs.sha }} | |
| REPUBLISH: ${{ needs.prepare.outputs.existing }} | |
| GENERATED_NOTES: ${{ steps.notes.outputs.generated }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$CHANNEL" = "stable" ]; then | |
| marks=(--latest --prerelease=false) | |
| else | |
| marks=(--latest=false --prerelease) | |
| fi | |
| if [ "$REPUBLISH" = "true" ]; then | |
| gh release upload "$TAG" dist/* --repo "$RELEASE_REPOSITORY" --clobber | |
| gh release edit "$TAG" --repo "$RELEASE_REPOSITORY" --title "codeaf $TAG" "${marks[@]}" | |
| else | |
| if [ "$GENERATED_NOTES" = "true" ]; then | |
| notes=(--generate-notes --notes-file install.md) | |
| else | |
| notes=(--notes-file notes.md) | |
| fi | |
| if gh release view "$TAG" --repo "$RELEASE_REPOSITORY" >/dev/null 2>&1; then | |
| # A RELEASE BELONGS TO ITS TAG'S COMMIT. The tag is the reliable | |
| # attachment GitHub publishes, so an ordinary retry may clobber | |
| # only when that commit is exactly the one whose bytes were built. | |
| git fetch --tags --force | |
| if ! existing_sha=$(git rev-parse --verify "refs/tags/${TAG}^{commit}"); then | |
| echo "Release $TAG exists, but its tag commit could not be resolved; use the existing_version dispatch to repair it deliberately." | |
| exit 1 | |
| fi | |
| if [ "$existing_sha" != "$RELEASE_SHA" ]; then | |
| echo "Release $TAG points at $existing_sha, not $RELEASE_SHA; use the existing_version dispatch to repair it deliberately." | |
| exit 1 | |
| fi | |
| gh release upload "$TAG" dist/* --repo "$RELEASE_REPOSITORY" --clobber | |
| gh release edit "$TAG" --repo "$RELEASE_REPOSITORY" --title "codeaf $TAG" "${marks[@]}" | |
| else | |
| gh release create "$TAG" dist/* \ | |
| --repo "$RELEASE_REPOSITORY" \ | |
| --target "$RELEASE_SHA" \ | |
| --title "codeaf $TAG" \ | |
| "${notes[@]}" \ | |
| "${marks[@]}" | |
| fi | |
| fi | |
| if [ "$CHANNEL" = "dev" ] || [ "$CHANNEL" = "staging" ]; then | |
| # The listing could hide disposable tags only after stable and rc | |
| # releases alone exceed this thousand-entry bound. Each disposable | |
| # channel is kept at forty, so pagination is unnecessary until then. | |
| gh release list --repo "$RELEASE_REPOSITORY" --limit 1000 \ | |
| --json tagName,createdAt --jq '.[] | [.tagName, .createdAt] | @tsv' > releases.tsv | |
| go run ./cmd/codeaf-release prune --channel "$CHANNEL" < releases.tsv > expired-tags.txt | |
| while IFS= read -r expired; do | |
| [ -z "$expired" ] || gh release delete "$expired" \ | |
| --repo "$RELEASE_REPOSITORY" --cleanup-tag --yes | |
| done < expired-tags.txt | |
| fi |