diff --git a/.github/workflows/pr-source-check.yml b/.github/workflows/pr-source-check.yml new file mode 100644 index 0000000..42a6c29 --- /dev/null +++ b/.github/workflows/pr-source-check.yml @@ -0,0 +1,18 @@ +name: pr-source-check + +on: + pull_request: + branches: [main] + +jobs: + pr-source-check: + runs-on: ubuntu-latest + steps: + - name: Enforce source = test for PRs into main + run: | + if [ "${{ github.event.pull_request.head.ref }}" != "test" ]; then + echo "::error::PRs into main must originate from 'test'. Head is '${{ github.event.pull_request.head.ref }}'." + echo "::error::Open your PR against 'test' instead. main only advances via the Release workflow." + exit 1 + fi + echo "PR source is 'test' — allowed." diff --git a/.github/workflows/release-poller.yml b/.github/workflows/release-poller.yml new file mode 100644 index 0000000..9b8ce66 --- /dev/null +++ b/.github/workflows/release-poller.yml @@ -0,0 +1,36 @@ +name: Release poller + +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + +permissions: + actions: write + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.RELEASE_SCHEDULER_TOKEN || secrets.GITHUB_TOKEN }} + steps: + - name: Check pending release + run: | + target=$(gh variable get PENDING_RELEASE_AT --repo "${{ github.repository }}" 2>/dev/null || echo "") + if [ -z "$target" ]; then + echo "No release scheduled." + exit 0 + fi + + now_epoch=$(date -u +%s) + target_epoch=$(date -d "$target" -u +%s) + if [ "$now_epoch" -lt "$target_epoch" ]; then + mins_left=$(( (target_epoch - now_epoch) / 60 )) + echo "Release scheduled for $target — $mins_left min remaining." + exit 0 + fi + + echo "::notice::Scheduled time reached ($target). Dispatching release." + gh workflow run release.yml --repo "${{ github.repository }}" --ref test + gh variable delete PENDING_RELEASE_AT --repo "${{ github.repository }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..81bfc22 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release (promote test → main) + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — show what would happen, do not push' + type: boolean + default: false + workflow_call: + inputs: + dry_run: + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: test + + - name: Verify main is ancestor of test + run: | + git fetch origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor origin/main origin/test; then + echo "::error::main is not an ancestor of test. Reconcile manually before promoting." + exit 1 + fi + ahead=$(git rev-list --count origin/main..origin/test) + echo "main is behind test by $ahead commit(s). Ready to fast-forward." + echo "AHEAD=$ahead" >> $GITHUB_ENV + + - name: Tag release + run: | + TAG="release-$(date -u +%Y%m%d-%H%M%S)" + git tag "$TAG" origin/test + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Fast-forward main → test + if: ${{ !inputs.dry_run }} + run: | + git push origin "refs/remotes/origin/test:refs/heads/main" + git push origin "$TAG" + echo "::notice::Promoted $AHEAD commits to main. Tagged $TAG." + + - name: Notify Discord + if: ${{ !inputs.dry_run && env.DISCORD_WEBHOOK != '' }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + run: | + repo="${{ github.repository }}" + sha=$(git rev-parse origin/test) + short_sha="${sha:0:7}" + compare_url="https://github.com/${repo}/compare/${TAG}...main" + tag_url="https://github.com/${repo}/releases/tag/${TAG}" + payload=$(jq -n \ + --arg content "🚀 **${repo}** released \`${TAG}\` — promoted **${AHEAD}** commit(s) to \`main\` (\`${short_sha}\`)." \ + --arg tag "$tag_url" \ + --arg cmp "$compare_url" \ + '{content: $content, embeds: [{title: "View tag", url: $tag}, {title: "Diff", url: $cmp}]}') + curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$DISCORD_WEBHOOK" + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + echo "DRY RUN — would fast-forward main to $(git rev-parse origin/test)" + echo "Tag would be: $TAG" + git log --oneline origin/main..origin/test | head -50 diff --git a/.github/workflows/schedule-release.yml b/.github/workflows/schedule-release.yml new file mode 100644 index 0000000..70ca321 --- /dev/null +++ b/.github/workflows/schedule-release.yml @@ -0,0 +1,80 @@ +name: Schedule a release + +on: + workflow_dispatch: + inputs: + release_at: + description: 'When to release — local datetime (YYYY-MM-DD HH:MM), interpreted in the timezone below' + required: true + timezone: + description: 'Timezone for release_at' + required: true + default: 'America/Chicago' + type: choice + options: + - America/Chicago + - America/New_York + - America/Los_Angeles + - America/Denver + - UTC + action: + description: 'Action' + required: true + default: 'schedule' + type: choice + options: + - schedule + - cancel + - show + +permissions: + actions: write + contents: read + # Needed to read/write repository variables via gh api + # Requires GITHUB_TOKEN with repo admin, OR a PAT stored as RELEASE_SCHEDULER_TOKEN + +jobs: + manage-schedule: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.RELEASE_SCHEDULER_TOKEN || secrets.GITHUB_TOKEN }} + steps: + - name: Show current schedule + if: ${{ inputs.action == 'show' }} + run: | + current=$(gh variable get PENDING_RELEASE_AT --repo "${{ github.repository }}" 2>/dev/null || echo "") + if [ -z "$current" ]; then + echo "::notice::No release scheduled." + else + echo "::notice::Release scheduled for: $current (UTC)" + fi + + - name: Cancel scheduled release + if: ${{ inputs.action == 'cancel' }} + run: | + gh variable delete PENDING_RELEASE_AT --repo "${{ github.repository }}" 2>/dev/null || true + echo "::notice::Scheduled release canceled." + + - name: Schedule release + if: ${{ inputs.action == 'schedule' }} + run: | + local_input="${{ inputs.release_at }}" + tz="${{ inputs.timezone }}" + + # Parse local datetime in the chosen timezone, convert to UTC ISO8601 + utc_iso=$(TZ="$tz" date -d "$local_input" -u +"%Y-%m-%dT%H:%M:%SZ") || { + echo "::error::Could not parse '$local_input'. Use format: YYYY-MM-DD HH:MM" + exit 1 + } + + now_epoch=$(date -u +%s) + target_epoch=$(date -d "$utc_iso" -u +%s) + if [ "$target_epoch" -le "$now_epoch" ]; then + echo "::error::Scheduled time is in the past: $utc_iso" + exit 1 + fi + + gh variable set PENDING_RELEASE_AT --body "$utc_iso" --repo "${{ github.repository }}" + + human_local=$(TZ="$tz" date -d "$utc_iso" +"%Y-%m-%d %H:%M %Z") + echo "::notice::Release scheduled for $human_local ($utc_iso UTC)"