Skip to content

Commit a9c56b9

Browse files
authored
Merge pull request #45264 from github/repo-sync
Repo sync
2 parents 3665888 + b5eac08 commit a9c56b9

10 files changed

Lines changed: 270 additions & 30 deletions

File tree

.github/workflows/changelog-agent.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ jobs:
412412

413413
- name: Install GitHub Copilot CLI
414414
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
415-
run: npm install -g @github/copilot
415+
run: npm install -g @github/copilot@1.0.71
416416

417417
- name: Generate changelog draft via Copilot
418418
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'

.github/workflows/purge-fastly.yml

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ name: Purge Fastly
99

1010
on:
1111
deployment_status:
12+
schedule:
13+
# Daily soft purge of all languages to catch reusables and AUTOTITLE links.
14+
# Intentionally off-peak, around 6-7pm PT.
15+
- cron: '20 2 * * *'
1216
workflow_dispatch:
1317
inputs:
1418
languages:
@@ -58,6 +62,17 @@ jobs:
5862
- name: Check out repo
5963
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
6064

65+
- name: Generate GitHub App token
66+
# Lets a scheduled-run failure open a tracking issue in docs-engineering
67+
# (the default GITHUB_TOKEN can't write cross-repo).
68+
id: app-token
69+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
70+
with:
71+
app-id: ${{ secrets.DOCS_BOT_APP_ID }}
72+
private-key: ${{ secrets.DOCS_BOT_APP_PRIVATE_KEY }}
73+
owner: github
74+
repositories: docs-engineering
75+
6176
- uses: ./.github/actions/node-npm-setup
6277

6378
- name: Validate confirmation input
@@ -76,8 +91,9 @@ jobs:
7691
7792
- name: Purge Fastly
7893
# Auto post-deploy runs wait for the build, purge English only (temporary),
79-
# and stay soft. A manual run uses the inputs: blank languages = all, the
80-
# `hard` toggle, or a confirmed full-cache purge.
94+
# and stay soft. The daily scheduled run waits for nothing, purges all
95+
# languages (blank = all), and stays soft. A manual run uses the inputs:
96+
# blank languages = all, the `hard` toggle, or a confirmed full-cache purge.
8197
#
8298
# Raw inputs are passed through the environment and quoted, never spliced
8399
# into the command string, so a value like `en' --everything` can't break
@@ -89,10 +105,8 @@ jobs:
89105
EVERYTHING_INPUT: ${{ inputs.everything }}
90106
run: |
91107
args=()
92-
if [ "$EVENT_NAME" != "workflow_dispatch" ]; then
93-
args+=(--wait-for-build)
94-
fi
95108
if [ "$EVENT_NAME" = "deployment_status" ]; then
109+
args+=(--wait-for-build)
96110
args+=(--languages en)
97111
elif [ -n "$LANGUAGES_INPUT" ]; then
98112
args+=(--languages "$LANGUAGES_INPUT")
@@ -123,3 +137,8 @@ jobs:
123137
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
124138
with:
125139
slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
140+
141+
- uses: ./.github/actions/create-workflow-failure-issue
142+
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
143+
with:
144+
token: ${{ steps.app-token.outputs.token }}

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,81 @@ test.describe('test nav at different viewports', () => {
732732
})
733733
})
734734

735+
test.describe('secondary-bar breadcrumb scroller', () => {
736+
// The secondary bar (and its breadcrumb scroller) only renders at wide
737+
// viewports, and the fixture trail is short enough to fit there, so we cap the
738+
// scroller width to force a deterministic overflow independent of title
739+
// lengths — then exercise the chevrons.
740+
test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => {
741+
// Smooth-scroll settle waits across several chevron clicks add up past the
742+
// default 5s cap.
743+
test.setTimeout(20000)
744+
page.setViewportSize({ width: 1300, height: 700 })
745+
await page.goto('/get-started/foo/bar')
746+
747+
const bar = page.getByTestId('breadcrumbs-bar')
748+
await expect(bar).toBeVisible()
749+
750+
const scrollArea = page.locator('[data-search="breadcrumbs"]')
751+
await expect(scrollArea).toBeVisible()
752+
753+
// Force a deterministic overflow independent of title lengths: cap the
754+
// scroll region, drop the nav's min-width:100% (which otherwise stretches the
755+
// short fixture trail to fill the container so it never overflows), and pad
756+
// the crumbs so several are hidden at once — enough that a per-crumb nudge is
757+
// distinguishable from a jump to the end.
758+
await page.addStyleTag({
759+
content: `
760+
[data-search="breadcrumbs"] { max-width: 360px; }
761+
[data-search="breadcrumbs"] nav { min-width: 0 !important; }
762+
[data-search="breadcrumbs"] li { padding-right: 60px; }
763+
`,
764+
})
765+
766+
const scrollLeftOf = () => scrollArea.evaluate((el) => el.scrollLeft)
767+
const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth)
768+
await expect.poll(maxScrollOf).toBeGreaterThan(0)
769+
770+
// Anchor to the right end explicitly so we start from a known state: fully
771+
// scrolled right (current page visible), only the left chevron active.
772+
await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' }))
773+
const maxScroll = await maxScrollOf()
774+
await expect.poll(scrollLeftOf).toBe(maxScroll)
775+
776+
const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' })
777+
const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' })
778+
// At the right extreme the left chevron is active and the right one is hidden.
779+
await expect(leftChevron).toBeVisible()
780+
await expect(rightChevron).toBeHidden()
781+
782+
// One left click nudges toward the start by a single crumb — it must move,
783+
// but must NOT jump all the way to 0 (the old behavior) while more than one
784+
// crumb is still hidden to the left.
785+
await leftChevron.click()
786+
await expect.poll(scrollLeftOf).toBeLessThan(maxScroll)
787+
const afterOneLeft = await scrollLeftOf()
788+
expect(afterOneLeft).toBeGreaterThan(0)
789+
// The right chevron appears once we're no longer at the right extreme.
790+
await expect(rightChevron).toBeVisible()
791+
792+
// A right click walks back toward the current page by one crumb, not a full
793+
// jump back to the right extreme.
794+
await rightChevron.click()
795+
await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft)
796+
797+
// Repeated left clicks eventually reach the start, which hides the left
798+
// chevron (canScrollLeft flips false). Drive off the chevron's own visibility
799+
// rather than an exact scrollLeft, since smooth scrolling can leave a
800+
// sub-pixel remainder.
801+
for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) {
802+
await leftChevron.click()
803+
await page.waitForTimeout(200)
804+
}
805+
await expect(leftChevron).toBeHidden()
806+
await expect.poll(scrollLeftOf).toBeLessThanOrEqual(1)
807+
})
808+
})
809+
735810
test.describe('survey', () => {
736811
test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled')
737812

src/frame/components/page-header/BreadcrumbsScroller.module.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@
3838
background-color: var(--bgColor-default, var(--color-canvas-default));
3939
}
4040

41+
// On hover, keep the solid canvas background (Primer's invisible IconButton
42+
// otherwise swaps in a translucent tint, letting crumbs bleed through the
43+
// button) and instead signal interactivity by bolding the chevron: a
44+
// currentColor stroke thickens the fill-based octicon glyph.
45+
.leftChevron:hover,
46+
.rightChevron:hover {
47+
background-color: var(
48+
--bgColor-default,
49+
var(--color-canvas-default)
50+
) !important;
51+
color: var(--fgColor-default, var(--color-fg-default));
52+
53+
svg {
54+
stroke: currentColor;
55+
stroke-width: 0.75px;
56+
}
57+
}
58+
4159
// Hidden chevrons are removed from view and interaction (and from the tab
4260
// order / a11y tree in the component). Because they're absolutely positioned
4361
// they already occupy no layout space, so the scroll area is unaffected.

src/frame/components/page-header/BreadcrumbsScroller.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { Breadcrumbs } from './Breadcrumbs'
99

1010
import styles from './BreadcrumbsScroller.module.scss'
1111

12+
// Padding past the ~28px chevron so a revealed crumb clears it rather than
13+
// landing half-hidden underneath.
14+
const CHEVRON_PAD = 32
15+
1216
// Wraps the secondary-bar breadcrumbs in a horizontal scroll region. When the
1317
// trail is too long to fit, the region scrolls — anchored to the RIGHT so the
1418
// current page is shown first — with a left chevron to reveal the leftmost
@@ -67,20 +71,42 @@ export const BreadcrumbsScroller = () => {
6771
return () => observer.disconnect()
6872
}, [anchorRight])
6973

70-
// Reveal the start of the trail (Home) in one click. Breadcrumb trails are
71-
// short, so scrolling fully to the left is clearer than a partial nudge that
72-
// can leave the leading crumb half-hidden behind the chevron.
74+
// Reveal the previous crumb: align the crumb straddling the left edge to that
75+
// edge. Targeting the boundary crumb (not stepping back from the first fully
76+
// visible one) also covers a current crumb wider than the viewport. Falls back
77+
// to the far left only when nothing is clipped left.
7378
const scrollLeftClick = useCallback(() => {
7479
const el = scrollRef.current
7580
if (!el) return
76-
el.scrollTo({ left: 0 })
81+
const containerLeft = el.getBoundingClientRect().left + CHEVRON_PAD
82+
const crumbs = Array.from(el.querySelectorAll('li'))
83+
// The rightmost crumb still clipped/off to the left: the one to reveal.
84+
let target: Element | undefined
85+
for (const crumb of crumbs) {
86+
if (crumb.getBoundingClientRect().left < containerLeft - 1) target = crumb
87+
}
88+
if (!target) {
89+
el.scrollTo({ left: 0 })
90+
return
91+
}
92+
el.scrollBy({ left: target.getBoundingClientRect().left - containerLeft })
7793
}, [])
7894

79-
// Return to the current page (the end of the trail) in one click.
95+
// Reveal the next crumb toward the current page. Mirrors scrollLeftClick,
96+
// including the oversized-crumb case. Falls back to the far right only when
97+
// nothing is clipped right.
8098
const scrollRightClick = useCallback(() => {
8199
const el = scrollRef.current
82100
if (!el) return
83-
el.scrollTo({ left: el.scrollWidth })
101+
const containerRight = el.getBoundingClientRect().right - CHEVRON_PAD
102+
const crumbs = Array.from(el.querySelectorAll('li'))
103+
// The leftmost crumb still clipped/off to the right: the one to reveal.
104+
const target = crumbs.find((crumb) => crumb.getBoundingClientRect().right > containerRight + 1)
105+
if (!target) {
106+
el.scrollTo({ left: el.scrollWidth })
107+
return
108+
}
109+
el.scrollBy({ left: target.getBoundingClientRect().right - containerRight })
84110
}, [])
85111

86112
// When a crumb link receives focus via keyboard (Tab), the browser does not

src/landings/components/shared/LandingHero.module.scss

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,24 @@
5050
}
5151
}
5252

53-
// Where the art IS shown, reserve a right-side gutter so the text column wraps
54-
// before it reaches the artwork. Without this, the 48rem text block overlaps
55-
// the right-anchored isometric art in the ~866–1130px band, dropping the muted
56-
// intro to ~1.2:1 over the orange/pink fill (WCAG 1.4.3 AA fail). The art is
57-
// pinned to the border box (background-origin above), so this padding insets
58-
// only the text, not the artwork. 22rem clears the opaque art (~300px) plus a
59-
// gap at every width in the band.
53+
// Where the art IS shown, the intro can extend far enough right to overlap the
54+
// right-anchored isometric art (the heading is short enough that it never
55+
// does). Instead of reserving a right gutter that shrinks the text column, put
56+
// a frosted-glass panel behind the intro so it stays readable over the art
57+
// matching the homepage hero treatment (HomePageHero.module.scss `.content`).
58+
// Guarded to >=866px because the art is hidden below that, so no panel is
59+
// needed on mobile.
6060
@media (min-width: 866px) {
61-
.landingHero {
62-
padding-right: 22rem;
61+
.heroIntroScrim {
62+
display: inline-block;
63+
backdrop-filter: blur(1rem);
64+
background-color: color-mix(
65+
in srgb,
66+
var(--color-canvas-default) 70%,
67+
transparent
68+
);
69+
border-radius: var(--brand-borderRadius-medium, 0.75rem);
70+
padding: 0.5rem 0.75rem;
71+
margin: -0.5rem -0.75rem;
6372
}
6473
}

src/landings/components/shared/LandingHero.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHero
3333
{title}
3434
</Heading>
3535
{intro && (
36-
<Text as="div" size="200" variant="muted" className={styles.heroDescription}>
36+
<Text
37+
as="div"
38+
size="200"
39+
variant="muted"
40+
className={`${styles.heroDescription} ${styles.heroIntroScrim}`}
41+
>
3742
<RenderedHTML as="div" html={intro} />
3843
</Text>
3944
)}

src/workflows/purge-fastly-changed-content.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Octokit } from '@octokit/rest'
22

33
import { fetchWithRetry } from '@/frame/lib/fetch-utils'
44
import warmServer from '@/frame/lib/warm-server'
5+
import { allVersions } from '@/versions/lib/all-versions'
56
import github from './github'
67
import { getActionContext } from './action-context'
78

@@ -19,13 +20,31 @@ import { getActionContext } from './action-context'
1920
// fresh. By the time deployment succeeds, the old pods are already terminated
2021
// (Heaven waits for the rollout to fully reconcile), so this is deterministic.
2122
//
22-
// Scope: content/ only. data/ changes (reusables, variables, ...) fan out to
23-
// far too many URLs to enumerate cheaply, so they stay covered by the soft
24-
// purge of the whole language.
23+
// Scope: content/ only, and only the latest release of each plan (fpt, ghec,
24+
// latest ghes — see PURGEABLE_PAGE_VERSIONS). data/ changes (reusables,
25+
// variables, ...) fan out to far too many URLs to enumerate cheaply, so they
26+
// stay covered by the soft purge of the whole language.
2527

2628
const PROD_HOST = 'docs.github.com'
2729
const CONTENT_PREFIX = 'content/'
2830

31+
// Which page versions we hard-purge. A page that applies to every
32+
// version fans out to one URL per version: fpt + ghec + one per *supported* GHES
33+
// release (8+ URLs today, growing with each GHES release). Enumerating all of
34+
// them for a large deploy blew past Fastly's URL-purge rate limit (HTTP 429) and
35+
// failed the job. So we only target the newest ("latest") release of each plan:
36+
// free-pro-team (fpt), enterprise-cloud (ghec), and the newest supported
37+
// enterprise-server (ghes). Older still-supported GHES releases change far less
38+
// often and remain covered by the routine soft purge of the whole `language:en`
39+
// key that always runs. A version is the latest of its plan exactly when its
40+
// `version` equals its plan's `latestVersion`, so this set is {fpt, ghec, latest
41+
// ghes} and updates itself as GHES releases come and go.
42+
export const PURGEABLE_PAGE_VERSIONS = new Set(
43+
Object.values(allVersions)
44+
.filter((version) => version.version === version.latestVersion)
45+
.map((version) => version.version),
46+
)
47+
2948
// A defensive ceiling. A normal content deploy touches a handful of files ->
3049
// tens of URLs. If a deploy changes so much that we'd hard-purge more than this,
3150
// skip the targeted purge: the soft purge of the whole `language:en` key (which
@@ -216,14 +235,19 @@ export function contentFilesToEnglishUrls(
216235
return [...urls]
217236
}
218237

219-
// Build relativePath -> English permalink hrefs from the warmed page list.
238+
// Build relativePath -> English permalink hrefs from the warmed page list. Only
239+
// the latest release of each plan is kept (see PURGEABLE_PAGE_VERSIONS) to cap
240+
// the URL fan-out that was tripping Fastly's purge rate limiter.
220241
async function loadEnglishPermalinkIndex(): Promise<Map<string, string[]>> {
221242
const { pageList } = await warmServer(['en'])
222243
const index = new Map<string, string[]>()
223244
for (const page of pageList) {
224245
if (page.languageCode !== 'en') continue
225246
const hrefs = page.permalinks
226-
.filter((permalink) => permalink.languageCode === 'en')
247+
.filter(
248+
(permalink) =>
249+
permalink.languageCode === 'en' && PURGEABLE_PAGE_VERSIONS.has(permalink.pageVersion),
250+
)
227251
.map((permalink) => permalink.href)
228252
if (hrefs.length) {
229253
index.set(page.relativePath, hrefs)

0 commit comments

Comments
 (0)