diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8ecca88 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,142 @@ +name: Deploy Release + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + deploy: + if: github.event.issue.pull_request != null && github.event.comment.body == 'DEPLOY-RELEASE' + runs-on: ubuntu-latest + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Resolve PR metadata + run: | + PR_NUMBER="${{ github.event.issue.number }}" + echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV" + + PR_JSON=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER) + PR_BRANCH=$(echo "$PR_JSON" | jq -r '.head.ref') + PR_STATE=$(echo "$PR_JSON" | jq -r '.state') + + echo "PR_BRANCH=$PR_BRANCH" >> "$GITHUB_ENV" + + if [ "$PR_STATE" != "open" ]; then + gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments \ + --method POST \ + --field body="Deploy cancelled: this PR is not open (state: \`$PR_STATE\`)." + exit 1 + fi + + - name: Check authorization + run: | + COMMENTER="${{ github.event.comment.user.login }}" + + PERMISSION=$(gh api \ + repos/${{ github.repository }}/collaborators/$COMMENTER/permission \ + --jq '.permission' 2>/dev/null || echo "none") + + IS_ADMIN=false + if [ "$PERMISSION" = "admin" ]; then + IS_ADMIN=true + fi + + APPROVALS=$(gh api \ + repos/${{ github.repository }}/pulls/${{ env.PR_NUMBER }}/reviews \ + --jq '[.[] | select(.state == "APPROVED")] | length') + + echo "Commenter: $COMMENTER | Is admin: $IS_ADMIN | Approvals: $APPROVALS" + + if [ "$IS_ADMIN" = "false" ] && [ "$APPROVALS" -lt 2 ]; then + gh api repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/comments \ + --method POST \ + --field body="Deploy cancelled: **@$COMMENTER** is not a repo admin and this PR only has **$APPROVALS/2** required approvals. Either an admin must trigger the deploy, or the PR needs at least 2 approvals." + exit 1 + fi + + - name: Validate release branch + run: | + BRANCH="${{ env.PR_BRANCH }}" + + if ! echo "$BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + gh api repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/comments \ + --method POST \ + --field body="Deploy cancelled: branch \`$BRANCH\` does not match the required pattern \`release/vX.Y.Z\`. Only release branches may be deployed to main." + exit 1 + fi + + VERSION="${BRANCH#release/v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ env.PR_BRANCH }} + fetch-depth: 0 + # NOTE: If branch protection requires signed commits, replace with: + # token: ${{ secrets.RELEASE_PAT }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump package.json version + run: | + jq --arg v "${{ env.VERSION }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Commit version bump + run: | + git add package.json + if git diff --cached --quiet; then + echo "Version already at ${{ env.VERSION }}, skipping commit." + else + git commit -m "chore(release): bump version to ${{ env.VERSION }}" + fi + + - name: Push to PR branch + run: | + git push origin HEAD:${{ env.PR_BRANCH }} + + - name: Create git tag + run: | + git tag "${{ env.TAG }}" + git push origin "${{ env.TAG }}" + + - name: Create GitHub Release + run: | + gh release create "${{ env.TAG }}" \ + --title "Release ${{ env.TAG }}" \ + --generate-notes \ + --repo ${{ github.repository }} + + - name: Merge PR + run: | + gh pr merge ${{ env.PR_NUMBER }} \ + --merge \ + --repo ${{ github.repository }} + + - name: Post success comment + if: success() + run: | + gh api repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/comments \ + --method POST \ + --field body="Deployed successfully. Version \`${{ env.VERSION }}\` has been tagged as \`${{ env.TAG }}\`, a GitHub Release has been created with auto-generated release notes, and this PR has been merged into \`main\`." + + - name: Post failure comment + if: failure() + run: | + gh api repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/comments \ + --method POST \ + --field body="Deploy failed. Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." \ + 2>/dev/null || true diff --git a/.github/workflows/enforce-release-branch.yml b/.github/workflows/enforce-release-branch.yml new file mode 100644 index 0000000..32e0c89 --- /dev/null +++ b/.github/workflows/enforce-release-branch.yml @@ -0,0 +1,27 @@ +name: Enforce release branch target + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + +permissions: + pull-requests: write + +jobs: + check-branch: + runs-on: ubuntu-latest + steps: + - name: Reject non-release PRs targeting main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="${{ github.head_ref }}" + if ! echo "$BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --method POST \ + --field body="This PR has been closed automatically. Only \`release/vX.Y.Z\` branches may target \`main\`. Please retarget this PR to a release branch." + gh pr close ${{ github.event.pull_request.number }} --repo ${{ github.repository }} + echo "::error::PRs targeting main must come from a release/vX.Y.Z branch. Got: $BRANCH" + exit 1 + fi diff --git a/scripts/setup.sh b/scripts/setup.sh index c1de4e3..03edf94 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -27,7 +27,9 @@ echo "Commands:" find "$AW_COMMANDS" -maxdepth 1 -type l | while read -r f; do [ ! -e "$f" ] && rm "$f"; done for dir in "$AIWORKERS_DIR/src/commands"/*/; do name=$(basename "$dir") - ln -sfn "$dir" "$AW_COMMANDS/$name" + target="$AW_COMMANDS/$name" + [ -d "$target" ] && [ ! -L "$target" ] && rm -rf "$target" + ln -sfn "$dir" "$target" echo " ✓ $name" done @@ -38,7 +40,9 @@ echo "Skills:" find "$AW_SKILLS" -maxdepth 1 -type l | while read -r f; do [ ! -e "$f" ] && rm "$f"; done for dir in "$AIWORKERS_DIR/src/skills"/*/; do name=$(basename "$dir") - ln -sfn "$dir" "$AW_SKILLS/$name" + target="$AW_SKILLS/$name" + [ -d "$target" ] && [ ! -L "$target" ] && rm -rf "$target" + ln -sfn "$dir" "$target" echo " ✓ $name" done