Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
109 changes: 109 additions & 0 deletions .github/workflows/deploy-pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Deploy PR Preview

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: write
pull-requests: write

concurrency:
group: pages-branch
cancel-in-progress: true

jobs:
deploy-preview:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
Comment on lines +17 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared concurrency group cancels cross-workflow deployments

All four workflows (deploy-pr-preview.yml, deploy-site.yml, remove-pr-preview.yml, undeploy-site.yml) share group: pages-branch with cancel-in-progress: true. This means a push to main will cancel any in-flight PR preview deployment (and vice versa).

In the worst case: a PR preview run is mid-way through the rsync + git add steps when a main push triggers the site deploy and cancels it. The gh-pages worktree at /tmp/xnet-gh-pages is abandoned mid-edit, but since the push hadn't happened yet, the remote gh-pages branch is unaffected. The PR preview simply never gets published, with no error surfaced to the PR author.

A safer pattern is to use separate concurrency groups for PR previews and site deploys — they both operate on gh-pages but write to distinct subdirectories (/pr/<N>/ vs everything else):

# In deploy-pr-preview.yml / remove-pr-preview.yml
concurrency:
  group: pages-pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

# In deploy-site.yml / undeploy-site.yml
concurrency:
  group: pages-site
  cancel-in-progress: true

The rsync --exclude pr in deploy-site.yml and rsync -a --delete inside /pr/<N>/ in the preview workflows already keep them isolated at the file level, so separate groups would not introduce write conflicts.

This same issue is present in .github/workflows/remove-pr-preview.yml (line 17) and .github/workflows/undeploy-site.yml (line 12).

- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}

- uses: ./.github/actions/setup

- name: Build packages
run: pnpm build

- name: Build web app preview
run: pnpm --filter xnet-web build
env:
VITE_BASE_PATH: /pr/${{ github.event.pull_request.number }}/app/
VITE_USE_HASH_ROUTER: 'true'

- name: Prepare preview tree
run: |
rm -rf /tmp/xnet-pr-preview
mkdir -p /tmp/xnet-pr-preview
cp -R apps/web/dist/. /tmp/xnet-pr-preview/
cp /tmp/xnet-pr-preview/index.html /tmp/xnet-pr-preview/404.html

- name: Publish preview to gh-pages
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

rm -rf /tmp/xnet-gh-pages
git fetch origin gh-pages || true

if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages
else
git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD
find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp site/public/CNAME /tmp/xnet-gh-pages/CNAME
touch /tmp/xnet-gh-pages/.nojekyll
fi

preview_dir="/tmp/xnet-gh-pages/pr/${{ github.event.pull_request.number }}/app"
mkdir -p "$preview_dir"
rsync -a --delete /tmp/xnet-pr-preview/ "$preview_dir/"

cd /tmp/xnet-gh-pages
git add -A

if git diff --cached --quiet; then
echo "No preview changes to publish."
exit 0
fi

git commit -m "deploy(preview): publish PR #${{ github.event.pull_request.number }} preview"
git push origin HEAD:gh-pages

- name: Comment preview link
uses: actions/github-script@v7
with:
script: |
const body = [
'<!-- xnet-pr-preview -->',
`Preview: https://xnet.fyi/pr/${context.payload.pull_request.number}/app/`
].join('\n')

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number
})

const existing = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes('<!-- xnet-pr-preview -->')
)

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
})
return
}

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body
})
63 changes: 39 additions & 24 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ on:
workflow_dispatch:

permissions:
Comment on lines 12 to 14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contents: write is broader than the minimum required permission

The previous implementation used pages: write + id-token: write, which are scoped specifically to GitHub Pages deployments via the OIDC token exchange. The new approach replaces these with contents: write, which grants the workflow token write access to every branch and file in the repository.

For the site deploy (triggered by pushes to main), this is only exploitable by someone who can merge to main — acceptable for this repo. However, deploy-pr-preview.yml also uses contents: write, and it's triggered by pull_request events. Although the fork guard (if: github.event.pull_request.head.repo.full_name == github.repository) prevents external-fork PRs from running the job, any internal collaborator with PR-creation rights could run arbitrary workflow steps with write-to-all-branches access.

Consider splitting the token scope more tightly, or documenting the accepted risk:

permissions:
  contents: write  # Required to push to gh-pages branch only
  pull-requests: write  # Required to post preview link comments

GitHub doesn't yet offer a gh-pages: write fine-grained permission, so the comment-based documentation is the pragmatic mitigation here.

contents: read
pages: write
id-token: write
contents: write

concurrency:
group: pages
group: pages-branch
cancel-in-progress: true
Comment on lines +18 to 19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GitHub Actions cancel-in-progress shared concurrency group behavior multiple workflows

💡 Result:

In GitHub Actions, concurrency groups are scoped at the repository level, so different workflows share the same concurrency “slot” if they use the same concurrency.group value—and they can block/cancel each other. [1]

What happens when multiple workflows share a group

For a given concurrency group, GitHub ensures at most 1 running and 1 pending run/job at a time. If a new run is queued into the same group while another is running, the new one becomes pending, and any existing pending run in that group is canceled and replaced by the newer one. [1]

How cancel-in-progress interacts across workflows

  • If the newly-triggered run has cancel-in-progress: true, GitHub will cancel the currently running run/job in that same group—even if it’s from a different workflow. [1]
  • If the newly-triggered run has cancel-in-progress: false (or omitted), it won’t cancel the running one; it will just wait in pending (still replacing any older pending run). [1]

Avoiding cross-workflow cancellation

GitHub explicitly warns that if you have multiple workflows, concurrency group names must be unique across workflows to avoid canceling each other. A common pattern is:
group: ${{ github.workflow }}-${{ github.ref }}. [1]

Sources: GitHub Docs on workflow/job concurrency and cross-workflow group collisions/cancellation behavior. [1]


🏁 Script executed:

# Find and examine the workflow files mentioned
fd -t f "deploy-site.yml|deploy-pr-preview.yml|remove-pr-preview.yml|undeploy-site.yml" .github/workflows/

Repository: crs48/xNet

Length of output: 203


🏁 Script executed:

# Check deploy-site.yml concurrency section
head -n 25 .github/workflows/deploy-site.yml | tail -n 10

Repository: crs48/xNet

Length of output: 205


🏁 Script executed:

# Check all four workflows for their concurrency groups
for file in .github/workflows/deploy-site.yml .github/workflows/deploy-pr-preview.yml .github/workflows/remove-pr-preview.yml .github/workflows/undeploy-site.yml; do
  echo "=== $file ==="
  grep -A 5 "^concurrency:" "$file" || echo "No concurrency found"
done

Repository: crs48/xNet

Length of output: 569


Remove cancel-in-progress: true from shared concurrency group to prevent production deploys from being canceled.

When multiple workflows share the same concurrency.group, setting cancel-in-progress: true allows any newly-triggered workflow to cancel currently running jobs from other workflows in that group. With all four deployment workflows (deploy-site.yml, deploy-pr-preview.yml, remove-pr-preview.yml, undeploy-site.yml) using group: pages-branch, a preview or cleanup run can cancel an in-flight production deploy, leaving gh-pages stale.

Set cancel-in-progress: false in all four workflows:

Changes needed
  • .github/workflows/deploy-site.yml (line 19)
  • .github/workflows/deploy-pr-preview.yml
  • .github/workflows/remove-pr-preview.yml
  • .github/workflows/undeploy-site.yml

Change from cancel-in-progress: true to cancel-in-progress: false in each.

Alternatively, use unique concurrency groups per workflow (e.g., group: ${{ github.workflow }}-${{ github.ref }}) to prevent cross-workflow interference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-site.yml around lines 18 - 19, Multiple deployment
workflows share the same concurrency.group (group: pages-branch) and currently
set cancel-in-progress: true, which lets newer preview/cleanup runs cancel an
in-flight production deploy; update the concurrency block in deploy-site.yml,
deploy-pr-preview.yml, remove-pr-preview.yml, and undeploy-site.yml to set
cancel-in-progress: false (i.e., concurrency: { group: pages-branch,
cancel-in-progress: false }) or alternatively switch to unique concurrency
groups (e.g., group: ${{ github.workflow }}-${{ github.ref }}) to prevent
cross-workflow cancellations.


jobs:
build:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: ./.github/actions/setup

- name: Build packages
Expand All @@ -51,25 +50,41 @@ jobs:
run: pnpm build
working-directory: site

- name: Copy web app to site/dist/app
- name: Prepare production site tree
run: |
mkdir -p site/dist/app
cp -r apps/web/dist/* site/dist/app/
# SPA fallback for GitHub Pages
cp site/dist/app/index.html site/dist/app/404.html
rm -rf /tmp/xnet-pages-root
mkdir -p /tmp/xnet-pages-root/app
cp -R site/dist/. /tmp/xnet-pages-root/
cp -R apps/web/dist/. /tmp/xnet-pages-root/app/
cp /tmp/xnet-pages-root/app/index.html /tmp/xnet-pages-root/app/404.html
cp site/public/CNAME /tmp/xnet-pages-root/CNAME
touch /tmp/xnet-pages-root/.nojekyll

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: site/dist
- name: Publish production site to gh-pages
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
rm -rf /tmp/xnet-gh-pages
git fetch origin gh-pages || true

if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages
else
git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD
find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
fi

rsync -a --delete --exclude pr /tmp/xnet-pages-root/ /tmp/xnet-gh-pages/
mkdir -p /tmp/xnet-gh-pages/pr

cd /tmp/xnet-gh-pages
git add -A

if git diff --cached --quiet; then
echo "No production site changes to publish."
exit 0
fi

git commit -m "deploy(site): publish production site"
git push origin HEAD:gh-pages
92 changes: 92 additions & 0 deletions .github/workflows/remove-pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Remove PR Preview

on:
pull_request:
types: [closed]

permissions:
contents: write
pull-requests: write

concurrency:
group: pages-branch
cancel-in-progress: true

jobs:
remove-preview:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Remove preview from gh-pages
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

rm -rf /tmp/xnet-gh-pages
git fetch origin gh-pages || true

if ! git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
echo "gh-pages branch does not exist yet."
exit 0
fi

git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages

target="/tmp/xnet-gh-pages/pr/${{ github.event.pull_request.number }}"

if [ ! -d "$target" ]; then
echo "Preview path already absent."
exit 0
fi

rm -rf "$target"

cd /tmp/xnet-gh-pages
git add -A

if git diff --cached --quiet; then
echo "No preview cleanup changes to publish."
exit 0
fi

git commit -m "deploy(preview): remove PR #${{ github.event.pull_request.number }} preview"
git push origin HEAD:gh-pages

- name: Comment preview removal
uses: actions/github-script@v7
with:
script: |
const body = [
'<!-- xnet-pr-preview -->',
`Preview removed for PR #${context.payload.pull_request.number}.`
].join('\n')

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number
})

const existing = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes('<!-- xnet-pr-preview -->')
)

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
})
return
}

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body
})
51 changes: 37 additions & 14 deletions .github/workflows/undeploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,50 @@ on:
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write
contents: write

concurrency:
group: pages
group: pages-branch
cancel-in-progress: true

jobs:
undeploy:
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- name: Create empty site
run: mkdir -p empty && echo '<html><body><p>Site offline.</p></body></html>' > empty/index.html
- uses: actions/checkout@v4

- name: Upload empty artifact
uses: actions/upload-pages-artifact@v3
with:
path: empty
- name: Replace gh-pages contents with offline page
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Deploy empty page
uses: actions/deploy-pages@v4
rm -rf /tmp/xnet-gh-pages
git fetch origin gh-pages || true

if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages
else
git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD
fi

find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp site/public/CNAME /tmp/xnet-gh-pages/CNAME
touch /tmp/xnet-gh-pages/.nojekyll
cat <<'EOF' > /tmp/xnet-gh-pages/index.html
<html>
<body>
<p>Site offline.</p>
</body>
</html>
EOF

cd /tmp/xnet-gh-pages
git add -A

if git diff --cached --quiet; then
echo "No undeploy changes to publish."
exit 0
fi

git commit -m "deploy(site): publish offline placeholder"
git push origin HEAD:gh-pages
Loading