Skip to content

Commit f58bdd8

Browse files
authored
chore: bootstrap workflows wiped by sync (auto-close, mirror-back, etc.) (#758)
Restores the 6 workflow files that disappeared from public when their source-of-truth was removed from microsoft-foundry/foundry-samples-pr/public-overlay/.github/workflows/ in PRs #448 and #451. Files restored to last-known-good content from private overlay just before deletion: - ado-automation.yml, pre-commit.yml, pull-request-checks.yml, run-setup.yml: from foundry-samples-pr@3bc473b2^ (before #451) - redirect-pull-requests.yml (auto-close): from foundry-samples-pr@3bc473b2^ — includes the #437 internal/external contributor detection fix - mirror-back.yml: from foundry-samples-pr@ac86600b^ (before #448) — content matches PR #752 modulo whitespace artifacts Without these files, public PRs from external contributors no longer get the auto-close + redirect comment, and other CI/repo automation is silently disabled. Note: these files will be re-wiped on the next private->public sync that does a fresh-marks fast-import rebuild, because public-overlay/.github/workflows/ is still empty in private. The durable fix is a separate work item — either grant the sync GitHub App workflows:write or split overlay-apply to push workflow paths via PAT.
1 parent 2019a2c commit f58bdd8

6 files changed

Lines changed: 496 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Create ADO user story from GitHub issue
2+
run-name: GitHub Issue #${{ github.event.issue.number }}
3+
4+
on:
5+
issues:
6+
types: [opened]
7+
8+
jobs:
9+
create-ado-story:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Build payload and call Azure DevOps
14+
env:
15+
ADO_ORG: ${{ secrets.ADO_ORG }}
16+
ADO_PROJECT: ${{ secrets.ADO_PROJECT }}
17+
ADO_PAT: ${{ secrets.ADO_PAT }}
18+
DRI_EMAIL: ${{ secrets.DRI_EMAIL }}
19+
ADO_AREA_PATH: ${{ secrets.ADO_AREA_PATH }}
20+
ADO_ITERATION_PATH: ${{ secrets.ADO_ITERATION_PATH }}
21+
ADO_TAG: ${{ secrets.ADO_TAG }}
22+
ISSUE_TITLE: ${{ github.event.issue.title }}
23+
ISSUE_BODY: ${{ github.event.issue.body }}
24+
ISSUE_URL: ${{ github.event.issue.html_url }}
25+
run: |
26+
DESCRIPTION="<div>${ISSUE_BODY//$'\n'/<br/>}<br/><br/><a href=\"$ISSUE_URL\">$ISSUE_URL</a></div>"
27+
28+
jq -n \
29+
--arg title "$ISSUE_TITLE" \
30+
--arg desc "$DESCRIPTION" \
31+
--arg area "$ADO_AREA_PATH" \
32+
--arg iter "$ADO_ITERATION_PATH" \
33+
--arg assn "$DRI_EMAIL" \
34+
--arg tags "$ADO_TAG" \
35+
'[
36+
{ "op":"add", "path":"/fields/System.Title", "value":$title },
37+
{ "op":"add", "path":"/fields/System.Description", "value":$desc },
38+
{ "op":"add", "path":"/fields/System.AreaPath", "value":$area },
39+
{ "op":"add", "path":"/fields/System.IterationPath", "value":$iter },
40+
{ "op":"add", "path":"/fields/System.AssignedTo", "value":$assn },
41+
{ "op":"add", "path":"/fields/System.Tags", "value":$tags }
42+
]' > /tmp/payload.json
43+
44+
AUTH=$(printf ":$ADO_PAT" | base64 | tr -d '\n')
45+
WORK_ITEM_URL="https://dev.azure.com/${ADO_ORG}/${ADO_PROJECT}/_apis/wit/workitems/\$User%20Story?api-version=7.1-preview.3"
46+
47+
curl --fail-with-body -sS \
48+
-H "Content-Type: application/json-patch+json" \
49+
-H "Authorization: Basic $AUTH" \
50+
-X POST \
51+
--data-binary @/tmp/payload.json \
52+
"$WORK_ITEM_URL"

.github/workflows/mirror-back.yml

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: Mirror Public Commits Back to Private
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: 'Print the proposed private PR body without creating branches or PRs.'
10+
required: false
11+
type: boolean
12+
default: true
13+
public_sha:
14+
description: 'Specific public commit SHA to replay or preview. Defaults to the workflow SHA.'
15+
required: false
16+
type: string
17+
default: ''
18+
private_repo:
19+
description: 'Private repository that receives replay PRs.'
20+
required: false
21+
type: string
22+
default: 'microsoft-foundry/foundry-samples-pr'
23+
24+
permissions:
25+
contents: read
26+
27+
concurrency:
28+
group: mirror-back-${{ github.ref }}
29+
cancel-in-progress: false
30+
31+
jobs:
32+
mirror:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Resolve repositories
36+
id: repos
37+
env:
38+
PRIVATE_REPO_INPUT: ${{ github.event.inputs.private_repo || 'microsoft-foundry/foundry-samples-pr' }}
39+
run: |
40+
set -euo pipefail
41+
private_owner="${PRIVATE_REPO_INPUT%%/*}"
42+
private_name="${PRIVATE_REPO_INPUT#*/}"
43+
if [[ -z "$private_owner" || -z "$private_name" || "$private_owner" == "$private_name" ]]; then
44+
echo "::error::private_repo must be owner/name; got '$PRIVATE_REPO_INPUT'"
45+
exit 1
46+
fi
47+
echo "private_repo=$PRIVATE_REPO_INPUT" >> "$GITHUB_OUTPUT"
48+
echo "private_owner=$private_owner" >> "$GITHUB_OUTPUT"
49+
echo "private_name=$private_name" >> "$GITHUB_OUTPUT"
50+
51+
- name: Validate App secrets
52+
env:
53+
SYNC_APP_ID: ${{ secrets.SYNC_APP_ID }}
54+
SYNC_APP_PRIVATE_KEY: ${{ secrets.SYNC_APP_PRIVATE_KEY }}
55+
run: |
56+
set -euo pipefail
57+
if [[ -z "$SYNC_APP_ID" || -z "$SYNC_APP_PRIVATE_KEY" ]]; then
58+
echo "::error::SYNC_APP_ID and SYNC_APP_PRIVATE_KEY must be configured on the public repository before mirror-back can mint a private-repo App token."
59+
exit 1
60+
fi
61+
62+
- name: Generate private repo App token
63+
id: app-token
64+
uses: actions/create-github-app-token@v1
65+
with:
66+
app-id: ${{ secrets.SYNC_APP_ID }}
67+
private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }}
68+
owner: ${{ steps.repos.outputs.private_owner }}
69+
repositories: ${{ steps.repos.outputs.private_name }}
70+
71+
- name: Checkout public repo
72+
uses: actions/checkout@v4
73+
with:
74+
path: public-repo
75+
fetch-depth: 0
76+
77+
- name: Checkout private repo
78+
uses: actions/checkout@v4
79+
with:
80+
repository: ${{ steps.repos.outputs.private_repo }}
81+
token: ${{ steps.app-token.outputs.token }}
82+
path: private-repo
83+
fetch-depth: 0
84+
ref: main
85+
86+
- name: Mirror non-sync-App public commits
87+
env:
88+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
89+
PUBLIC_REPO_PATH: ${{ github.workspace }}/public-repo
90+
PRIVATE_REPO_PATH: ${{ github.workspace }}/private-repo
91+
PUBLIC_REPO: ${{ github.repository }}
92+
PRIVATE_REPO: ${{ steps.repos.outputs.private_repo }}
93+
BEFORE_SHA: ${{ github.event.before || '' }}
94+
AFTER_SHA: ${{ github.sha }}
95+
PUBLIC_SHA: ${{ github.event.inputs.public_sha || '' }}
96+
DRY_RUN: ${{ github.event.inputs.dry_run == 'true' && '1' || '0' }}
97+
run: bash public-repo/.github/scripts/mirror-back.sh

.github/workflows/pre-commit.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Pre-Commit
2+
3+
4+
on:
5+
# push:
6+
# branches:
7+
# - main
8+
# pull_request:
9+
# branches:
10+
# - main
11+
workflow_dispatch:
12+
13+
jobs:
14+
pre-commit:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v3
18+
- uses: actions/setup-python@v4
19+
with:
20+
python-version: "3.9"
21+
- run: pip install -r dev-requirements.txt
22+
- name: Run Pre-Commit
23+
run: pre-commit run --all-files
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Pull Request Checks
2+
3+
on:
4+
# push:
5+
# branches:
6+
# - main
7+
# pull_request:
8+
# branches:
9+
# - main
10+
workflow_dispatch:
11+
12+
jobs:
13+
pull_request_size:
14+
if: github.event_name == 'pull_request'
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v3
18+
- uses: actions/setup-python@v4
19+
with:
20+
python-version: "3.9"
21+
- name: Check Pull Request Size
22+
run: |
23+
git fetch origin ${{ github.event.pull_request.base.ref }} --quiet # Need to manually fetch base branch in CI
24+
python ./.github/scripts/commit-filesize-diff-summary.py --limit 1MB origin/${{ github.event.pull_request.base.ref }}..HEAD
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
name: Redirect Pull Requests
2+
3+
on:
4+
pull_request_target:
5+
types: [opened]
6+
7+
permissions:
8+
pull-requests: write
9+
10+
jobs:
11+
redirect:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Check org membership and redirect
15+
uses: actions/github-script@v7
16+
with:
17+
script: |
18+
const pr = context.payload.pull_request;
19+
const author = pr.user.login;
20+
21+
// Allow PRs from trusted automation bots (e.g., repo sync)
22+
const allowedBots = ['foundry-samples-repo-sync[bot]'];
23+
if (allowedBots.includes(author)) {
24+
console.log(`Skipping redirect for allowed bot: ${author}`);
25+
return;
26+
}
27+
28+
// Classify the PR author as internal (Microsoft) vs external using a
29+
// cascade of signals. The GITHUB_TOKEN is an *installation* token, not
30+
// a user identity in the 'microsoft' or 'microsoft-foundry' orgs, so
31+
// the org-membership checks below can only confirm *public* members.
32+
// Most Microsoft employees default to private membership, so we also
33+
// fall back to a username pattern and a public-profile heuristic.
34+
// Contributors with no public Microsoft signal anywhere will still be
35+
// misclassified as external; the external-tier message below carries a
36+
// universal caveat pointing self-aware internal contributors at the
37+
// private staging repo, so that failure mode is self-correcting.
38+
async function classifyAuthor(login) {
39+
// Signal 1: microsoft-foundry org membership (public members only).
40+
try {
41+
const res = await github.rest.orgs.checkMembershipForUser({
42+
org: 'microsoft-foundry',
43+
username: login,
44+
});
45+
if (res.status === 204) return 'microsoft-foundry org member (public)';
46+
} catch {}
47+
48+
// Signal 2: direct collaborator on this repo (team-based access is
49+
// typically not visible to GITHUB_TOKEN here).
50+
try {
51+
const res = await github.rest.repos.checkCollaborator({
52+
owner: context.repo.owner,
53+
repo: context.repo.repo,
54+
username: login,
55+
});
56+
if (res.status === 204) return 'repo collaborator';
57+
} catch {}
58+
59+
// Signal 3: microsoft org membership (public members only).
60+
try {
61+
const res = await github.rest.orgs.checkMembershipForUser({
62+
org: 'microsoft',
63+
username: login,
64+
});
65+
if (res.status === 204) return 'microsoft org member (public)';
66+
} catch {}
67+
68+
// Signal 4: username pattern. Matches 'ms', 'msft', or 'microsoft'
69+
// as a whole token bounded by start/end/'-'/'_'. Catches handles like
70+
// 'aprilk-ms', 'mitsha-microsoft', 'brandom-msft' without false-
71+
// positiving 'cosmos', 'awesome', etc.
72+
if (/(^|[-_])(ms|msft|microsoft)([-_]|$)/i.test(login)) {
73+
return 'username pattern';
74+
}
75+
76+
// Signal 5: public profile heuristic. Strict regex on `email`, plus
77+
// a normalized whole-string match on `company` against a small allow
78+
// list. We deliberately do NOT scan `bio` ΓÇö phrases like
79+
// 'ex-Microsoft' or 'Microsoft MVP' would produce false positives.
80+
try {
81+
const { data: profile } = await github.rest.users.getByUsername({ username: login });
82+
const email = (profile.email || '').trim();
83+
if (/@([a-z0-9-]+\.)?microsoft\.com$/i.test(email)) {
84+
return 'profile email (@microsoft.com)';
85+
}
86+
const normalizedCompany = (profile.company || '')
87+
.trim()
88+
.toLowerCase()
89+
.replace(/^@/, '')
90+
.replace(/[.,]+$/, '');
91+
const acceptedCompanies = new Set([
92+
'microsoft',
93+
'microsoft corporation',
94+
'microsoft corp',
95+
'msft',
96+
]);
97+
if (acceptedCompanies.has(normalizedCompany)) {
98+
return 'profile company';
99+
}
100+
} catch {}
101+
102+
return null;
103+
}
104+
105+
const matchedSignal = await classifyAuthor(author);
106+
const isInternal = matchedSignal !== null;
107+
108+
console.log(`Author: ${author}, isInternal: ${isInternal}, signal: ${matchedSignal || 'none'}`);
109+
110+
let body;
111+
if (isInternal) {
112+
body = [
113+
`👋 Thanks for your contribution, @${author}!`,
114+
'',
115+
'This repository is read-only. If you are contributing on behalf of Microsoft, please submit your PR to the private staging repository instead:',
116+
'',
117+
'👉 **[foundry-samples-pr](https://github.com/microsoft-foundry/foundry-samples-pr)**',
118+
'',
119+
'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for full instructions.',
120+
].join('\n');
121+
} else {
122+
body = [
123+
`👋 Thanks for your interest in contributing, @${author}!`,
124+
'',
125+
'This repository does not accept pull requests directly. If you\'d like to report a bug, suggest an improvement, or propose a new sample, please **[open an issue](https://github.com/microsoft-foundry/foundry-samples/issues/new)** instead.',
126+
'',
127+
'_If you are a Microsoft-internal contributor, please submit your PR through **[foundry-samples-pr](https://github.com/microsoft-foundry/foundry-samples-pr)** instead._',
128+
'',
129+
'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for more details.',
130+
].join('\n');
131+
}
132+
133+
// Skip if the bot already commented (idempotent on re-runs). We
134+
// match on the staging-repo slug "microsoft-foundry/foundry-samples-pr",
135+
// which both the internal- and external-tier messages above include
136+
// and is structurally specific to this workflow ΓÇö generic phrases
137+
// like "This repository" can collide with unrelated bot comments
138+
// and silently suppress the redirect.
139+
const comments = await github.rest.issues.listComments({
140+
owner: context.repo.owner,
141+
repo: context.repo.repo,
142+
issue_number: pr.number,
143+
});
144+
const alreadyCommented = comments.data.some(c =>
145+
c.user.login === 'github-actions[bot]' &&
146+
c.body.includes('microsoft-foundry/foundry-samples-pr')
147+
);
148+
if (alreadyCommented) {
149+
console.log('Bot already commented on this PR, skipping.');
150+
return;
151+
}
152+
153+
await github.rest.issues.createComment({
154+
owner: context.repo.owner,
155+
repo: context.repo.repo,
156+
issue_number: pr.number,
157+
body,
158+
});
159+
160+
await github.rest.pulls.update({
161+
owner: context.repo.owner,
162+
repo: context.repo.repo,
163+
pull_number: pr.number,
164+
state: 'closed',
165+
});

0 commit comments

Comments
 (0)