From 023276d52df44519d367d766e2ccc613ded4fa6f Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Tue, 16 Jun 2026 07:58:33 -0400 Subject: [PATCH] chore: add CI preflight gate and pin action SHAs - Switch release trigger from push-tag to workflow_dispatch with preflight validation (branch, tag format, uniqueness, semver ordering, CI status, unreleased commits) - Pin all action references to full commit SHAs - Rename CI job to "Build and Test" (org convention) - Add permissions and concurrency blocks to CI workflow - Scope release workflow permissions per-job - Update constitution to reflect new release trigger - Include OpenSpec artifacts and retrospective learnings Fixes: unbound-force/replicator#15 Assisted-by: OpenCode (claude-opus-4-6) Signed-off-by: Jay Flowers --- .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 196 +++++++++++--- .specify/memory/constitution.md | 3 +- ...e-preflight-20260616T105942-jay-flowers.md | 10 + ...e-preflight-20260616T105947-jay-flowers.md | 10 + ...e-preflight-20260616T105953-jay-flowers.md | 10 + .../ci-release-preflight/.openspec.yaml | 2 + .../changes/ci-release-preflight/design.md | 148 +++++++++++ .../changes/ci-release-preflight/proposal.md | 116 +++++++++ .../specs/ci-release-gate.md | 244 ++++++++++++++++++ .../changes/ci-release-preflight/tasks.md | 80 ++++++ 11 files changed, 800 insertions(+), 33 deletions(-) create mode 100644 .uf/dewey/learnings/ci-release-preflight-20260616T105942-jay-flowers.md create mode 100644 .uf/dewey/learnings/ci-release-preflight-20260616T105947-jay-flowers.md create mode 100644 .uf/dewey/learnings/ci-release-preflight-20260616T105953-jay-flowers.md create mode 100644 openspec/changes/ci-release-preflight/.openspec.yaml create mode 100644 openspec/changes/ci-release-preflight/design.md create mode 100644 openspec/changes/ci-release-preflight/proposal.md create mode 100644 openspec/changes/ci-release-preflight/specs/ci-release-gate.md create mode 100644 openspec/changes/ci-release-preflight/tasks.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c84adde..0d485dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,21 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - test: + build-and-test: + name: Build and Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb61da7..1ff7a6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,53 +1,187 @@ name: Release on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g., v0.2.0)' + required: true + type: string -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: + preflight: runs-on: ubuntu-latest - timeout-minutes: 45 + permissions: + contents: write + checks: read + timeout-minutes: 10 + env: + RELEASE_TAG: ${{ inputs.tag }} outputs: - has_signing_secrets: ${{ steps.check-secrets.outputs.has_secrets }} + has_signing_secrets: ${{ steps.check-secrets.outputs.has_signing_secrets }} steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Validate branch + env: + TRIGGER_REF: ${{ github.ref }} + run: | + if [[ "$TRIGGER_REF" != "refs/heads/main" ]]; then + echo "::error::Release must be triggered from main branch, not '$TRIGGER_REF'." + exit 1 + fi + echo "Branch validation passed: triggered from main." + + - name: Validate tag format + run: | + if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid tag format: '$RELEASE_TAG'. Must match vMAJOR.MINOR.PATCH (e.g., v0.2.0)." + exit 1 + fi + echo "Tag format valid: $RELEASE_TAG" + + - name: Check tag uniqueness + run: | + REMOTE_REF=$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}" | awk '{print $1}') + if [ -n "$REMOTE_REF" ]; then + HEAD_SHA=$(git rev-parse HEAD) + if [ "$REMOTE_REF" = "$HEAD_SHA" ]; then + echo "Tag '$RELEASE_TAG' already exists and points to HEAD (re-run case). Continuing." + else + echo "::error::Tag '$RELEASE_TAG' already exists and points to a different commit. Choose a different version." + exit 1 + fi + else + echo "Tag '$RELEASE_TAG' does not exist yet." + fi + + - name: Verify semver ordering + run: | + LATEST=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -1) + if [ -z "$LATEST" ]; then + echo "No existing tags found. First release." + exit 0 + fi + echo "Latest existing tag: $LATEST" + # Compare using sort -V: if TAG sorts after LATEST, it is greater + HIGHER=$(printf '%s\n%s' "$LATEST" "$RELEASE_TAG" | sort -V | tail -1) + if [ "$HIGHER" = "$LATEST" ]; then + echo "::error::Tag '$RELEASE_TAG' is not greater than latest release '$LATEST'." + exit 1 + fi + echo "Version ordering valid: $RELEASE_TAG > $LATEST" + + - name: Verify CI passed on HEAD + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + HEAD_SHA=$(git rev-parse HEAD) + echo "Checking CI status for commit $HEAD_SHA" + + REQUIRED_CHECKS=( + "Build and Test" + ) + + for CHECK_NAME in "${REQUIRED_CHECKS[@]}"; do + STATUS=$(gh api "repos/${GH_REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq ".check_runs[] | select(.name == \"${CHECK_NAME}\") | .conclusion" \ + 2>/dev/null | head -1) + if [ "$STATUS" != "success" ]; then + echo "::error::Required check '${CHECK_NAME}' has not passed (status: ${STATUS:-not found}). Push to main and wait for CI before releasing." + exit 1 + fi + echo " ✓ ${CHECK_NAME}: success" + done + + echo "All required CI checks passed." + + - name: Verify unreleased commits + run: | + LATEST=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -1) + if [ -z "$LATEST" ]; then + COUNT=$(git rev-list --count HEAD) + else + COUNT=$(git rev-list --count "${LATEST}..HEAD") + fi + if [ "$COUNT" -eq 0 ]; then + echo "::error::No unreleased commits since ${LATEST:-initial commit}. Nothing to release." + exit 1 + fi + echo "$COUNT commit(s) since ${LATEST:-initial commit}." + + - name: Create and push tag + run: | + # Skip if tag was already created (e.g., re-run after + # partial failure or manual tag via GitHub API). + if git ls-remote --tags origin | grep -q "refs/tags/${RELEASE_TAG}$"; then + echo "Tag $RELEASE_TAG already exists, skipping creation." + exit 0 + fi + # Annotated tags require a committer identity on the + # CI runner (git tag -a uses GIT_COMMITTER_NAME/EMAIL). + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" -m "$RELEASE_TAG" + git push origin "$RELEASE_TAG" + echo "Created and pushed tag: $RELEASE_TAG" + - name: Check signing secrets id: check-secrets run: | if [ -n "$MACOS_SIGN_P12" ]; then - echo "has_secrets=true" >> "$GITHUB_OUTPUT" + echo "has_signing_secrets=true" >> "$GITHUB_OUTPUT" else - echo "has_secrets=false" >> "$GITHUB_OUTPUT" + echo "has_signing_secrets=false" >> "$GITHUB_OUTPUT" fi env: MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + release: + runs-on: ubuntu-latest + needs: preflight + permissions: + contents: write + id-token: write + timeout-minutes: 45 + env: + RELEASE_TAG: ${{ inputs.tag }} + outputs: + has_signing_secrets: ${{ needs.preflight.outputs.has_signing_secrets }} + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + ref: ${{ inputs.tag }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: distribution: goreleaser version: 'v2.14.1' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ inputs.tag }} - name: Upload generated cask run: | - gh release upload "${GITHUB_REF_NAME}" \ + gh release upload "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ dist/homebrew/Casks/replicator.rb \ --clobber @@ -58,7 +192,11 @@ jobs: runs-on: macos-latest needs: release if: ${{ needs.release.outputs.has_signing_secrets == 'true' }} + permissions: + contents: write timeout-minutes: 30 + env: + RELEASE_TAG: ${{ inputs.tag }} steps: - name: Import certificate into Keychain run: | @@ -66,13 +204,13 @@ jobs: KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - echo -n "$MACOS_SIGN_P12" | base64 --decode -o $RUNNER_TEMP/cert.p12 - security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security import $RUNNER_TEMP/cert.p12 -P "$MACOS_SIGN_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH + echo -n "$MACOS_SIGN_P12" | base64 --decode -o "$RUNNER_TEMP/cert.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_SIGN_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" env: MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} @@ -80,17 +218,17 @@ jobs: - name: Prepare notary key run: | set -euo pipefail - echo -n "$MACOS_NOTARY_KEY" | base64 --decode -o $RUNNER_TEMP/notary_key.p8 + echo -n "$MACOS_NOTARY_KEY" | base64 --decode -o "$RUNNER_TEMP/notary_key.p8" env: MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Download darwin archive run: | set -euo pipefail - gh release download "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \ + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --pattern "replicator_*_darwin_arm64.tar.gz" --dir ./artifacts - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" if [ ! -f "./artifacts/replicator_${VERSION}_darwin_arm64.tar.gz" ]; then echo "::error::Expected darwin_arm64 archive not found after download" exit 1 @@ -135,10 +273,10 @@ jobs: - name: Replace release assets and update checksums run: | set -euo pipefail - gh release upload "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \ + gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ ./signed/*.tar.gz --clobber - gh release download "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \ + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --pattern "checksums.txt" --dir ./ grep -v darwin checksums.txt > checksums_updated.txt || true @@ -148,7 +286,7 @@ jobs: done mv checksums_updated.txt checksums.txt - gh release upload "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \ + gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ checksums.txt --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -156,7 +294,7 @@ jobs: - name: Update Homebrew cask run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" if [ ! -f "./signed/replicator_${VERSION}_darwin_arm64.tar.gz" ]; then echo "::error::Signed darwin_arm64 archive not found" @@ -166,7 +304,7 @@ jobs: ARM64_SHA=$(shasum -a 256 "./signed/replicator_${VERSION}_darwin_arm64.tar.gz" | awk '{print $1}') echo "darwin_arm64 SHA256: $ARM64_SHA" - gh release download "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \ + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --pattern "replicator.rb" --dir ./cask-staging CASK_FILE="./cask-staging/replicator.rb" diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index c238d39..2e08181 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -98,7 +98,8 @@ environment-dependent. (`make check`). Derive commands from `.github/workflows/`, not from memory. - **Continuous Integration**: CI MUST pass before merge. -- **Releases**: Semantic versioning. Tag `v*` triggers +- **Releases**: Semantic versioning. `workflow_dispatch` + with tag input triggers preflight validation then GoReleaser. - **Commit Messages**: Conventional commits (`type: description`). diff --git a/.uf/dewey/learnings/ci-release-preflight-20260616T105942-jay-flowers.md b/.uf/dewey/learnings/ci-release-preflight-20260616T105942-jay-flowers.md new file mode 100644 index 0000000..2b336fa --- /dev/null +++ b/.uf/dewey/learnings/ci-release-preflight-20260616T105942-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: ci-release-preflight +author: jay-flowers +category: gotcha +created_at: 2026-06-16T10:59:42Z +identity: ci-release-preflight-20260616T105942-jay-flowers +tier: draft +--- + +When writing GitHub Actions workflow steps that reference context values like github.ref, github.event.pull_request.title, or inputs.tag, always pass them through env: bindings rather than interpolating them directly into shell scripts with ${{ }}. The ${{ }} syntax performs textual substitution before the shell runs, which creates a shell injection vector. The correct pattern is to bind the value to an environment variable in the step's env: block and reference it as $VAR_NAME in the shell. This was caught during the ci-release-preflight code review when the Adversary reviewer identified that github.ref was interpolated directly into a bash if-statement while inputs.tag was correctly passed via env: RELEASE_TAG in the same file — the inconsistency made the injection risk visible. diff --git a/.uf/dewey/learnings/ci-release-preflight-20260616T105947-jay-flowers.md b/.uf/dewey/learnings/ci-release-preflight-20260616T105947-jay-flowers.md new file mode 100644 index 0000000..26c73ca --- /dev/null +++ b/.uf/dewey/learnings/ci-release-preflight-20260616T105947-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: ci-release-preflight +author: jay-flowers +category: pattern +created_at: 2026-06-16T10:59:47Z +identity: ci-release-preflight-20260616T105947-jay-flowers +tier: draft +--- + +When adapting a canonical reference workflow from another repo (e.g., unbound-force/unbound-force release.yml), the spec review council will catch gaps that the canonical reference handles implicitly. In the ci-release-preflight change, the canonical reference's tag uniqueness check works because the preflight creates the tag — but the spec initially had a contradiction between "tag must not exist" and "tag creation must be idempotent for re-runs." The resolution was to make the uniqueness check HEAD-aware: if the tag exists and points to HEAD, it's a re-run (pass). If it points to a different commit, it's a genuine duplicate (fail). Always specify the re-run idempotency mechanism explicitly when adapting preflight patterns, because the canonical reference may handle it through implicit ordering that isn't obvious from reading the workflow file alone. diff --git a/.uf/dewey/learnings/ci-release-preflight-20260616T105953-jay-flowers.md b/.uf/dewey/learnings/ci-release-preflight-20260616T105953-jay-flowers.md new file mode 100644 index 0000000..e58d63d --- /dev/null +++ b/.uf/dewey/learnings/ci-release-preflight-20260616T105953-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: ci-release-preflight +author: jay-flowers +category: pattern +created_at: 2026-06-16T10:59:53Z +identity: ci-release-preflight-20260616T105953-jay-flowers +tier: draft +--- + +When switching a GitHub Actions release workflow from push-tag trigger to workflow_dispatch, there are several non-obvious impacts that the spec review council will flag: (1) GITHUB_REF_NAME becomes the branch name instead of the tag — all references must change to inputs.tag, (2) github.ref in concurrency groups becomes the branch ref not the tag — this is actually correct for serializing releases from the same branch, but must be documented, (3) the workflow can be triggered from any branch, not just main — a branch validation step is essential to prevent releases from feature branches, (4) the constitution or project documentation that describes the release process becomes stale. The spec review found 7 HIGH-severity gaps in the initial spec that all related to these implicit consequences of the trigger change. diff --git a/openspec/changes/ci-release-preflight/.openspec.yaml b/openspec/changes/ci-release-preflight/.openspec.yaml new file mode 100644 index 0000000..5a52faf --- /dev/null +++ b/openspec/changes/ci-release-preflight/.openspec.yaml @@ -0,0 +1,2 @@ +schema: unbound-force +created: 2026-06-16 diff --git a/openspec/changes/ci-release-preflight/design.md b/openspec/changes/ci-release-preflight/design.md new file mode 100644 index 0000000..0449072 --- /dev/null +++ b/openspec/changes/ci-release-preflight/design.md @@ -0,0 +1,148 @@ +## Context + +Replicator's release pipeline has two supply-chain hygiene gaps identified in +issue #15: + +1. `release.yml` triggers on any `v*` tag push and runs GoReleaser immediately + with no CI preflight verification. +2. CI workflows use mutable floating action tags instead of commit SHA pins. + +The canonical reference (`unbound-force/unbound-force/.github/workflows/release.yml`) +implements a robust preflight pattern that this change adapts for replicator. +A tag ruleset already restricts `v*` tag creation to org admins, providing a +partial mitigation for the trigger surface. + +## Goals / Non-Goals + +### Goals + +- Add a preflight validation job to `release.yml` that gates releases on CI + status, tag format, uniqueness, and semver ordering. +- Pin all GitHub Actions references to full commit SHAs in both `ci.yml` and + `release.yml`. +- Rename the CI job to `Build and Test` to match org convention and provide a + stable check name for the preflight to query via the Checks API. +- Add `permissions:` and `concurrency:` blocks to `ci.yml` for security + hardening and duplicate-run prevention. + +### Non-Goals + +- Adding security scanning (`govulncheck`, OSV-Scanner) to CI — tracked in #23. +- Adding coverage ratchets to CI — tracked in #24. +- Adopting org-infra reusable workflows (MegaLinter, PR title checks) — + tracked in #25. +- Modifying Go source code, tests, or the replicator binary. + +## Decisions + +### D1: Rename CI job to "Build and Test" + +The preflight job queries the GitHub Checks API by check name string to verify +CI passed. Replicator's CI job is currently named `test`, which diverges from +the org convention of `Build and Test`. Renaming aligns with the canonical +reference and produces a more readable preflight. The visible impact (CI badge +name change) is a one-time cosmetic transition. + +### D2: Use canonical action SHAs from unbound-force + +The canonical reference (`unbound-force/unbound-force`) has updated to +`actions/checkout@v6.0.3` (`df4cb1c0...`) and `actions/setup-go@v6.4.0` +(`4a360112...`). Rather than pinning to the older v4/v5 SHAs currently in +use, we adopt the same SHAs as the canonical reference for consistency. This +is a version upgrade (v4 -> v6 for checkout, v5 -> v6 for setup-go) but both +are stable releases with no breaking changes for our usage. + +Action SHA mapping: + +| Action | Current | Pinned SHA | Version | +|--------|---------|------------|---------| +| `actions/checkout` | `@v4` | `df4cb1c069e1874edd31b4311f1884172cec0e10` | v6.0.3 | +| `actions/setup-go` | `@v5` | `4a3601121dd01d1626a1e23e37211e3254c1c06c` | v6.4.0 | +| `goreleaser/goreleaser-action` | `@v6` | `5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89` | v7.2.2 | + +### D3: Switch release trigger to workflow_dispatch + +Replacing `on: push: tags: ['v*']` with `on: workflow_dispatch: inputs: tag:` +enables the preflight job to validate before the tag exists. The preflight +creates the tag after all validations pass. This is the same pattern used by +the canonical reference. + +The preflight MUST validate that the workflow was triggered from the `main` +branch. Since `workflow_dispatch` can be triggered from any branch, without +this check a release could be built from a feature branch's HEAD. + +Pre-release and build metadata suffixes (e.g., `-rc.1`, `+build.123`) are +intentionally excluded by the tag format regex. The release pipeline only +supports stable semver releases. Pre-release distribution, if needed, would +use a separate workflow. + +**Breaking change**: After this change, releases can no longer be triggered by +pushing a tag. Contributors must use: +- GitHub Actions UI: Actions -> Release -> Run workflow -> enter tag +- CLI: `gh workflow run release.yml -f tag=vX.Y.Z` + +### D4: Replace GITHUB_REF_NAME with inputs.tag + +With `workflow_dispatch`, `GITHUB_REF_NAME` is the branch name (e.g., `main`), +not the tag. All references to `GITHUB_REF_NAME` in the `release` and +`sign-macos` jobs must be replaced with `inputs.tag`. We use a job-level +`env: RELEASE_TAG: ${{ inputs.tag }}` for consistency with the canonical +reference, then reference `$RELEASE_TAG` in shell scripts. + +### D5: Omit security scan check from preflight + +The canonical preflight verifies that at least one security scan check passed. +Replicator has no security scan CI step. Rather than adding a dummy check or +skipping validation of a non-existent check, we omit the security scan block +entirely. When govulncheck is added (issue #23), the preflight can be extended +to verify it. + +### D6: Scope permissions per-job + +The current `release.yml` has `permissions: contents: write` at workflow level, +which grants write access to all jobs. Following the canonical reference, we +set `permissions: {}` at workflow level and grant per-job permissions: +- `preflight`: `contents: write` (to push the tag), `checks: read` (to query + CI status) +- `release`: `contents: write` (to create the release), `id-token: write` + (for Cosign signing) +- `sign-macos`: `contents: write` (to upload signed assets) + +## Risks / Trade-offs + +- **Action version upgrade**: Moving from checkout v4 to v6, setup-go v5 to + v6, and goreleaser-action v6 to v7 introduces upgrade risk. All are stable + releases with no known breaking changes for our usage. The canonical + reference has been running these versions in production. The GoReleaser + binary version (`v2.14.1`) is already pinned and unchanged. +- **SHA maintenance**: Pinned SHAs require periodic updates when upstream + actions release security patches. GitHub Dependabot with + `package-ecosystem: github-actions` in `.github/dependabot.yml` is the + recommended low-friction approach. This can be added as a follow-up. + SHAs were verified by cross-referencing the canonical reference + (`unbound-force/unbound-force`) against the official action repositories' + release tags. +- **Release process change**: Contributors must learn the new release process + (workflow_dispatch instead of tag push). The existing tag ruleset already + restricts tag creation to org admins, so this change primarily affects the + same small group. +- **No local testability**: GitHub Actions workflows cannot be tested locally. + The preflight logic is adapted from a production-validated canonical + reference, which mitigates this risk. Manual verification will be performed + by triggering a `workflow_dispatch` with a test tag after merge. +- **CI check name dependency**: The preflight is tightly coupled to the CI + job name `Build and Test`. If the CI job is renamed without updating the + preflight, releases will be blocked. This coupling is intentional and + matches the canonical pattern. +- **`sort -V` platform dependency**: The semver ordering check uses GNU + `sort -V`, which is available on `ubuntu-latest` runners but not on macOS + BSD sort by default. Since the preflight only runs on `ubuntu-latest`, this + is an acceptable platform dependency. +- **Rollback**: If the new release workflow blocks releases, revert + `release.yml` to the previous `push: tags` trigger and push a tag manually. + The GoReleaser configuration is unchanged and works with either trigger. + +**Gatekeeping note**: Action SHA changes fall under the Gatekeeping Value +Protection constraint in AGENTS.md. This change is authorized by the spec +author as an improvement (pinning is more secure than floating tags). + diff --git a/openspec/changes/ci-release-preflight/proposal.md b/openspec/changes/ci-release-preflight/proposal.md new file mode 100644 index 0000000..66ff409 --- /dev/null +++ b/openspec/changes/ci-release-preflight/proposal.md @@ -0,0 +1,116 @@ +## Why + +The release pipeline (`release.yml`) triggers on any `v*` tag push and runs +GoReleaser immediately with no verification that CI passed on the tagged +commit. A tag pushed from a commit that never passed tests will produce and +distribute release binaries with unknown quality. + +Additionally, CI workflows use mutable floating action tags +(`actions/checkout@v4`, `actions/setup-go@v5`) instead of commit SHA pins. The +project's own `severity.md` classifies unpinned CI actions on mutable tags as +HIGH severity supply-chain risk. All other repos in the org pin actions to full +commit SHAs. + +Fixes: https://github.com/unbound-force/replicator/issues/15 + +A tag ruleset has been applied to restrict `v*` tag creation to org admins, but +this only limits who can trigger a release — it does not verify CI status or +address action pinning. + +## What Changes + +1. **`ci.yml`**: Add `permissions:` block, `concurrency:` group, rename the + job from `test` to `Build and Test` (matching org convention), and pin all + action references to full commit SHAs. + +2. **`release.yml`**: Switch the trigger from `push: tags: ['v*']` to + `workflow_dispatch` with a `tag` input. Add a `preflight` job that validates + tag format, uniqueness, semver ordering, and CI status via the GitHub Checks + API before allowing GoReleaser to run. Pin all action references to full + commit SHAs. Scope permissions per-job instead of workflow-level. Replace + all `GITHUB_REF_NAME` references with `inputs.tag` (since `workflow_dispatch` + sets `GITHUB_REF_NAME` to the branch name, not the tag). + +## Capabilities + +### New Capabilities + +- `release/preflight`: Pre-flight validation job that gates every release on + tag format, uniqueness, semver ordering, CI status, and unreleased commits. + +### Modified Capabilities + +- `release/trigger`: Changes from automatic tag-push trigger to manual + `workflow_dispatch` with explicit tag input. +- `ci/job-naming`: CI job renamed from `test` to `Build and Test` to match org + convention and provide a stable check name for the preflight to query. +- `ci/permissions`: Explicit `permissions: contents: read` and `concurrency:` + group added to CI workflow. + +### Removed Capabilities + +- None + +## Impact + +- `.github/workflows/ci.yml` — permissions, concurrency, job rename, SHA pins +- `.github/workflows/release.yml` — trigger change, preflight job, SHA pins, + per-job permissions, `GITHUB_REF_NAME` -> `inputs.tag` replacement +- Release process: contributors must use the GitHub Actions UI or + `gh workflow run release.yml -f tag=vX.Y.Z` instead of pushing a tag +- The preflight verifies only `"Build and Test"` — no security scan check is + required until govulncheck is added to CI (tracked in #23) + +## Constitution Alignment + +Assessed against the Replicator constitution (`.specify/memory/constitution.md`), +which extends the Unbound Force org constitution v1.1.0. + +### I. Autonomous Collaboration + +**Assessment**: N/A + +This change modifies CI/CD workflow files only. No MCP tools, inter-agent +communication, or tool outputs are affected. The change is purely +infrastructure-level and does not alter how heroes collaborate through +artifacts. + +### II. Composability First + +**Assessment**: PASS + +The binary remains independently installable and usable without any external +services. The CI and release workflows are GitHub-specific infrastructure that +do not affect the standalone functionality of the replicator binary. Dewey +integration and graceful degradation are unaffected. + +### III. Observable Quality + +**Assessment**: PASS + +The preflight job produces structured GitHub Actions output with clear +pass/fail status for each validation step (tag format, uniqueness, semver, +CI status, unreleased commits). Error messages use `::error::` annotations +for machine-parseable CI feedback. The CI job rename to `Build and Test` +provides a stable, human-readable check name for both the preflight and +branch protection rules. + +### IV. Testability + +**Assessment**: N/A + +This change modifies GitHub Actions workflow files which cannot be tested +in isolation (they require the GitHub Actions runtime). The change does not +modify any Go source code, tests, or testable components. The preflight +logic is adapted from the canonical reference (`unbound-force/unbound-force`) +which has been validated in production. Post-merge verification will be +performed by triggering a `workflow_dispatch` with a test tag. + +### Development Workflow Impact + +The constitution's Development Workflow section (line ~101) states "Tag `v*` +triggers GoReleaser." This description becomes inaccurate after this change. +A documentation update task is included to correct this to reflect the new +`workflow_dispatch` trigger. This is a factual correction to a descriptive +statement, not a modification of a constitutional principle. + diff --git a/openspec/changes/ci-release-preflight/specs/ci-release-gate.md b/openspec/changes/ci-release-preflight/specs/ci-release-gate.md new file mode 100644 index 0000000..24b97dc --- /dev/null +++ b/openspec/changes/ci-release-preflight/specs/ci-release-gate.md @@ -0,0 +1,244 @@ +## ADDED Requirements + +### Requirement: Release Preflight Validation + +The `release.yml` workflow MUST include a `preflight` job that runs before +the `release` job. The `release` job MUST declare `needs: preflight`. The +preflight job MUST validate all of the following before proceeding: + +1. **Branch validation**: The workflow MUST verify it was triggered from the + default branch (`main`). Releases from feature branches MUST be rejected. +2. **Tag format**: The input tag MUST match `^v[0-9]+\.[0-9]+\.[0-9]+$`. + Pre-release suffixes (e.g., `-rc.1`, `-beta`) and build metadata (e.g., + `+build.123`) are intentionally excluded — the release pipeline only + supports stable semver releases. +3. **Tag uniqueness**: The input tag MUST NOT already exist on the remote, + UNLESS the existing tag points to the current HEAD commit (indicating a + re-run of a previously successful preflight). In the re-run case, the + uniqueness check MUST pass and the tag creation step MUST be skipped. +4. **Semver ordering**: The input tag MUST be greater than the latest + existing release tag (using GNU `sort -V`, available on `ubuntu-latest` + runners). If no existing tags are found (first release), this check MUST + pass unconditionally. +5. **CI status**: The `Build and Test` check MUST have concluded with + `success` on the HEAD commit, verified via the GitHub Checks API. If the + API call fails or the check has not yet concluded, the preflight MUST + fail with a descriptive error. +6. **Unreleased commits**: At least one commit MUST exist since the last + release tag. + +If any validation fails, the preflight MUST exit with a non-zero status and +produce an `::error::` annotation describing the failure. Error messages +SHOULD include the tag value and relevant context (e.g., comparison values +for semver ordering failures). + +After all validations pass, the preflight MUST create an annotated tag and +push it to the remote. Tag creation MUST be idempotent — if the tag already +exists and points to HEAD (e.g., from a re-run after partial failure), the +step MUST skip creation. + +#### Scenario: All validations pass + +- **GIVEN** the workflow is triggered from the `main` branch, the HEAD + commit has a passing `Build and Test` check, there are unreleased commits + since the last tag, and the input tag is a valid semver greater than the + latest tag +- **WHEN** the preflight job runs with tag `v1.2.0` +- **THEN** all validation steps succeed, the tag `v1.2.0` is created and + pushed, and the `release` job proceeds + +#### Scenario: Triggered from non-default branch + +- **GIVEN** the workflow is triggered from branch `feature/foo` +- **WHEN** the preflight job runs with any tag +- **THEN** the preflight fails with `::error::` mentioning "must be + triggered from main" and the `release` job does not run + +#### Scenario: Invalid tag format + +- **GIVEN** the input tag is `v1.2` (or `v1.2.0-beta`, `1.2.0`, `vfoo`) +- **WHEN** the preflight job validates the tag format +- **THEN** the preflight fails with `::error::` mentioning "Invalid tag + format" and the `release` job does not run + +#### Scenario: CI has not passed on HEAD + +- **GIVEN** the HEAD commit has no passing `Build and Test` check +- **WHEN** the preflight job runs with any valid tag +- **THEN** the preflight fails with `::error::` mentioning "Required check + 'Build and Test' has not passed" and the `release` job does not run + +#### Scenario: CI check is still in progress + +- **GIVEN** the HEAD commit has a `Build and Test` check that has not yet + concluded (status is pending or in_progress) +- **WHEN** the preflight job queries the Checks API +- **THEN** the preflight fails with `::error::` mentioning "has not passed" + and the `release` job does not run + +#### Scenario: Checks API failure + +- **GIVEN** the GitHub Checks API returns an error or is unreachable +- **WHEN** the preflight job queries CI status +- **THEN** the preflight fails with `::error::` mentioning the API error + and the `release` job does not run + +#### Scenario: Tag already exists on different commit + +- **GIVEN** tag `v1.1.0` already exists on the remote and points to a + commit other than HEAD +- **WHEN** the preflight job runs with tag `v1.1.0` +- **THEN** the preflight fails at the tag uniqueness step with `::error::` + mentioning "already exists" and the `release` job does not run + +#### Scenario: Tag is not greater than latest + +- **GIVEN** the latest existing tag is `v1.2.0` +- **WHEN** the preflight job runs with tag `v1.1.0` +- **THEN** the preflight fails at the semver ordering step with `::error::` + mentioning "not greater than" and the `release` job does not run + +#### Scenario: First release (no existing tags) + +- **GIVEN** no `v*` tags exist on the remote +- **WHEN** the preflight job runs with tag `v0.1.0` +- **THEN** the semver ordering check passes (no previous tag to compare + against) and the preflight proceeds + +#### Scenario: No unreleased commits + +- **GIVEN** there are zero commits since the latest tag +- **WHEN** the preflight job runs +- **THEN** the preflight fails with `::error::` mentioning "No unreleased + commits" and the `release` job does not run + +#### Scenario: Re-run after partial failure + +- **GIVEN** a previous `workflow_dispatch` run with tag `v1.2.0` created + and pushed the tag to the remote (pointing to HEAD), but the `release` + job failed +- **WHEN** a new `workflow_dispatch` is triggered with the same tag `v1.2.0` +- **THEN** the tag uniqueness check detects the tag exists and points to + HEAD, so it passes. The tag creation step detects the tag exists and + skips creation. All other validations succeed and the `release` job + proceeds + +### Requirement: Release Trigger via workflow_dispatch + +The `release.yml` workflow MUST use `on: workflow_dispatch` with a required +`tag` input of type `string` instead of `on: push: tags: ['v*']`. + +Previously: The workflow triggered automatically on any `v*` tag push. + +#### Scenario: Manual release trigger + +- **GIVEN** a contributor wants to create a release +- **WHEN** they run `gh workflow run release.yml -f tag=v1.2.0` +- **THEN** the workflow starts with `inputs.tag` set to `v1.2.0` + +### Requirement: Signing Secrets Detection + +The `preflight` job MUST check for the presence of macOS signing secrets and +output `has_signing_secrets` (true/false). The `release` job MUST forward +this output. The `sign-macos` job MUST be conditional on +`has_signing_secrets == 'true'`. + +#### Scenario: Signing secrets available + +- **GIVEN** the `MACOS_SIGN_P12` secret is configured +- **WHEN** the preflight job checks for signing secrets +- **THEN** `has_signing_secrets` is set to `true` and the `sign-macos` job + runs after `release` completes + +#### Scenario: Signing secrets unavailable + +- **GIVEN** no `MACOS_SIGN_P12` secret is configured +- **WHEN** the preflight job checks for signing secrets +- **THEN** `has_signing_secrets` is set to `false` and the `sign-macos` job + is skipped + +## MODIFIED Requirements + +### Requirement: CI Job Naming + +The `ci.yml` workflow's job MUST be named `Build and Test` using the `name:` +field. The job key SHOULD be `build-and-test`. + +Previously: The job was named `test` with no explicit `name:` field, appearing +in the GitHub UI as `test`. + +#### Scenario: CI check appears with correct name + +- **GIVEN** the `ci.yml` job has `name: Build and Test` +- **WHEN** CI runs on a push or pull request +- **THEN** the check appears in the GitHub UI as `Build and Test` + +### Requirement: Action SHA Pinning + +All GitHub Actions `uses:` references in `ci.yml` and `release.yml` MUST +use full commit SHAs instead of mutable tags. Each pinned reference SHOULD +include a trailing comment with the human-readable version. + +Previously: Actions used mutable floating tags (`@v4`, `@v5`, `@v6`). + +#### Scenario: Pinned action reference format + +- **GIVEN** a workflow step uses `actions/checkout` +- **WHEN** the workflow file is reviewed +- **THEN** the reference is in the format + `actions/checkout@<40-char-sha> # v6.0.3` (SHA + version comment) + +### Requirement: CI Workflow Hardening + +The `ci.yml` workflow MUST include: +- `permissions: contents: read` at workflow level +- `concurrency:` group with `cancel-in-progress: true` to prevent duplicate + runs on rapid pushes + +Previously: The workflow had no `permissions:` or `concurrency:` blocks. + +#### Scenario: Concurrent CI runs are cancelled + +- **GIVEN** a PR has a CI run in progress +- **WHEN** a new commit is pushed to the same PR +- **THEN** the in-progress run is cancelled and a new run starts + +### Requirement: Per-Job Permissions in Release Workflow + +The `release.yml` workflow MUST set `permissions: {}` at workflow level and +declare permissions per-job: +- `preflight`: `contents: write`, `checks: read` +- `release`: `contents: write`, `id-token: write` +- `sign-macos`: `contents: write` + +Previously: The workflow had `permissions: contents: write` at workflow level, +granting write access to all jobs including those that only need read access. + +#### Scenario: Preflight job has minimal permissions + +- **GIVEN** the `preflight` job only needs to push a tag and read check + statuses +- **WHEN** the job runs +- **THEN** it has only `contents: write` and `checks: read` permissions + +### Requirement: Release Tag Reference + +All references to `GITHUB_REF_NAME` or `github.ref_name` in the `release` and +`sign-macos` jobs MUST be replaced with `inputs.tag` (via a job-level +`env: RELEASE_TAG`) since `workflow_dispatch` sets `GITHUB_REF_NAME` to the +branch name, not the tag. + +Previously: Jobs used `${{ github.ref_name }}` and `${GITHUB_REF_NAME}` which +resolved to the tag name under the `push: tags:` trigger. + +#### Scenario: Release job uses correct tag + +- **GIVEN** the workflow is triggered via `workflow_dispatch` with + `tag: v1.2.0` from the `main` branch +- **WHEN** the release job runs +- **THEN** all tag references resolve to `v1.2.0`, not `main` + +## REMOVED Requirements + +None. + diff --git a/openspec/changes/ci-release-preflight/tasks.md b/openspec/changes/ci-release-preflight/tasks.md new file mode 100644 index 0000000..a4bedc2 --- /dev/null +++ b/openspec/changes/ci-release-preflight/tasks.md @@ -0,0 +1,80 @@ + + +## 1. Harden CI Workflow + +All tasks in this group modify `.github/workflows/ci.yml`. + +- [x] 1.1 Add `permissions: contents: read` block after `on:` section. +- [x] 1.2 Add `concurrency:` group (`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`. +- [x] 1.3 Rename job key from `test` to `build-and-test` and add `name: Build and Test`. +- [x] 1.4 Pin `actions/checkout@v4` to `actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10` with `# v6.0.3` comment. +- [x] 1.5 Pin `actions/setup-go@v5` to `actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c` with `# v6.4.0` comment. + +## 2. Add Preflight Job to Release Workflow + +All tasks in this group modify `.github/workflows/release.yml`. + +- [x] 2.1 Replace `on: push: tags: ['v*']` with `on: workflow_dispatch:` with `inputs: tag:` (required, type string, description `'Release tag (e.g., v0.2.0)'`). +- [x] 2.2 Replace workflow-level `permissions: contents: write` with `permissions: {}`. +- [x] 2.3 Add `concurrency: group: release-${{ github.ref }}, cancel-in-progress: false`. +- [x] 2.4 Add `preflight` job before the existing `release` job with `permissions: contents: write` and `checks: read`, `timeout-minutes: 10`, and `env: RELEASE_TAG: ${{ inputs.tag }}`. +- [x] 2.5 Add preflight step: Checkout with `fetch-depth: 0` using pinned checkout SHA. +- [x] 2.6 Add preflight step: Validate branch — reject if not triggered from `main` (`github.ref != 'refs/heads/main'`). +- [x] 2.7 Add preflight step: Validate tag format (`^v[0-9]+\.[0-9]+\.[0-9]+$`). +- [x] 2.8 Add preflight step: Check tag uniqueness via `git ls-remote --tags origin`. If the tag exists and points to HEAD, pass (re-run case). If it exists on a different commit, fail. +- [x] 2.9 Add preflight step: Verify semver ordering using `sort -V` comparison against latest tag. If no existing tags, skip (first release). +- [x] 2.10 Add preflight step: Verify CI passed on HEAD — query GitHub Checks API for `"Build and Test"` check with `success` conclusion. Fail if API call fails or check has not concluded. +- [x] 2.11 Add preflight step: Verify unreleased commits since last tag. +- [x] 2.12 Add preflight step: Create and push annotated tag (idempotent — check `git ls-remote` first, skip if tag already exists on HEAD). +- [x] 2.13 Move `check-secrets` step from `release` job into `preflight` job. Rename step output from `has_secrets` to `has_signing_secrets` for clarity. Add `has_signing_secrets` as preflight job output. + +## 3. Update Release Job + +All tasks in this group modify `.github/workflows/release.yml`. + +- [x] 3.1 Add `needs: preflight` to the `release` job. +- [x] 3.2 Add per-job permissions: `contents: write`, `id-token: write`. +- [x] 3.3 Add `env: RELEASE_TAG: ${{ inputs.tag }}` at job level. +- [x] 3.4 Add `ref: ${{ inputs.tag }}` to the checkout step. +- [x] 3.5 Add `GORELEASER_CURRENT_TAG: ${{ inputs.tag }}` to the GoReleaser step env. +- [x] 3.6 Pin `actions/checkout` to `df4cb1c069e1874edd31b4311f1884172cec0e10` with `# v6.0.3` comment. +- [x] 3.7 Pin `actions/setup-go` to `4a3601121dd01d1626a1e23e37211e3254c1c06c` with `# v6.4.0` comment. +- [x] 3.8 Pin `goreleaser/goreleaser-action` to `5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89` with `# v7.2.2` comment. +- [x] 3.9 Replace `${GITHUB_REF_NAME}` with `$RELEASE_TAG` in the cask upload step. +- [x] 3.10 Forward `has_signing_secrets` output from preflight via `outputs: has_signing_secrets: ${{ needs.preflight.outputs.has_signing_secrets }}`. + +## 4. Update sign-macos Job + +All tasks in this group modify `.github/workflows/release.yml`. + +- [x] 4.1 Add per-job permissions: `contents: write`. +- [x] 4.2 Add `env: RELEASE_TAG: ${{ inputs.tag }}` at job level. +- [x] 4.3 Replace all `${GITHUB_REF_NAME}` references with `$RELEASE_TAG` in: download step, sign step, asset replacement step, and Homebrew cask update step. +- [x] 4.4 Update the `if:` condition to reference `needs.release.outputs.has_signing_secrets`. + +## 5. Verification + +- [x] 5.1 Verify all `uses:` references in both workflow files use full commit SHAs (no floating tags remain). +- [x] 5.2 Verify no `GITHUB_REF_NAME` or `github.ref_name` references remain in `release.yml` (except in the `concurrency:` section where `github.ref` is intentionally used for per-branch serialization). +- [x] 5.3 Verify `has_signing_secrets` output name is consistent across preflight job output, release job forwarding, and sign-macos `if:` condition. +- [x] 5.4 Verify the `release` job has `needs: preflight` and `sign-macos` has `needs: release`. +- [x] 5.5 Verify constitution alignment: Observable Quality — preflight produces `::error::` annotations for machine-parseable feedback. Composability First — binary functionality is unaffected by workflow changes. +- [x] 5.6 Run YAML validation on both workflow files (`actionlint` or `yamllint` if available). +- [x] 5.7 Verify website documentation gate exemption: this is a CI/CD pipeline change (exempt per AGENTS.md). + +## 6. Documentation Updates + +- [x] 6.1 Update `.specify/memory/constitution.md` Development Workflow section (line ~101) to reflect the new release trigger: replace "Tag `v*` triggers GoReleaser" with "workflow_dispatch with tag input triggers preflight validation then GoReleaser." + + +