Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
196 changes: 167 additions & 29 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -58,39 +192,43 @@ 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: |
set -euo pipefail
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 }}

- 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
Expand Down Expand Up @@ -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
Expand All @@ -148,15 +286,15 @@ 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 }}

- 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"
Expand All @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions openspec/changes/ci-release-preflight/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: unbound-force
created: 2026-06-16
Loading