From 43f9f02029a578f64a404857f1ed843baba248cc Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:05:59 +0400 Subject: [PATCH] chore: update issue validation --- .github/workflows/compliance-close.yml | 108 -------------- .github/workflows/issue-compliance.yml | 166 ++------------------- .github/workflows/publish-repos.yml | 193 +++---------------------- 3 files changed, 38 insertions(+), 429 deletions(-) delete mode 100644 .github/workflows/compliance-close.yml diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml deleted file mode 100644 index 5e3ceca..0000000 --- a/.github/workflows/compliance-close.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Compliance Close - -on: - schedule: - # Every 30 minutes; the actual close decision uses comment age, so the cron - # cadence only bounds how stale the closure can get past the 24-hour mark. - - cron: "*/30 * * * *" - workflow_dispatch: - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-non-compliant: - if: github.repository == 'zhom/donutbrowser' - runs-on: ubuntu-latest - steps: - - name: Close non-compliant issues and PRs after 24 hours - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { data: items } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'needs:compliance', - state: 'open', - per_page: 100, - }); - - if (items.length === 0) { - core.info('No open issues/PRs with needs:compliance label'); - return; - } - - const now = Date.now(); - const window_ms = 24 * 60 * 60 * 1000; - - for (const item of items) { - const isPR = !!item.pull_request; - const kind = isPR ? 'PR' : 'issue'; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - }); - - // Use the OLDEST compliance sentinel as the start of the 24-hour - // window so back-and-forth edits don't reset the clock. - const sentinel = comments - .filter(c => c.body && c.body.includes('')) - .sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0]; - - if (!sentinel) { - core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`); - continue; - } - - const age_ms = now - new Date(sentinel.created_at).getTime(); - if (age_ms < window_ms) { - const hours = (age_ms / (60 * 60 * 1000)).toFixed(1); - core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`); - continue; - } - - const closeMessage = isPR - ? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.' - : 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - body: closeMessage, - }); - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - name: 'needs:compliance', - }); - } catch (e) { - core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`); - } - - if (isPR) { - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: item.number, - state: 'closed', - }); - } else { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - state: 'closed', - state_reason: 'not_planned', - }); - } - - core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`); - } diff --git a/.github/workflows/issue-compliance.yml b/.github/workflows/issue-compliance.yml index e29cd1e..1a5a8ed 100644 --- a/.github/workflows/issue-compliance.yml +++ b/.github/workflows/issue-compliance.yml @@ -2,7 +2,7 @@ name: Issue Compliance Check on: issues: - types: [opened, edited] + types: [opened] permissions: contents: read @@ -13,7 +13,12 @@ env: jobs: check-compliance: - if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened' + # Maintainers' own issues are exempt — they open quick tracking issues + # without the template on purpose. Everyone else is checked. + if: >- + github.repository == 'zhom/donutbrowser' && + github.event.issue.author_association != 'OWNER' && + github.event.issue.author_association != 'MEMBER' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -44,7 +49,7 @@ jobs: - A feature request gives no use case at all - The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports) - Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative. + Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative — a non-compliant verdict closes the issue, so only flag a genuine template violation. ## Output schema { @@ -83,7 +88,7 @@ jobs: jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt # Strip accidental markdown fences and parse. On parse failure, fall back - # to a noop result so the workflow doesn't fail the issue author's run. + # to a compliant result so a flaky model never closes a legitimate issue. sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json if ! jq -e . /tmp/result.json >/dev/null 2>&1; then echo "::warning::Model returned non-JSON; treating as compliant" @@ -94,6 +99,7 @@ jobs: cat /tmp/result.json - name: Build comment + id: build run: | python3 - <<'EOF' import json, os @@ -103,167 +109,25 @@ jobs: parts = [] if not compliant: - parts.append('') - parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).") + parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).") parts.append('') - parts.append('**What needs to be fixed:**') + parts.append('**What was missing:**') for reason in reasons: parts.append(f'- {reason}') parts.append('') - parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.') - parts.append('') - parts.append('If you believe this was flagged incorrectly, please let a maintainer know.') + parts.append('If this is a real bug or feature request, please open a new issue using the **Bug Report** or **Feature Request** template and fill in the required fields. Issues that ignore the template are not triaged.') comment = '\n'.join(parts).strip() open('/tmp/comment.md', 'w').write(comment) with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: - fh.write(f'has_comment={"true" if comment else "false"}\n') fh.write(f'non_compliant={"true" if not compliant else "false"}\n') EOF - id: build - - name: Post comment - if: steps.build.outputs.has_comment == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md - - - name: Apply needs:compliance label + - name: Comment and close non-compliant issue if: steps.build.outputs.non_compliant == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance" - - recheck-compliance: - # When a flagged issue is edited, re-check. If now compliant: remove label, - # delete the previous compliance comment, and thank the author. If still - # non-compliant: leave label and post an updated note. - if: > - github.repository == 'zhom/donutbrowser' && - github.event.action == 'edited' && - contains(github.event.issue.labels.*.name, 'needs:compliance') - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Gather context - env: - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} - run: | - printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt - printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt - - - name: Build prompt - run: | - cat > /tmp/system.txt <<'PROMPT' - You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences. - - Project: Donut Browser. There are three valid templates: - - Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields) - - Feature Request (description + verification checkbox) - - Question (free form) - - ## Flag NON-compliant ONLY when at least one of these is true - - The issue body is empty or contains only placeholder text from the template - - The issue is an obvious AI-generated wall of text with no real specifics - - A bug report has no reproduction information or no error description - - A feature request gives no use case at all - - The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports) - - Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative. - - ## Output schema - { - "is_compliant": true | false, - "non_compliance_reasons": ["short bullet", ...] - } - PROMPT - - - name: Call OpenRouter - env: - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - run: | - PAYLOAD=$(jq -n \ - --arg model "$MODEL" \ - --rawfile system_prompt /tmp/system.txt \ - --rawfile title /tmp/issue-title.txt \ - --rawfile body /tmp/issue-body.txt \ - '{ - model: $model, - messages: [ - { role: "system", content: $system_prompt }, - { role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) } - ], - response_format: { type: "json_object" } - }') - - RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \ - -H "Authorization: Bearer $OPENROUTER_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD") - - jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt - sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json - if ! jq -e . /tmp/result.json >/dev/null 2>&1; then - echo "::warning::Model returned non-JSON; assuming still non-compliant" - echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json - fi - - - name: Resolve compliance state - id: resolve - run: | - IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json) - echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT" - - - name: Clear compliance label and acknowledge fix - if: steps.resolve.outputs.is_compliant == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true - - # Delete the previous sentinel comment so - # the thread is clean once the author has addressed the issue. - COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \ - --jq '[.[] | select(.body | contains(""))][-1].id // empty') - if [ -n "$COMMENT_ID" ]; then - gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true - fi - - gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --body "Thanks for updating the issue." - - - name: Build follow-up comment - if: steps.resolve.outputs.is_compliant != 'true' - run: | - python3 - <<'EOF' - import json - r = json.load(open('/tmp/result.json')) - reasons = r.get('non_compliance_reasons') or [] - parts = [ - '', - 'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).', - '', - '**What still needs to be fixed:**', - ] - for reason in reasons: - parts.append(f'- {reason}') - parts.append('') - parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.') - open('/tmp/comment.md', 'w').write('\n'.join(parts)) - EOF - - - name: Post follow-up comment - if: steps.resolve.outputs.is_compliant != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: | gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md + gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned" diff --git a/.github/workflows/publish-repos.yml b/.github/workflows/publish-repos.yml index e570bbf..fec527d 100644 --- a/.github/workflows/publish-repos.yml +++ b/.github/workflows/publish-repos.yml @@ -23,6 +23,9 @@ jobs: github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - name: Determine release tag id: tag env: @@ -40,182 +43,32 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" fi - - name: Configure aws-cli for R2 - # aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2 - # rejects those headers with `Unauthorized` on ListObjectsV2. - # Also normalise the endpoint URL (must start with https://). - # Both values propagate to later steps via $GITHUB_ENV. - env: - RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }} - run: | - endpoint="$RAW_ENDPOINT" - if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then - endpoint="https://$endpoint" - fi - echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV" - echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV" - echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV" - - - name: Install tools + - name: Install tools (dpkg-dev, createrepo-c, aws-cli v1) run: | sudo apt-get update sudo apt-get install -y dpkg-dev createrepo-c python3-pip - # Remove pre-installed aws-cli v2 — it sends CRC64NVME checksums - # that Cloudflare R2 rejects with Unauthorized, and the s3transfer - # lib has a confirmed bug where WHEN_REQUIRED is silently ignored - # (boto/s3transfer#327). Install aws-cli v1 via pip instead. + # GitHub runners ship aws-cli v2, which sends CRC64NVME integrity + # checksums that Cloudflare R2 rejects with `Unauthorized` on + # ListObjectsV2 (the call behind `aws s3 sync`). aws-cli v1 predates + # that behavior — the same reason scripts/publish-repo.sh works in + # Docker. Remove v2 and install v1 system-wide so it's the binary on + # PATH within this job, and fail fast if v2 somehow survives rather + # than letting the publish step die on an opaque Unauthorized later. sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer sudo rm -rf /usr/local/aws-cli - pip3 install --break-system-packages awscli - # Ensure pip-installed aws is on PATH (pip may install to ~/.local/bin) - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + sudo pip3 install --break-system-packages 'awscli<2' + hash -r aws --version + case "$(aws --version 2>&1)" in + aws-cli/1.*) ;; + *) echo "::error::Expected aws-cli v1 but got: $(aws --version 2>&1)"; exit 1 ;; + esac - - name: Download packages from GitHub release + - name: Publish DEB & RPM repositories to R2 env: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.tag.outputs.tag }} - run: | - mkdir -p /tmp/packages - gh release download "$TAG" \ - --repo "${{ github.repository }}" \ - --pattern "*.deb" \ - --dir /tmp/packages - gh release download "$TAG" \ - --repo "${{ github.repository }}" \ - --pattern "*.rpm" \ - --dir /tmp/packages - echo "Downloaded packages:" - ls -lh /tmp/packages/ - - - name: Build DEB repository - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }} - run: | - DEB_DIR="/tmp/repo/deb" - mkdir -p "$DEB_DIR/pool/main" - mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64" - mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64" - - # Sync existing pool from R2 (incremental) - aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \ - --endpoint-url "$R2_ENDPOINT" 2>/dev/null || true - - # Copy new .deb files into pool - cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true - - # Generate Packages and Packages.gz for each arch - for arch in amd64 arm64; do - BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}" - (cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \ - > "$BINARY_DIR/Packages" - gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz" - echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)" - done - - # Generate Release file - { - echo "Origin: Donut Browser" - echo "Label: Donut Browser" - echo "Suite: stable" - echo "Codename: stable" - echo "Architectures: amd64 arm64" - echo "Components: main" - echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')" - echo "MD5Sum:" - for arch in amd64 arm64; do - for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do - filepath="$DEB_DIR/dists/stable/$file" - if [[ -f "$filepath" ]]; then - size=$(wc -c < "$filepath") - md5=$(md5sum "$filepath" | awk '{print $1}') - printf " %s %8d %s\n" "$md5" "$size" "$file" - fi - done - done - echo "SHA256:" - for arch in amd64 arm64; do - for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do - filepath="$DEB_DIR/dists/stable/$file" - if [[ -f "$filepath" ]]; then - size=$(wc -c < "$filepath") - sha256=$(sha256sum "$filepath" | awk '{print $1}') - printf " %s %8d %s\n" "$sha256" "$size" "$file" - fi - done - done - } > "$DEB_DIR/dists/stable/Release" - - echo "DEB Release file created." - - - name: Build RPM repository - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }} - run: | - RPM_DIR="/tmp/repo/rpm" - mkdir -p "$RPM_DIR/x86_64" - mkdir -p "$RPM_DIR/aarch64" - - # Sync existing RPMs from R2 (incremental) - aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \ - --endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true - aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \ - --endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true - - # Copy new .rpm files into arch directories - for rpm in /tmp/packages/*.rpm; do - [[ -f "$rpm" ]] || continue - filename=$(basename "$rpm") - if [[ "$filename" == *x86_64* ]]; then - cp "$rpm" "$RPM_DIR/x86_64/" - elif [[ "$filename" == *aarch64* ]]; then - cp "$rpm" "$RPM_DIR/aarch64/" - fi - done - - # Generate repodata - createrepo_c --update "$RPM_DIR" - echo "RPM repodata created." - - - name: Upload to R2 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }} - run: | - echo "Uploading DEB repository..." - aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \ - --endpoint-url "$R2_ENDPOINT" --delete - aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \ - --endpoint-url "$R2_ENDPOINT" - - echo "Uploading RPM repository..." - aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \ - --endpoint-url "$R2_ENDPOINT" - - - name: Verify upload - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - run: | - echo "Published repos for $TAG" - echo "" - echo "DEB dists/stable/:" - aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \ - --endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)" - echo "DEB pool/main/:" - aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \ - --endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)" - echo "RPM repodata/:" - aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \ - --endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)" + run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"