From 4a2a886e4d4e7d1e248a1d26556da6f720e26bd5 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 15:03:49 +0900 Subject: [PATCH 01/10] ci: enforce the checks a v1.0 tag depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release procedure was documented end to end but unenforced, and four defects were live on main. The fidelity manifest could drift silently. internal/generated is committed but derived, and nothing verified the committed output still matched its sources. The Go tests guard the manifest's shape — floors, registered services, the CRUD registry — but none of them notice an operation a provider gained and the manifest never did; absence from a 7,475-entry map is invisible. A codegen-drift job now regenerates and diffs. Its first catch was this repo: the manifest's own doc comment was a release behind its template. Eight changelog fragments carried an empty Issue field, which changie renders as ([#](https://github.com/skyoo2003/devcloud/issues/)) and batches without complaint. They would have shipped as dead links in the v1.0 release notes. The fragments are fixed and the release workflow now rejects a batched file containing one. One fragment sat in .changes/unreleased/ rather than changes/unreleased/, so it was excluded from every release since it was written. Moved; the batch goes from 25 entries to 26. A tag push published without waiting for tests. CI does trigger on tags, but the two workflows race, so a red commit could still ship binaries, container images and a Homebrew formula. The release job now needs a test job. Also ships the docs tree inside the release archive, so docs are versioned by tag: the docs/ beside a binary describe that binary. --- .github/workflows/ci.yml | 25 ++++++++++++ .github/workflows/release.yml | 33 +++++++++++++-- .goreleaser.yaml | 4 ++ changes/unreleased/Added-20260809-120000.yaml | 2 +- changes/unreleased/Added-20260809-140000.yaml | 2 +- .../unreleased/Fixed-20260725-010755.yaml | 3 +- changes/unreleased/Fixed-20260809-120100.yaml | 2 +- changes/unreleased/Fixed-20260809-140100.yaml | 2 +- changes/unreleased/Fixed-20260809-160000.yaml | 2 +- changes/unreleased/Fixed-20260809-160100.yaml | 2 +- changes/unreleased/Fixed-20260809-170000.yaml | 2 +- changes/unreleased/Fixed-20260809-170100.yaml | 2 +- docs/release.md | 40 +++++++++++++++++-- internal/generated/fidelity/manifest_gen.go | 12 ++++-- 14 files changed, 115 insertions(+), 18 deletions(-) rename .changes/unreleased/Fixed-113.yaml => changes/unreleased/Fixed-20260725-010755.yaml (80%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5657267..631cfe6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,28 @@ jobs: run: | CGO_ENABLED=1 go build -o dist/devcloud ./cmd/devcloud CGO_ENABLED=1 go build -o dist/codegen ./cmd/codegen + + # internal/generated is committed but derived. The Go tests check the fidelity + # manifest's shape — floors, registered services, the CRUD registry — none of + # which notice an operation a provider gained and the manifest never did. + # Regenerating and diffing is the only check that does. + codegen-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: "1.26" + + # No SQLite headers: cmd/codegen builds with CGO_ENABLED=0. + - name: Regenerate + run: make codegen + + - name: Check the committed output is current + run: | + if ! git diff --exit-code internal/generated; then + echo "::error::internal/generated is stale. Run 'make codegen' and commit the result." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7595b7b..afe331a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,26 @@ env: HOMEBREW_TAP_REPO: homebrew-tap jobs: + # CI triggers on a tag push too, but the two workflows race — nothing stops + # GoReleaser from publishing binaries, images and a Homebrew formula off a red + # commit. This job is what makes the tag wait. + test: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Install SQLite dev headers + run: sudo apt-get install -y libsqlite3-dev + - name: Run Go tests + run: CGO_ENABLED=1 go test ./... + release: + needs: test runs-on: ubuntu-latest permissions: contents: write @@ -46,13 +65,21 @@ jobs: if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then echo "extra_flags=--snapshot" >> "$GITHUB_OUTPUT" fi - - name: Verify changie release notes exist + - name: Verify changie release notes run: | - if [ ! -f "changes/${{ steps.resolve_tag.outputs.tag }}.md" ]; then - echo "::error::Changie fragment changes/${{ steps.resolve_tag.outputs.tag }}.md not found." + notes="changes/${{ steps.resolve_tag.outputs.tag }}.md" + if [ ! -f "$notes" ]; then + echo "::error::Changie fragment $notes not found." echo "::error::Run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge' before pushing the tag." exit 1 fi + # 'changie batch' does not validate custom fields on hand-written + # fragments, so an empty Issue renders as ([#](.../issues/)) and ships + # as a dead link in the release notes. + if grep -n 'issues/)' "$notes"; then + echo "::error::The lines above have no issue number. Fix the Issue field in their changes/unreleased fragment, then re-batch." + exit 1 + fi - name: Set up Docker Buildx if: ${{ github.event.inputs.dry_run != 'true' }} uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9d155cf..7131884 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,6 +32,10 @@ archives: - LICENSE - README.md - CHANGELOG.md + # Docs are versioned by tag, and the ones stating what a release promises — + # the fidelity manifest, the compatibility policy — are only true of the + # binary they ship beside. + - docs changelog: disable: false diff --git a/changes/unreleased/Added-20260809-120000.yaml b/changes/unreleased/Added-20260809-120000.yaml index b15def8..a7e69f3 100644 --- a/changes/unreleased/Added-20260809-120000.yaml +++ b/changes/unreleased/Added-20260809-120000.yaml @@ -2,4 +2,4 @@ kind: Added body: Per-operation fidelity manifest declaring every operation as hand-verified, auto-crud or unimplemented, exposed at `GET /devcloud/api/fidelity` and enforced by a build-failing coverage test time: 2026-08-09T12:00:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Added-20260809-140000.yaml b/changes/unreleased/Added-20260809-140000.yaml index 50620c6..9c59145 100644 --- a/changes/unreleased/Added-20260809-140000.yaml +++ b/changes/unreleased/Added-20260809-140000.yaml @@ -2,4 +2,4 @@ kind: Added body: Real tag support for KMS, CloudWatch and EventBridge — TagResource, UntagResource and ListTagsForResource/ListResourceTags now persist tags per resource ARN instead of being echoed by the generic CRUD engine time: 2026-08-09T14:00:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/.changes/unreleased/Fixed-113.yaml b/changes/unreleased/Fixed-20260725-010755.yaml similarity index 80% rename from .changes/unreleased/Fixed-113.yaml rename to changes/unreleased/Fixed-20260725-010755.yaml index 8973b11..228bace 100644 --- a/.changes/unreleased/Fixed-113.yaml +++ b/changes/unreleased/Fixed-20260725-010755.yaml @@ -1,4 +1,5 @@ kind: Fixed body: Config-time warnings (deprecated `dashboard` key, unknown `DEVCLOUD_SERVICES` tier) now honor `logging.format`/`logging.level` instead of always printing as plain text before the logger is configured +time: 2026-07-25T01:07:55.000000+09:00 custom: - Issue: "113" + Issue: "113" diff --git a/changes/unreleased/Fixed-20260809-120100.yaml b/changes/unreleased/Fixed-20260809-120100.yaml index 7d4df19..0ed35f3 100644 --- a/changes/unreleased/Fixed-20260809-120100.yaml +++ b/changes/unreleased/Fixed-20260809-120100.yaml @@ -2,4 +2,4 @@ kind: Fixed body: Codegen now parses operations bound to Smithy resource shapes, recovering 285 operations that were invisible to the generator (bedrock 0 of 101, lambda 19 of 85, ecs 12 of 76, transfer 29 of 71, sso-admin 67 of 79) time: 2026-08-09T12:01:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Fixed-20260809-140100.yaml b/changes/unreleased/Fixed-20260809-140100.yaml index 522bcb8..984da28 100644 --- a/changes/unreleased/Fixed-20260809-140100.yaml +++ b/changes/unreleased/Fixed-20260809-140100.yaml @@ -2,4 +2,4 @@ kind: Fixed body: The fidelity manifest now reads each provider's actual dispatch instead of intersecting with the Smithy model, recovering 226 served operations it had hidden (dynamodbstreams listed 4 of its 22, acm's UpdateCertificate, bedrock's InvokeModelWithResponseStream) and dropping 5 non-operations it had invented (identitystore Description/DisplayName/Emails, pipes DELETE/POST) time: 2026-08-09T14:01:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Fixed-20260809-160000.yaml b/changes/unreleased/Fixed-20260809-160000.yaml index ff5a908..e0f6292 100644 --- a/changes/unreleased/Fixed-20260809-160000.yaml +++ b/changes/unreleased/Fixed-20260809-160000.yaml @@ -2,4 +2,4 @@ kind: Fixed body: EventBridge now drops a bus's or rule's tags when it is deleted. ARNs are derived from the name, so recreating a deleted resource reused its ARN and inherited the previous tags time: 2026-08-09T16:00:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Fixed-20260809-160100.yaml b/changes/unreleased/Fixed-20260809-160100.yaml index 13fd2f2..6055e50 100644 --- a/changes/unreleased/Fixed-20260809-160100.yaml +++ b/changes/unreleased/Fixed-20260809-160100.yaml @@ -2,4 +2,4 @@ kind: Fixed body: Restored CloudWatch's 17 CRUD-engine operations. The gateway picks the protocol from the request, not from the provider, so CloudWatch reaches the engine whenever a client speaks JSON — filtering the registry by the provider's declared protocol had removed that coverage outright time: 2026-08-09T16:01:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Fixed-20260809-170000.yaml b/changes/unreleased/Fixed-20260809-170000.yaml index 1eb0996..d7ca838 100644 --- a/changes/unreleased/Fixed-20260809-170000.yaml +++ b/changes/unreleased/Fixed-20260809-170000.yaml @@ -2,4 +2,4 @@ kind: Fixed body: EventBridge rule ARNs now name their event bus, as AWS does. A rule name is unique per bus, so same-named rules on two custom buses previously shared one ARN — and with it, one tag set, where tagging one rule changed the other's and deleting one wiped the survivor's time: 2026-08-09T17:00:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/changes/unreleased/Fixed-20260809-170100.yaml b/changes/unreleased/Fixed-20260809-170100.yaml index 0b5793d..38bc9d6 100644 --- a/changes/unreleased/Fixed-20260809-170100.yaml +++ b/changes/unreleased/Fixed-20260809-170100.yaml @@ -2,4 +2,4 @@ kind: Fixed body: CloudWatch now drops an alarm's tags when the alarm is deleted, so recreating an alarm under the same name no longer inherits the old one's tags time: 2026-08-09T17:01:00.000000+09:00 custom: - Issue: "" + Issue: "126" diff --git a/docs/release.md b/docs/release.md index 7621275..2d3c8a5 100644 --- a/docs/release.md +++ b/docs/release.md @@ -32,6 +32,32 @@ small YAML file under `changes/unreleased/`. Commit it alongside your code chang Config lives in [`.changie.yaml`](../.changie.yaml). +Prefer `changie new` over writing the YAML by hand: it enforces the issue number, and a +fragment without one renders as a dead link in the release notes. + +## Pre-flight checklist + +Run through this before batching. CI enforces most of it — the list is for catching problems +before the tag, not instead of CI. + +- [ ] **`main` is green.** CI, lint, CodeQL and compat. +- [ ] **Generated code is current** — `make codegen && git diff --exit-code internal/generated`. + `internal/generated` is committed but derived; a stale fidelity manifest misreports what + the release can be trusted to do. Enforced by CI's `codegen-drift` job. +- [ ] **boto3 compatibility passes** — `make test-compat`. +- [ ] **Go tests pass** — `CGO_ENABLED=1 go test ./...`. The Release workflow re-runs this and + refuses to publish if it fails. +- [ ] **Every unreleased fragment carries an issue number** — + `grep -L 'Issue: "[0-9]' changes/unreleased/*.yaml` prints nothing. +- [ ] **`changes/unreleased/` is not empty.** No fragments means either nothing shipped or + someone forgot one. +- [ ] **Deprecation review.** If this release *removes* anything previously deprecated — a + config key, an env var, an admin route — confirm it shipped for at least one release + with a warning first. The precedent is the `dashboard` → `admin` rename in + [`internal/config/config.go`](../internal/config/config.go): the old key kept working, + emitted a warning, and only then became removable. Removing without that overlap is a + major-version change. + ## Cutting a release 1. **Make sure `main` is green** and holds all changes you want in the release. @@ -70,10 +96,12 @@ Config lives in [`.changie.yaml`](../.changie.yaml). Pushing the tag triggers the Release workflow. It will: -- verify `changes/v0.3.0.md` exists (guard against tagging without release notes), +- run the Go test suite and stop before publishing anything if it fails, +- verify `changes/v0.3.0.md` exists (guard against tagging without release notes) and that no + entry in it is missing its issue number, - run GoReleaser, which builds binaries for **darwin/linux/windows × amd64/arm64**, packages - them as `tar.gz` (`zip` on Windows) with `LICENSE`/`README.md`/`CHANGELOG.md`, and generates - a SHA-256 `CHECKSUMS` file, + them as `tar.gz` (`zip` on Windows) with `LICENSE`/`README.md`/`CHANGELOG.md` and the `docs/` + tree, and generates a SHA-256 `CHECKSUMS` file, - build and push `*-alpine` container images to `ghcr.io/skyoo2003/devcloud`, - publish a **GitHub Release** whose notes come from `changes/v0.3.0.md` (`--release-notes`, `mode: replace`). @@ -91,4 +119,10 @@ GHCR or GitHub Releases. - The tag (`v0.3.0`) and the fragment file (`changes/v0.3.0.md`) must match exactly. - `changie batch` + `changie merge` must be committed **before** the tag is pushed. +- The tagged commit must pass the Go test suite; the workflow will not publish otherwise. +- Every entry in the batched notes needs an issue number. - No manual GitHub Release editing — release notes are owned by Changie fragments. + +Docs ship inside the release archive, so they are versioned by tag: the `docs/` tree in +`devcloud_v0.3.0_linux_amd64.tar.gz` describes exactly the binary beside it. There is no +separate docs site to version. diff --git a/internal/generated/fidelity/manifest_gen.go b/internal/generated/fidelity/manifest_gen.go index 99e93c1..acaf3e3 100644 --- a/internal/generated/fidelity/manifest_gen.go +++ b/internal/generated/fidelity/manifest_gen.go @@ -6,8 +6,9 @@ // to "can I trust this call?" — see docs/fidelity-manifest.md. // // The manifest is derived, never hand-written: the operation universe comes from -// the in-tree Smithy models, auto-crud from the generated CRUD registry, and -// hand-verified from the dispatch literals of each provider. +// the in-tree Smithy models unioned with what providers serve, auto-crud from +// the generated CRUD registry, and hand-verified from the dispatch literals of +// each provider's HandleRequest. package fidelity // Tier is how faithfully an operation is served. @@ -19,7 +20,12 @@ const ( // TierAutoCRUD is served by the generic CRUD engine with plausible, // store-backed responses: no validation, no business logic. TierAutoCRUD Tier = "auto-crud" - // TierUnimplemented returns an honest InvalidAction error. + // TierUnimplemented is not served: the call fails with an error rather than + // a made-up success. The error is the provider's own, not one shared code — + // JSON and Query services fall through to InvalidAction (HTTP 400), while + // the path-routed providers answer in their own vocabulary (s3 + // MethodNotAllowed, lambda ResourceNotFoundException, bedrock + // UnsupportedOperation). TierUnimplemented Tier = "unimplemented" ) From 66dbdea370ef1d5a7a8a99a539e88f6cc083d8cc Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 15:05:04 +0900 Subject: [PATCH 02/10] docs(changelog): fragment for the release-hardening change --- changes/unreleased/Changed-20260809-180000.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/unreleased/Changed-20260809-180000.yaml diff --git a/changes/unreleased/Changed-20260809-180000.yaml b/changes/unreleased/Changed-20260809-180000.yaml new file mode 100644 index 0000000..cdf76e9 --- /dev/null +++ b/changes/unreleased/Changed-20260809-180000.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: Release archives now carry the `docs/` tree alongside the binary, so the documentation you unpack — including the fidelity manifest and the release's compatibility promises — describes exactly the version you downloaded +time: 2026-08-09T18:00:00.000000+09:00 +custom: + Issue: "127" From b6b0dd7b4b217702796313ff0c53d11667352d6e Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 15:20:11 +0900 Subject: [PATCH 03/10] fix(ci): close the holes the release gate left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six follow-ups from review, each verified against the tree. The drift check did not gate publishing. release.yml waited only on the Go tests, which are exactly the checks that cannot see a stale fidelity manifest — the reason the drift job exists. A tag could publish generated code that misstates what the release serves. The release job now needs a codegen-drift job of its own rather than trusting that CI won the race. The drift check missed untracked output. `git diff --exit-code` ignores a newly generated package, so adding a service model and forgetting to commit its generated directory passed. cmd/codegen/main.go:18-20 already documents this trap and smithy-sync.yml:44-46 already avoids it; both drift checks now use `git status --porcelain`, as that one does. codegen exited 0 after skipping a model. An unreadable or malformed model printed to stderr and continued, so generation could be incomplete while the drift check saw no changed files and called it clean. It now exits non-zero, and does so before writing the CRUD registry and fidelity manifest — those describe the whole fleet, and building them from a partial set would state in generated code that a service's operations do not exist. The issue-number guard only caught the empty case. `Issue: "abc"`, `"0"` and `"-1"` all render links that go nowhere and all batched cleanly. The check now requires every entry to end in a positive integer rather than enumerating the malformed spellings. Manual dispatch tested the wrong commit. actions/checkout defaults to the ref the run was launched from, not the tag input, so the gate vouched for a branch while GoReleaser was asked for a tag. Both jobs now check out the resolved tag. The gate ran on amd64 only while GoReleaser publishes arm64 artifacts. The test job now mirrors ci.yml's architecture matrix. --- .github/workflows/ci.yml | 7 ++++- .github/workflows/release.yml | 56 ++++++++++++++++++++++++++++++----- cmd/codegen/main.go | 15 ++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 631cfe6..c85bf9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,13 @@ jobs: run: make codegen - name: Check the committed output is current + # --porcelain rather than `git diff --exit-code` so a newly generated + # package (an untracked directory) counts as drift instead of being + # silently dropped, matching smithy-sync.yml's check. run: | - if ! git diff --exit-code internal/generated; then + drift="$(git status --porcelain internal/generated)" + if [ -n "$drift" ]; then + echo "$drift" echo "::error::internal/generated is stale. Run 'make codegen' and commit the result." exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afe331a..1b901b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,12 +25,23 @@ env: jobs: # CI triggers on a tag push too, but the two workflows race — nothing stops # GoReleaser from publishing binaries, images and a Homebrew formula off a red - # commit. This job is what makes the tag wait. + # commit. These two jobs are what make the tag wait, and they mirror the + # checks in ci.yml rather than trusting that CI got there first. + # + # Both check out the resolved tag explicitly: on workflow_dispatch the default + # ref is whatever branch the run was launched from, not the tag input, so the + # gate would otherwise vouch for the wrong commit. test: - runs-on: ubuntu-latest + strategy: + matrix: + # GoReleaser publishes arm64 artifacts, so the gate covers arm64 too. + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} steps: - name: Checkout the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} - name: Set up Go uses: actions/setup-go@v7 with: @@ -41,8 +52,34 @@ jobs: - name: Run Go tests run: CGO_ENABLED=1 go test ./... + # The Go tests cannot see a stale fidelity manifest — that is the whole reason + # ci.yml has a codegen-drift job — so waiting only on `test` would let a tag + # publish generated code that misstates what the release serves. + codegen-drift: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Regenerate + run: make codegen + - name: Check the committed output is current + run: | + drift="$(git status --porcelain internal/generated)" + if [ -n "$drift" ]; then + echo "$drift" + echo "::error::internal/generated is stale on this tag. Run 'make codegen', commit, and re-tag." + exit 1 + fi + release: - needs: test + needs: [test, codegen-drift] runs-on: ubuntu-latest permissions: contents: write @@ -53,6 +90,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + ref: ${{ github.event.inputs.tag || github.ref }} - name: Set up Go uses: actions/setup-go@v7 with: @@ -74,10 +112,14 @@ jobs: exit 1 fi # 'changie batch' does not validate custom fields on hand-written - # fragments, so an empty Issue renders as ([#](.../issues/)) and ships - # as a dead link in the release notes. - if grep -n 'issues/)' "$notes"; then - echo "::error::The lines above have no issue number. Fix the Issue field in their changes/unreleased fragment, then re-batch." + # fragments, so an Issue that is empty, zero or non-numeric renders a + # dead link and batches without complaint. Every entry must end in + # .../issues/), so check the good form rather than + # enumerating the bad ones. + bad="$(grep '^\* ' "$notes" | grep -vE 'issues/[1-9][0-9]*\)\)$' || true)" + if [ -n "$bad" ]; then + echo "$bad" + echo "::error::The entries above do not end in a valid issue link. Fix the Issue field in their changes/unreleased fragment, then re-batch." exit 1 fi - name: Set up Docker Buildx diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index 3939b6b..8fa3bdf 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -47,6 +47,10 @@ func main() { var crudServices []codegen.CRUDServiceData modelOps := make(map[string][]string) + // A model that cannot be read or parsed is skipped, which used to leave the + // exit status at 0 — so a drift check downstream saw no changed files and + // called incomplete generation clean. + skipped := false for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { @@ -56,12 +60,14 @@ func main() { data, err := os.ReadFile(filepath.Join(*modelsDir, entry.Name())) if err != nil { fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", entry.Name(), err) + skipped = true continue } model, err := codegen.ParseSmithyJSON(data) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", entry.Name(), err) + skipped = true continue } @@ -94,6 +100,15 @@ func main() { } } + // Bail before the aggregate artefacts: the CRUD registry and the fidelity + // manifest describe the whole fleet, and writing them from a set that is + // missing a service would state, in generated code, that its operations do + // not exist. + if skipped { + fmt.Fprintln(os.Stderr, "Error: one or more models were skipped; generated output is incomplete") + os.Exit(1) + } + // Write the aggregate CRUD registry only when generating the full fleet // (a filtered run would otherwise clobber it with a partial registry). if len(allowedServices) == 0 { From 2ea4012d2541b9b8c2d5cae73393d2050daf7b0d Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 16:26:40 +0900 Subject: [PATCH 04/10] fix(ci): gate the release on the compatibility suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compat.yml triggers on branch pushes and pull requests only, so on a tag it does not race the release the way CI does — it never runs at all. The boto3 suite is the guardrail this project leans on hardest, and it had no bearing on what a tag published; docs/release.md listed it as a manual pre-flight step, which is the judgment call the release gate exists to remove. The pre-flight checklist now says which boxes the workflow re-runs and which only a human catches, and quotes the drift check the way CI actually runs it (git status --porcelain, not git diff --exit-code). --- .github/workflows/release.yml | 33 ++++++++++++++++++++++++++++++++- docs/release.md | 23 ++++++++++++++--------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b901b7..99b1fc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,8 +78,39 @@ jobs: exit 1 fi + # compat.yml triggers on branch pushes and pull requests only, so on a tag it + # does not race the release — it never runs at all. Without this job the boto3 + # suite, the guardrail the project leans on hardest, has no bearing on what + # gets published. + compat: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Install SQLite dev headers + run: sudo apt-get install -y libsqlite3-dev + - name: Build devcloud binary + run: CGO_ENABLED=1 go build -o dist/devcloud ./cmd/devcloud + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install Python dependencies + run: pip install -r tests/compatibility/requirements.txt + - name: Run compatibility tests + run: pytest tests/compatibility/ -v --tb=short + env: + DEVCLOUD_BIN: dist/devcloud + release: - needs: [test, codegen-drift] + needs: [test, codegen-drift, compat] runs-on: ubuntu-latest permissions: contents: write diff --git a/docs/release.md b/docs/release.md index 2d3c8a5..35a7ac8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -37,16 +37,18 @@ fragment without one renders as a dead link in the release notes. ## Pre-flight checklist -Run through this before batching. CI enforces most of it — the list is for catching problems -before the tag, not instead of CI. +Run through this before batching. -- [ ] **`main` is green.** CI, lint, CodeQL and compat. -- [ ] **Generated code is current** — `make codegen && git diff --exit-code internal/generated`. - `internal/generated` is committed but derived; a stale fidelity manifest misreports what - the release can be trusted to do. Enforced by CI's `codegen-drift` job. +The first three are re-run by the Release workflow against the tagged commit, which refuses to +publish if any fails — tick them to find out on your machine rather than from a failed tag. +The rest are only caught here. + +- [ ] **Generated code is current** — `make codegen && git status --porcelain internal/generated` + prints nothing. `internal/generated` is committed but derived; a stale fidelity manifest + misreports what the release can be trusted to do. - [ ] **boto3 compatibility passes** — `make test-compat`. -- [ ] **Go tests pass** — `CGO_ENABLED=1 go test ./...`. The Release workflow re-runs this and - refuses to publish if it fails. +- [ ] **Go tests pass** — `CGO_ENABLED=1 go test ./...`. +- [ ] **`main` is green**, including lint and CodeQL. - [ ] **Every unreleased fragment carries an issue number** — `grep -L 'Issue: "[0-9]' changes/unreleased/*.yaml` prints nothing. - [ ] **`changes/unreleased/` is not empty.** No fragments means either nothing shipped or @@ -96,7 +98,10 @@ before the tag, not instead of CI. Pushing the tag triggers the Release workflow. It will: -- run the Go test suite and stop before publishing anything if it fails, +- re-run the guardrails against the tagged commit and stop before publishing anything if any + of them fails: the Go test suite on amd64 and arm64, the boto3 compatibility suite, and the + codegen drift check. CI is not relied on here — it races the tag, and `compat.yml` does not + trigger on tags at all, - verify `changes/v0.3.0.md` exists (guard against tagging without release notes) and that no entry in it is missing its issue number, - run GoReleaser, which builds binaries for **darwin/linux/windows × amd64/arm64**, packages From 20a3e804daedefb634fef65db374fa454b4970a6 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 16:56:23 +0900 Subject: [PATCH 05/10] fix(ci): regenerate from a clean tree so retired outputs surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerating in place only overwrites the filenames the generator still emits. internal/codegen/generator.go has no deletion path — no os.Remove, no RemoveAll — so an output it stops emitting stays on disk, tracked and unchanged, and `git status --porcelain` reports nothing. The drift check then passes on generated code that no longer matches its generator, which is the case the check exists to catch. The repo has hit this before: cmd/codegen/ main.go:16-22 exists because stubs deleted in #91 and #96 kept coming back. Both drift jobs now clear internal/generated before regenerating, so a retired or renamed output shows up as a deletion. Every tracked file under that tree carries a generated marker, so nothing hand-written is at risk, and scripts/generate-imports.sh writes to cmd/devcloud/imports.go, outside it. Verified by committing a file the generator does not emit: in-place regeneration left porcelain empty, clean-tree regeneration reported ` D internal/generated/zz_obsolete_gen.go`. A clean-tree run against the current tree reproduces it byte for byte, so the stricter check starts green. --- .github/workflows/ci.yml | 19 ++++++++++++++----- .github/workflows/release.yml | 9 +++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c85bf9f..152b09e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,14 +54,23 @@ jobs: with: go-version: "1.26" + # Regenerating in place only overwrites the filenames the generator still + # emits — it never removes one it has stopped emitting, and + # internal/codegen/generator.go has no deletion path at all. Clearing the + # tree first turns a retired or renamed output into a visible deletion. + # Every tracked file under internal/generated carries a generated marker, + # so nothing hand-written is lost. + # # No SQLite headers: cmd/codegen builds with CGO_ENABLED=0. - - name: Regenerate - run: make codegen + - name: Regenerate from a clean tree + run: | + rm -rf internal/generated + make codegen - name: Check the committed output is current - # --porcelain rather than `git diff --exit-code` so a newly generated - # package (an untracked directory) counts as drift instead of being - # silently dropped, matching smithy-sync.yml's check. + # --porcelain rather than `git diff --exit-code` so an added or removed + # file counts as drift instead of being silently dropped, matching + # smithy-sync.yml's check. run: | drift="$(git status --porcelain internal/generated)" if [ -n "$drift" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99b1fc7..cc80a86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,8 +67,13 @@ jobs: with: go-version-file: go.mod cache-dependency-path: go.sum - - name: Regenerate - run: make codegen + # Cleared first so a retired or renamed output shows up as a deletion; the + # generator overwrites what it still emits but never removes what it does + # not. See the same step in ci.yml. + - name: Regenerate from a clean tree + run: | + rm -rf internal/generated + make codegen - name: Check the committed output is current run: | drift="$(git status --porcelain internal/generated)" From 4e0d6a8f5f540aa91c7546cd0fa317b8d00b2816 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 19:47:01 +0900 Subject: [PATCH 06/10] fix(release): ship what the archived docs link to, and clear the tree locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding docs/ to the archive exposed how little of it resolved: measured against a real snapshot archive, README.md and the docs tree reach seven top-level files by relative path that were not shipped. Adding them takes the archive from 26 of 28 relative links broken — the state before docs/ was included at all — to 17 of 134, and every one of those points into source or CI config, which a binary archive has no business carrying. Verified by extracting dist/devcloud_..._linux_amd64.tar.gz and resolving each link against the extracted tree: 117 of 134 work. Archive size 3.2M. The pre-flight checklist also still told developers to regenerate in place, which is the exact check the previous commit taught CI not to trust. It now mirrors the clean-tree sequence. --- .goreleaser.yaml | 12 ++++++++++++ docs/release.md | 11 +++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7131884..eebb6f3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -36,6 +36,18 @@ archives: # the fidelity manifest, the compatibility policy — are only true of the # binary they ship beside. - docs + # README.md and the docs tree reach these by relative path, so leaving them + # out dangles the archive's own entry point: before docs/ was added here, + # 26 of README.md's 28 relative links resolved to nothing. What stays broken + # points into source and CI config, which a binary archive has no business + # carrying. + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + - GOVERNANCE.md + - SECURITY.md + - SUPPORT.md + - NOTICE + - TRADEMARKS.md changelog: disable: false diff --git a/docs/release.md b/docs/release.md index 35a7ac8..628fe70 100644 --- a/docs/release.md +++ b/docs/release.md @@ -43,9 +43,12 @@ The first three are re-run by the Release workflow against the tagged commit, wh publish if any fails — tick them to find out on your machine rather than from a failed tag. The rest are only caught here. -- [ ] **Generated code is current** — `make codegen && git status --porcelain internal/generated` +- [ ] **Generated code is current** — + `rm -rf internal/generated && make codegen && git status --porcelain internal/generated` prints nothing. `internal/generated` is committed but derived; a stale fidelity manifest - misreports what the release can be trusted to do. + misreports what the release can be trusted to do. Clear the tree first, as CI does: the + generator overwrites the outputs it still emits but never removes one it has stopped + emitting, so regenerating in place leaves a retired file looking current. - [ ] **boto3 compatibility passes** — `make test-compat`. - [ ] **Go tests pass** — `CGO_ENABLED=1 go test ./...`. - [ ] **`main` is green**, including lint and CodeQL. @@ -105,8 +108,8 @@ Pushing the tag triggers the Release workflow. It will: - verify `changes/v0.3.0.md` exists (guard against tagging without release notes) and that no entry in it is missing its issue number, - run GoReleaser, which builds binaries for **darwin/linux/windows × amd64/arm64**, packages - them as `tar.gz` (`zip` on Windows) with `LICENSE`/`README.md`/`CHANGELOG.md` and the `docs/` - tree, and generates a SHA-256 `CHECKSUMS` file, + them as `tar.gz` (`zip` on Windows) with the `docs/` tree and the top-level files it and + `README.md` link to, and generates a SHA-256 `CHECKSUMS` file, - build and push `*-alpine` container images to `ghcr.io/skyoo2003/devcloud`, - publish a **GitHub Release** whose notes come from `changes/v0.3.0.md` (`--release-notes`, `mode: replace`). From 971a1009dd8bef006854ba34a511024c90d7e797 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 21:08:44 +0900 Subject: [PATCH 07/10] fix(ci): fail release notes that contain no changie entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue-link check filtered the '* ' entries and complained about the ones that did not carry a valid link. With no '* ' entries at all the filter matched nothing, `|| true` turned the empty result into success, and the gate passed having checked nothing. changes/v0.1.0.md is exactly that shape — hand-written prose with 25 '- ' bullets and no issue links — so a manual dispatch for that tag would have replaced its release with notes this gate never inspected. Now a notes file must contain at least one changie entry, and any other bullet form is rejected outright: those are hand edits, which is what the link check cannot vouch for and what docs/release.md already forbids. Verified against the real files and synthetic cases: v0.1.0.md and a headers- only file now fail on "no changie entries", a file mixing a changie entry with a hand-written bullet fails on the bullet, a malformed issue link still fails, and v0.2.0.md plus the current unreleased batch still pass. --- .github/workflows/release.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc80a86..b118d35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,23 @@ jobs: echo "::error::Run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge' before pushing the tag." exit 1 fi + # changie renders every entry as '* ...', so a notes file with none of + # them was not batched from fragments. Checking that first stops the + # entry validation below from passing on an empty match set — a + # hand-written file (changes/v0.1.0.md is one) would otherwise sail + # through with nothing checked at all. + if ! grep -q '^\* ' "$notes"; then + echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge'." + exit 1 + fi + # Any other bullet form means the file was edited by hand, and hand + # edits are exactly what the entry check below cannot vouch for. + handwritten="$(grep -nE '^[-+] ' "$notes" || true)" + if [ -n "$handwritten" ]; then + echo "$handwritten" + echo "::error::The lines above are hand-written bullets, not changie entries. Move them into changes/unreleased fragments and re-batch." + exit 1 + fi # 'changie batch' does not validate custom fields on hand-written # fragments, so an Issue that is empty, zero or non-numeric renders a # dead link and batches without complaint. Every entry must end in From 539aab14af4a5285709c8bb0264c87443922e0e6 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 21:26:53 +0900 Subject: [PATCH 08/10] fix(ci): reject every line a release notes file did not get from changie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-edit check enumerated bullet markers — '^[-+] ' — and its comment claimed it rejected "any other bullet form". It rejected two of them. An ordered item, an indented item and a pasted paragraph all passed, and the entry check that follows only inspects '* ' lines, so those lines shipped into a GitHub release with nothing about them verified: * Real entry ([#42](.../issues/42)) 1. Hand-written item with no issue link <- old check: pass Inverted it. A batched file holds only what .changie.yaml renders: the version heading, one kind heading per section, and one '* ...' entry per fragment. Allowing those three shapes and rejecting the rest covers every form a human might reach for, including the ones nobody thought to list. Verified against authentic output — `changie batch` (v1.25.0, current config, all 27 unreleased fragments) and changes/v0.2.0.md both pass; '1. text', ' - text', '- text' and a bare paragraph each fail with the offending line numbered. The empty-notes and missing-file gates still fire on changes/v0.1.0.md and an absent path. --- .github/workflows/release.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b118d35..8d736cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,12 +156,17 @@ jobs: echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge'." exit 1 fi - # Any other bullet form means the file was edited by hand, and hand - # edits are exactly what the entry check below cannot vouch for. - handwritten="$(grep -nE '^[-+] ' "$notes" || true)" - if [ -n "$handwritten" ]; then - echo "$handwritten" - echo "::error::The lines above are hand-written bullets, not changie entries. Move them into changes/unreleased fragments and re-batch." + # A batched file holds only what .changie.yaml renders: the version + # heading, one kind heading per section, and one '* ...' entry per + # fragment. Allowing those three shapes and rejecting every other + # line catches the hand edits the entry check below cannot vouch for. + # Enumerating bullet markers instead misses everything that is not one + # — '1. text', an indented ' - text', a pasted paragraph — and each of + # those publishes with nothing about it checked. + foreign="$(grep -nvE '^$|^## |^### |^\* ' "$notes" || true)" + if [ -n "$foreign" ]; then + echo "$foreign" + echo "::error::The lines above are not changie output. Move them into changes/unreleased fragments and re-batch." exit 1 fi # 'changie batch' does not validate custom fields on hand-written From f407e137ed7e27836cbcb70e67b76a3e780c482a Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 21:40:32 +0900 Subject: [PATCH 09/10] fix(ci): pin every release job to one commit, not to a mutable tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four jobs resolved `github.event.inputs.tag || github.ref` on their own. A tag is a mutable pointer: force-update it, or delete and recreate it, while the run is in flight — which is what a maintainer does on spotting that the wrong commit got tagged — and the gates check out one commit while the release job checks out another. "The gates passed" then says nothing about the artefacts GoReleaser publishes. A new `resolve` job checks out the ref once, records `git rev-parse HEAD`, and every job downstream takes `ref: ${{ needs.resolve.outputs.sha }}`. It carries the tag name and the dry-run flag too, so the `resolve_tag` step inside `release` and the ref expression repeated four times both collapse into it. github.sha alone would not do: it is immutable, but on workflow_dispatch it is the branch the run was launched from, not the tag that was typed in. The release checkout also needs `fetch-tags: true`, and that is not redundant with `fetch-depth: 0`. actions/checkout always fetches with --no-tags and brings tags down only through an explicit refspec; for a SHA ref that refspec is the bare commit (ref-helper.ts getRefSpec, git-command-manager.ts fetch, at the pinned de0fac2). Pinning without it hands GoReleaser a repo with no tags and no way to name the version. With it, a tag that moved after `resolve` no longer points at HEAD, and `git describe --exact-match` inside GoReleaser's git pipe fails the run rather than mispublishing. Verified: the job graph parses, every `needs` resolves, all four downstream checkouts pin to needs.resolve.outputs.sha, and the three outputs read are the three declared. The resolve step emits tag+sha on a tag push and adds extra_flags=--snapshot only under dry_run. The notes gate still passes real `changie batch` output and still rejects every hand-edited fixture. --- .github/workflows/release.yml | 71 ++++++++++++++++++++++++----------- docs/release.md | 15 +++++--- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d736cb..40f93a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,15 +23,42 @@ env: HOMEBREW_TAP_REPO: homebrew-tap jobs: + # A tag is a mutable pointer, and every job below would resolve it + # independently. Force-update or delete-and-recreate it while the run is in + # flight — which is exactly what a maintainer does on spotting that the wrong + # commit got tagged — and the gates vouch for one commit while GoReleaser + # publishes another. Resolving once here and passing the SHA down is what + # makes "the gates passed" a statement about the artefacts. + # + # The resolution has to happen in a job rather than an expression: github.sha + # is immutable but on workflow_dispatch it points at the branch the run was + # launched from, not the tag that was typed in. + resolve: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + sha: ${{ steps.resolve.outputs.sha }} + extra_flags: ${{ steps.resolve.outputs.extra_flags }} + steps: + - name: Checkout the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + - name: Pin the tag to a commit + id: resolve + run: | + echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then + echo "extra_flags=--snapshot" >> "$GITHUB_OUTPUT" + fi + # CI triggers on a tag push too, but the two workflows race — nothing stops # GoReleaser from publishing binaries, images and a Homebrew formula off a red - # commit. These two jobs are what make the tag wait, and they mirror the + # commit. These three jobs are what make the tag wait, and they mirror the # checks in ci.yml rather than trusting that CI got there first. - # - # Both check out the resolved tag explicitly: on workflow_dispatch the default - # ref is whatever branch the run was launched from, not the tag input, so the - # gate would otherwise vouch for the wrong commit. test: + needs: resolve strategy: matrix: # GoReleaser publishes arm64 artifacts, so the gate covers arm64 too. @@ -41,7 +68,7 @@ jobs: - name: Checkout the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ needs.resolve.outputs.sha }} - name: Set up Go uses: actions/setup-go@v7 with: @@ -56,12 +83,13 @@ jobs: # ci.yml has a codegen-drift job — so waiting only on `test` would let a tag # publish generated code that misstates what the release serves. codegen-drift: + needs: resolve runs-on: ubuntu-latest steps: - name: Checkout the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ needs.resolve.outputs.sha }} - name: Set up Go uses: actions/setup-go@v7 with: @@ -88,12 +116,13 @@ jobs: # suite, the guardrail the project leans on hardest, has no bearing on what # gets published. compat: + needs: resolve runs-on: ubuntu-latest steps: - name: Checkout the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ needs.resolve.outputs.sha }} - name: Set up Go uses: actions/setup-go@v7 with: @@ -115,36 +144,36 @@ jobs: DEVCLOUD_BIN: dist/devcloud release: - needs: [test, codegen-drift, compat] + needs: [resolve, test, codegen-drift, compat] runs-on: ubuntu-latest permissions: contents: write packages: write attestations: write steps: + # fetch-tags is not redundant with fetch-depth. actions/checkout always + # fetches with --no-tags and brings tags down only via an explicit refspec, + # and for a SHA ref that refspec is the bare commit — so pinning the commit + # without this leaves a repo with no tags at all, and GoReleaser cannot name + # the version it is releasing. With both, a tag that moved after `resolve` + # no longer points at HEAD and GoReleaser refuses the run outright. - name: Checkout the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - ref: ${{ github.event.inputs.tag || github.ref }} + fetch-tags: true + ref: ${{ needs.resolve.outputs.sha }} - name: Set up Go uses: actions/setup-go@v7 with: go-version-file: go.mod cache-dependency-path: go.sum - - name: Resolve tag - id: resolve_tag - run: | - echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then - echo "extra_flags=--snapshot" >> "$GITHUB_OUTPUT" - fi - name: Verify changie release notes run: | - notes="changes/${{ steps.resolve_tag.outputs.tag }}.md" + notes="changes/${{ needs.resolve.outputs.tag }}.md" if [ ! -f "$notes" ]; then echo "::error::Changie fragment $notes not found." - echo "::error::Run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge' before pushing the tag." + echo "::error::Run 'changie batch ${{ needs.resolve.outputs.tag }}' and 'changie merge' before pushing the tag." exit 1 fi # changie renders every entry as '* ...', so a notes file with none of @@ -153,7 +182,7 @@ jobs: # hand-written file (changes/v0.1.0.md is one) would otherwise sail # through with nothing checked at all. if ! grep -q '^\* ' "$notes"; then - echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ steps.resolve_tag.outputs.tag }}' and 'changie merge'." + echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ needs.resolve.outputs.tag }}' and 'changie merge'." exit 1 fi # A batched file holds only what .changie.yaml renders: the version @@ -206,7 +235,7 @@ jobs: with: distribution: goreleaser version: "~> v2" - args: release --clean ${{ steps.resolve_tag.outputs.extra_flags }} --release-notes changes/${{ steps.resolve_tag.outputs.tag }}.md + args: release --clean ${{ needs.resolve.outputs.extra_flags }} --release-notes changes/${{ needs.resolve.outputs.tag }}.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Always defined: {{ .Env.HOMEBREW_TAP_TOKEN }} fails to render if the diff --git a/docs/release.md b/docs/release.md index 628fe70..587f8fe 100644 --- a/docs/release.md +++ b/docs/release.md @@ -101,12 +101,15 @@ The rest are only caught here. Pushing the tag triggers the Release workflow. It will: -- re-run the guardrails against the tagged commit and stop before publishing anything if any - of them fails: the Go test suite on amd64 and arm64, the boto3 compatibility suite, and the - codegen drift check. CI is not relied on here — it races the tag, and `compat.yml` does not - trigger on tags at all, -- verify `changes/v0.3.0.md` exists (guard against tagging without release notes) and that no - entry in it is missing its issue number, +- resolve the tag to a commit **once**, up front, and check that same SHA out in every job that + follows. Moving the tag mid-run therefore cannot make the guardrails vouch for one commit + while GoReleaser publishes another, +- re-run the guardrails against that commit and stop before publishing anything if any of them + fails: the Go test suite on amd64 and arm64, the boto3 compatibility suite, and the codegen + drift check. CI is not relied on here — it races the tag, and `compat.yml` does not trigger + on tags at all, +- verify `changes/v0.3.0.md` exists (guard against tagging without release notes), contains + only what `changie batch` renders, and that no entry in it is missing its issue number, - run GoReleaser, which builds binaries for **darwin/linux/windows × amd64/arm64**, packages them as `tar.gz` (`zip` on Windows) with the `docs/` tree and the top-level files it and `README.md` link to, and generates a SHA-256 `CHECKSUMS` file, From 94dad3d08d540365a7d0af66113c72f19d97d222 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 9 Aug 2026 21:54:49 +0900 Subject: [PATCH 10/10] fix(ci): require the release notes heading to name the tag being released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowlist matched '^## ' and '^### ' by prefix, so it accepted any version heading at all. Copy or rename an earlier release's notes file to changes/.md and every check passed: the entries carry valid issue links, nothing is hand-written, and GoReleaser hands the body through verbatim — so the v1.0.0 release opens with a heading linking to the v0.2.0 release, dated to it. The heading is now rebuilt from the tag and required exactly, exactly once. Dots in the tag are escaped, which is not cosmetic: without it the regex for v9.9.9 matches a heading reading v9x9x9. Kind headings are restricted to the labels in .changie.yaml, read out of the config rather than restated here so that adding a kind does not start failing releases. If that extraction ever stops matching it yields an empty set, which rejects every kind heading — the check fails loudly instead of silently checking nothing, the same failure mode the empty-entry gate above exists for. Verified against authentic `changie batch` output, 14 cases: correct tag passes; the same file under a different tag, a stale heading, two headings, no heading, '### Bugfixes', an ordered item, an indented item, a pasted paragraph, an entry without an issue link, changes/v0.1.0.md and an absent path all fail. changes/v0.2.0.md still passes under its own tag. Dot escaping confirmed by control: the unescaped variant passes the v9x9x9 heading. --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40f93a2..dbd0ba8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,14 +185,33 @@ jobs: echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ needs.resolve.outputs.tag }}' and 'changie merge'." exit 1 fi - # A batched file holds only what .changie.yaml renders: the version - # heading, one kind heading per section, and one '* ...' entry per - # fragment. Allowing those three shapes and rejecting every other - # line catches the hand edits the entry check below cannot vouch for. - # Enumerating bullet markers instead misses everything that is not one - # — '1. text', an indented ' - text', a pasted paragraph — and each of - # those publishes with nothing about it checked. - foreign="$(grep -nvE '^$|^## |^### |^\* ' "$notes" || true)" + # changie renders the heading for the version it batched, so a prefix + # match on '## ' accepts a file copied or renamed from an earlier + # release: the notes for this tag then open with a link to that other + # release, dated to it, and GoReleaser passes it through verbatim as + # the release body. Rebuild the heading .changie.yaml would have + # produced for this tag and require exactly that, exactly once. + tag="${{ needs.resolve.outputs.tag }}" + want="^## \[${tag//./\\.}\]\(https://github\.com/${{ github.repository }}/releases/tag/${tag//./\\.}\) - [0-9]{4}-[0-9]{2}-[0-9]{2}$" + if [ "$(grep -cE '^## ' "$notes")" != "1" ] || ! grep -qE "$want" "$notes"; then + grep -n '^## ' "$notes" || true + echo "::error::$notes needs exactly one changie version heading, naming $tag. Run 'changie batch $tag' instead of copying or renaming an earlier release's file." + exit 1 + fi + # A batched file holds only what .changie.yaml renders: that heading, + # one kind heading per section, and one '* ...' entry per fragment. + # Allowing those three shapes and rejecting every other line catches + # the hand edits the entry check below cannot vouch for. Enumerating + # bullet markers instead misses everything that is not one — '1. text', + # an indented ' - text', a pasted paragraph — and each of those + # publishes with nothing about it checked. + # + # The kinds are read out of the config rather than restated here, so + # adding one to .changie.yaml does not start failing releases. An + # extraction that stops matching yields an empty set, which rejects + # every kind heading and fails loudly rather than checking nothing. + kinds="$(sed -n 's/^ - label: //p' .changie.yaml | paste -sd'|' -)" + foreign="$(grep -nvE "^$|^## |^### ($kinds)$|^\* " "$notes" || true)" if [ -n "$foreign" ]; then echo "$foreign" echo "::error::The lines above are not changie output. Move them into changes/unreleased fragments and re-batch."