Skip to content

Commit 7039caf

Browse files
author
Peter Hauge
committed
Merge remote-tracking branch 'origin/main' into docs/improve-environment-overrides
2 parents e299248 + 18bc2be commit 7039caf

44 files changed

Lines changed: 1689 additions & 433 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/integration-test.yml

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ concurrency:
8787
cancel-in-progress: false # Don't cancel — would leave resources running
8888

8989
env:
90-
SOURCE_RG: rg-apiops-bvt-source-${{ github.run_id }}
91-
TARGET_RG: rg-apiops-bvt-target-${{ github.run_id }}
90+
SOURCE_RG: rg-apiops-bvt-src-${{ github.run_id }}
91+
TARGET_RG: rg-apiops-bvt-tgt-${{ github.run_id }}
9292
SOURCE_APIM: apiops-bvt-src-${{ github.run_id }}
9393
TARGET_APIM: apiops-bvt-tgt-${{ github.run_id }}
9494

@@ -101,9 +101,9 @@ jobs:
101101
environment: integration-test # Requires approval for cost protection
102102

103103
steps:
104-
- uses: actions/checkout@v4
104+
- uses: actions/checkout@v6
105105

106-
- uses: actions/setup-node@v4
106+
- uses: actions/setup-node@v5
107107
with:
108108
node-version: '22'
109109
cache: 'npm'
@@ -128,7 +128,7 @@ jobs:
128128
"skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
129129
130130
- name: Azure Login
131-
uses: azure/login@v2
131+
uses: azure/login@v3
132132
with:
133133
client-id: ${{ secrets.AZURE_CLIENT_ID }}
134134
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -154,7 +154,7 @@ jobs:
154154
155155
- name: Azure Login - Refresh Before Phase 2
156156
if: success()
157-
uses: azure/login@v2
157+
uses: azure/login@v3
158158
with:
159159
# Phase 1 can run for a long time while APIM activates; refresh login
160160
# before extract/publish so apiops receives a fresh federated session.
@@ -232,14 +232,6 @@ jobs:
232232
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
233233
-LogLevel '${{ steps.settings.outputs.logLevel }}'
234234
235-
- name: Upload Extracted Artifacts
236-
if: always()
237-
uses: actions/upload-artifact@v4
238-
with:
239-
name: extracted-artifacts-${{ steps.phase1.outputs.skuName }}
240-
path: ${{ steps.phase2.outputs.ExtractOutputDir || './extracted-artifacts/' }}
241-
if-no-files-found: ignore
242-
243235
- name: Run Round-Trip Phase 7 (Teardown)
244236
if: always()
245237
shell: pwsh

.github/workflows/stale-issues.yml

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
name: Stale Issue Management
2+
3+
# Trigger: daily schedule
4+
# Scans open issues with no activity for 30+ days and closes those stale 60+ days.
5+
#
6+
# Protected labels — issues carrying ANY of these are NEVER touched:
7+
# priority:p0 | priority:p1 | security-review
8+
#
9+
# Exclusion is enforced in four overlapping layers (defense in depth):
10+
# 1. Search query — protected-label issues are excluded from the query result set.
11+
# 2. Code guard — every candidate is skipped at the start of the loop body if a
12+
# protected label is still present (catches search-index lag).
13+
# 3. Structural — add-labels only allows [stale]; close requires [stale] already
14+
# present, guaranteeing at least one warning cycle before close.
15+
# 4. Post-check — the close-stale job re-fetches every candidate's labels via the
16+
# API immediately before closing and aborts if a protected label is
17+
# found. This layer survives any other failure.
18+
19+
on:
20+
schedule:
21+
- cron: '0 9 * * *' # Daily at 09:00 UTC
22+
workflow_dispatch:
23+
24+
# Protected labels, thresholds, and workflow configuration defined at workflow level.
25+
env:
26+
PROTECTED_LABELS: '["priority:p0","priority:p1","security-review"]'
27+
STALE_DAYS: '30'
28+
STALE_CLOSE_DAYS: '60'
29+
30+
permissions:
31+
issues: write
32+
contents: read
33+
34+
jobs:
35+
# ──────────────────────────────────────────────────────────────────────────
36+
# Job 1 — Warn issues inactive for 30+ days
37+
# Adds the `stale` label and posts a "last chance" comment.
38+
# Maximum 10 issues per run.
39+
# ──────────────────────────────────────────────────────────────────────────
40+
warn-stale:
41+
name: Warn stale issues (30+ days)
42+
runs-on: ubuntu-latest
43+
44+
steps:
45+
- name: Warn stale issues
46+
uses: actions/github-script@v8
47+
with:
48+
script: |
49+
// Layer 2 — code guard: these labels make an issue untouchable regardless of
50+
// how it reached the candidate list.
51+
// PROTECTED_LABELS is defined at workflow level and shared with close-stale.
52+
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
53+
const MAX_WARN = 20;
54+
const STALE_DAYS = parseInt(process.env.STALE_DAYS, 10);
55+
const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10);
56+
57+
const now = new Date();
58+
const cutoff = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000);
59+
const cutoffDate = cutoff.toISOString().split('T')[0];
60+
61+
// Layer 1 — search query: protected-label issues excluded at query time.
62+
// Exclude clauses are generated from PROTECTED_LABELS so the query always
63+
// stays in sync with the code guard above.
64+
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
65+
const query = [
66+
`repo:${context.repo.owner}/${context.repo.repo}`,
67+
'is:issue',
68+
'is:open',
69+
...excludeClauses,
70+
'-label:stale',
71+
`updated:<${cutoffDate}`
72+
].join(' ');
73+
74+
core.info(`Warn query: ${query}`);
75+
76+
const result = await github.rest.search.issuesAndPullRequests({
77+
q: query,
78+
sort: 'updated',
79+
order: 'asc',
80+
per_page: MAX_WARN
81+
});
82+
83+
const candidates = result.data.items;
84+
core.info(`Found ${candidates.length} candidate(s) to warn`);
85+
86+
let warned = 0;
87+
for (const issue of candidates) {
88+
if (warned >= MAX_WARN) break;
89+
90+
// Layer 2 — code guard: skip if a protected label is present in the
91+
// search result payload (guards against search-index staleness).
92+
const labels = issue.labels.map(l => l.name);
93+
const hasProtected = PROTECTED_LABELS.some(l => labels.includes(l));
94+
if (hasProtected) {
95+
core.info(`Skipping #${issue.number} — protected label present`);
96+
continue;
97+
}
98+
99+
// Layer 3 — structural: only `stale` may be added by this workflow.
100+
await github.rest.issues.addLabels({
101+
owner: context.repo.owner,
102+
repo: context.repo.repo,
103+
issue_number: issue.number,
104+
labels: ['stale']
105+
});
106+
107+
const inactiveDays = Math.floor(
108+
(now - new Date(issue.updated_at)) / (24 * 60 * 60 * 1000)
109+
);
110+
111+
await github.rest.issues.createComment({
112+
owner: context.repo.owner,
113+
repo: context.repo.repo,
114+
issue_number: issue.number,
115+
body: [
116+
`⚠️ **Stale issue notice** — This issue has had no activity for **${inactiveDays} day(s)**.`,
117+
'',
118+
`It will be automatically closed after **${STALE_CLOSE_DAYS} days** of inactivity.`,
119+
'',
120+
'To keep this issue open:',
121+
'- Leave a comment describing the current status, or',
122+
'- Remove the `stale` label.',
123+
'',
124+
'> This notice was posted automatically by the stale issue workflow.'
125+
].join('\n')
126+
});
127+
128+
core.info(`Warned #${issue.number} (inactive ${inactiveDays} days)`);
129+
warned++;
130+
}
131+
132+
core.info(`Warn run complete — warned ${warned} issue(s)`);
133+
134+
# ──────────────────────────────────────────────────────────────────────────
135+
# Job 2 — Close issues inactive for 60+ days that already carry `stale`
136+
# Maximum 5 issues per run. Runs after warn-stale to avoid racing on issues
137+
# that were just warned in the same run.
138+
# ──────────────────────────────────────────────────────────────────────────
139+
close-stale:
140+
name: Close stale issues (60+ days)
141+
runs-on: ubuntu-latest
142+
needs: warn-stale
143+
144+
steps:
145+
- name: Close warned stale issues
146+
uses: actions/github-script@v8
147+
with:
148+
script: |
149+
// Layer 2 — code guard.
150+
// PROTECTED_LABELS is defined at workflow level and shared with warn-stale.
151+
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
152+
const MAX_CLOSE = 10;
153+
const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10);
154+
155+
const now = new Date();
156+
const cutoff = new Date(now.getTime() - STALE_CLOSE_DAYS * 24 * 60 * 60 * 1000);
157+
const cutoffDate = cutoff.toISOString().split('T')[0];
158+
159+
// Layer 1 — search query: protected-label issues excluded at query time.
160+
// Layer 3 — structural: `label:stale` is required; issues without it are
161+
// never returned (guarantees at least one warning cycle before close).
162+
// Exclude clauses are generated from PROTECTED_LABELS so the query always
163+
// stays in sync with the code guard above.
164+
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
165+
const query = [
166+
`repo:${context.repo.owner}/${context.repo.repo}`,
167+
'is:issue',
168+
'is:open',
169+
'label:stale',
170+
...excludeClauses,
171+
`updated:<${cutoffDate}`
172+
].join(' ');
173+
174+
core.info(`Close query: ${query}`);
175+
176+
const result = await github.rest.search.issuesAndPullRequests({
177+
q: query,
178+
sort: 'updated',
179+
order: 'asc',
180+
per_page: MAX_CLOSE
181+
});
182+
183+
const candidates = result.data.items;
184+
core.info(`Found ${candidates.length} candidate(s) to close`);
185+
186+
let closed = 0;
187+
for (const issue of candidates) {
188+
if (closed >= MAX_CLOSE) break;
189+
190+
// Layer 4 — deterministic post-check: re-fetch labels from the API
191+
// immediately before closing. This is the only layer that survives a
192+
// fully compromised upstream step — it always reads live data.
193+
const fresh = await github.rest.issues.get({
194+
owner: context.repo.owner,
195+
repo: context.repo.repo,
196+
issue_number: issue.number
197+
});
198+
199+
const freshLabels = fresh.data.labels.map(l => l.name);
200+
201+
// Abort close if any protected label is present on the live issue.
202+
const hasProtected = PROTECTED_LABELS.some(l => freshLabels.includes(l));
203+
if (hasProtected) {
204+
const hit = freshLabels.filter(l => PROTECTED_LABELS.includes(l));
205+
core.warning(
206+
`Skipping close of #${issue.number} — protected label(s) detected: ${hit.join(', ')}`
207+
);
208+
continue;
209+
}
210+
211+
// Layer 3 — structural: stale label must be present on the live issue.
212+
if (!freshLabels.includes('stale')) {
213+
core.info(`Skipping #${issue.number} — stale label was removed`);
214+
continue;
215+
}
216+
217+
// All four layers passed — safe to close.
218+
await github.rest.issues.update({
219+
owner: context.repo.owner,
220+
repo: context.repo.repo,
221+
issue_number: issue.number,
222+
state: 'closed',
223+
state_reason: 'not_planned'
224+
});
225+
226+
// Remove the stale label now that the issue is closed.
227+
await github.rest.issues.removeLabel({
228+
owner: context.repo.owner,
229+
repo: context.repo.repo,
230+
issue_number: issue.number,
231+
name: 'stale'
232+
});
233+
234+
const inactiveDays = Math.floor(
235+
(now - new Date(fresh.data.updated_at)) / (24 * 60 * 60 * 1000)
236+
);
237+
238+
await github.rest.issues.createComment({
239+
owner: context.repo.owner,
240+
repo: context.repo.repo,
241+
issue_number: issue.number,
242+
body: [
243+
`🔒 **Closing as stale** — This issue has had no activity for **${inactiveDays} day(s)**.`,
244+
'',
245+
'It has been automatically closed as `not_planned`.',
246+
'',
247+
'If this issue is still relevant, please:',
248+
'1. Reopen it, and',
249+
'2. Add a comment describing the current status.',
250+
'',
251+
'> This action was taken automatically by the stale issue workflow.'
252+
].join('\n')
253+
});
254+
255+
core.info(`Closed #${issue.number} (inactive ${inactiveDays} days)`);
256+
closed++;
257+
}
258+
259+
core.info(`Close run complete — closed ${closed} issue(s)`);

.github/workflows/sync-squad-labels.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
name: Sync Squad Labels
1+
name: Sync Labels
22

33
on:
4+
schedule:
5+
- cron: '0 8 * * 1' # Weekly on Monday at 08:00 UTC
46
push:
57
paths:
68
- '.squad/team.md'
@@ -103,6 +105,12 @@ jobs:
103105
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
104106
];
105107
108+
// Lifecycle labels managed by automated workflows
109+
const LIFECYCLE_LABELS = [
110+
{ name: 'stale', color: 'aaaaaa', description: 'No activity for 30+ days — will be closed if no further activity' },
111+
{ name: 'security-review', color: 'B60205', description: 'Requires security review — exempt from stale automation' }
112+
];
113+
106114
function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
107115
108116
// Ensure the base "squad" triage label exists
@@ -127,12 +135,13 @@ jobs:
127135
});
128136
}
129137
130-
// Add go:, release:, type:, priority:, and high-signal labels
138+
// Add go:, release:, type:, priority:, high-signal, and lifecycle labels
131139
labels.push(...GO_LABELS);
132140
labels.push(...RELEASE_LABELS);
133141
labels.push(...TYPE_LABELS);
134142
labels.push(...PRIORITY_LABELS);
135143
labels.push(...SIGNAL_LABELS);
144+
labels.push(...LIFECYCLE_LABELS);
136145
137146
// Sync labels (create or update)
138147
for (const label of labels) {

.squad/agents/apimexpert/history.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,13 @@ the SDK surface, reference docs, or ad-hoc observation.
106106
- Classic Developer/Premium SKU only.
107107
- Docs: <https://learn.microsoft.com/rest/api/apimanagement/policy-restriction> · <https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/policyrestrictions>
108108

109+
### 2026-06-20: Link-import vs inline-import operation fidelity (round-trip)
110+
111+
When comparing a *link-imported* API (e.g. Petstore via `swagger-link`/`openapi-link`) against the `apiops publish` *inline-imported* result, operation payloads diverge in two import-path-only ways — neither is an apiops bug:
112+
113+
1. **`schemaId`/`typeName` on representations.** APIM binds operation request/response representations to an API-level schema **only on link import**. Inline import (`format: openapi, value: <spec>`, what publish uses) does NOT rebind, so the target has no `schemaId`. The schema *content* is identical and still extracted as API-level Schemas. → strip `schemaId`, `typeName` (and any derived schema token) on operation resources.
114+
115+
2. **Parameter/header `description`.** Link import drops `templateParameters`/`queryParameters`/header descriptions; inline import preserves them from the spec. So the published **target is more faithful** than the link-imported source. Authoritative descriptions live in the API schema. → strip `description` on parameter-shaped objects (`{ name, …, values }`).
116+
117+
Round-trip comparison harness (`tests/integration/all-resource-types`) normalizes both via `RepresentationSchemaRefIgnoredProperties` and `ParameterIgnoredProperties`. Symptom if not stripped: every operation shows a `properties.request/responses/templateParameters` DIFF present-on-one-side-only.
118+

.squad/agents/githubexpert/history.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Learnings
44

5+
### 2026-06-22 — Prefer github-script@v8 in workflows
6+
7+
**Context:** `stale-issues.yml` emitted a warning that Node.js 20 is deprecated because `actions/github-script@v7` targets Node 20 and is forced to run on Node 24.
8+
9+
**Key pattern:** For GitHub workflow scripting steps, use `actions/github-script@v8` by default and avoid `@v7` to prevent deprecation warnings and keep runtime compatibility aligned with current runners.
10+
511
### 2026-07-15 — Merge main into feature branch (shallow clone handling)
612

713
**Context:** Branch `copilot/fix-github-issue-96` was a shallow clone (grafted). Merging main required `git fetch --unshallow` first, then `git fetch origin main:refs/remotes/origin/main` to create the remote tracking ref. PR #93 (`Fix unit test failure in workspace tests`) fixed the failing workspace-extractor test. After merge, all 910 tests pass.

.squad/agents/testengineer/history.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,14 @@ Gotchas for future PowerShell work:
172172
- `$x = if ($cond) { [List[T]]::new() }` assigns `$null` — PowerShell enumerates the empty list. Use `$x = $null; if ($cond) { $x = ... }`.
173173
- `ProcessStartInfo.StandardOutputEncoding/StandardErrorEncoding` default to OEM on Windows; force UTF-8 or `az --debug` output mangles.
174174

175+
### 2026-06-19: Round-trip hardened for `Set-StrictMode -Version Latest`
176+
177+
Run strict (inherited by `&` child scopes): `pwsh -Command "Set-StrictMode -Version Latest; & ./tests/integration/all-resource-types/run-roundtrip-test.ps1 -SkuName Premium -Location eastus2"`. Surfaced 3 latent Phase 1 bugs.
178+
179+
- **`.Count` on a scalar throws.** `Where-Object`/`Sort-Object`/`Get-ChildItem` return a scalar for single-item results — wrap the whole pipeline in `@(...)`. Fixed: Compare-ApimInstance.ps1 (`@(... | Sort-Object)`, `@()` must include `Sort-Object`; had silently swallowed the workspace-product `groupLinks` diff via an outer `catch`), run-phase1-deploy.ps1 job-poll loop, run-phase2-extract.ps1.
180+
- **Start-Job stray output → array.** A job leaking pipeline output makes `Receive-Job` return an array; `$out.apimServiceName` then throws. Root cause: run-phase1-deploy-source.ps1 called `Invoke-MaskedAzCommand` bare (post-activation) — fix `| Out-Null`. Defense: orchestrator reads `Receive-Job | Select-Object -Last 1`.
181+
- **Deploy return-shape asymmetry:** source = flat `@{ apimServiceName }`; target = raw ARM outputs (`apimServiceName.value`).
182+
- **Strict mode is now baked into run-roundtrip-test.ps1** (`Set-StrictMode -Version Latest`), so it's always on regardless of launch wrapper. Empirically confirmed: under Latest, both `$psobject.missing` AND `$hashtable.missing` throw (so `ConvertFrom-Json -AsHashtable` is no escape); only `$obj.PSObject.Properties['name']` returns `$null` safely.
183+
- **Test-ExtractedArtifact.ps1** does manifest-driven JSON traversal with optional fields (`skuDependent`, `files`, `scope`, ...) — each absent read threw under Latest. Fix: added a `Get-Prop $obj 'name'` safe-accessor helper and routed every optional read through it (do NOT opt down to `-Version 1.0`). Smoke-tested standalone under Latest against real Premium artifacts: 322/322 checks, exit 0.
184+
- **Parse-check:** `pwsh -Command '$e=$null; [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path FILE),[ref]$null,[ref]$e)>$null; if($e){$e|%{$_.Message};exit 1}else{"OK"}'`
185+

0 commit comments

Comments
 (0)